Vòng đời trạng thái trong Compose

Trong Jetpack Compose, các hàm composable thường giữ state bằng hàm remember. Các giá trị được ghi nhớ có thể được sử dụng lại trong các lần kết hợp lại, như được giải thích trong phần Trạng thái và Jetpack Compose.

Mặc dù remember đóng vai trò là công cụ để duy trì các giá trị trong quá trình kết hợp lại, nhưng trạng thái thường cần tồn tại lâu hơn thời gian hoạt động của một cấu trúc. Trang này giải thích sự khác biệt giữa các API remember, retain, rememberSaveable và rememberSerializable, thời điểm chọn API nào và các phương pháp hay nhất để quản lý các giá trị được ghi nhớ và giữ lại trong Compose.

Chọn tuổi thọ chính xác

Trong Compose, có một số hàm bạn có thể dùng để duy trì trạng thái trên các thành phần và hơn thế nữa: remember, retain, rememberSaveable và rememberSerializable. Các hàm này khác nhau về thời gian hoạt động và ngữ nghĩa, đồng thời mỗi hàm đều phù hợp để lưu trữ các loại trạng thái cụ thể. Sự khác biệt được trình bày trong bảng sau:

remember

retain

rememberSaveable, rememberSerializable

Giá trị vẫn tồn tại sau khi kết hợp lại?

✅

✅

✅

Giá trị có tồn tại khi tạo lại hoạt động không?

❌

✅

Thực thể (===) giống nhau sẽ luôn được trả về

✅

Hệ thống sẽ trả về một đối tượng tương đương (==), có thể là một bản sao đã giải tuần tự

Giá trị có còn lại sau khi bị buộc tắt không?

❌

❌

✅

Loại dữ liệu được hỗ trợ

Tất cả

Không được tham chiếu đến bất kỳ đối tượng nào có thể bị rò rỉ nếu hoạt động bị huỷ

Phải chuyển đổi tuần tự được
(bằng Saver tuỳ chỉnh hoặc bằng kotlinx.serialization)

Trường hợp sử dụng

  • Các đối tượng trong phạm vi của thành phần
  • Đối tượng cấu hình cho thành phần kết hợp
  • Trạng thái có thể được tạo lại mà không làm mất độ chân thực của giao diện người dùng
  • Bộ nhớ đệm
  • Đối tượng tồn tại lâu dài hoặc đối tượng "trình quản lý"
  • Hoạt động đầu vào của người dùng
  • Trạng thái mà ứng dụng không thể tạo lại, bao gồm cả dữ liệu nhập vào trường văn bản, trạng thái cuộn, nút bật/tắt, v.v.

remember

remember là cách phổ biến nhất để lưu trữ trạng thái trong Compose. Khi remember được gọi lần đầu tiên, phép tính đã cho sẽ được thực thi và ghi nhớ, nghĩa là phép tính đó được Compose lưu trữ để thành phần kết hợp sử dụng lại trong tương lai. Khi một composable kết hợp lại, thành phần đó sẽ thực thi lại mã của mình, nhưng mọi lệnh gọi đến remember đều trả về giá trị của chúng từ composition trước đó thay vì thực thi lại phép tính.

Mỗi thực thể của một hàm composable đều có tập hợp giá trị được ghi nhớ riêng, được gọi là ghi nhớ vị trí. Khi các giá trị được ghi nhớ được ghi nhớ để sử dụng trong các quá trình kết hợp lại, chúng sẽ được liên kết với vị trí của chúng trong hệ phân cấp cấu trúc. Nếu một composable được dùng ở nhiều vị trí, thì mỗi thực thể trong hệ phân cấp composition sẽ có một tập hợp giá trị được ghi nhớ riêng.

Khi một giá trị được ghi nhớ không còn được sử dụng, giá trị đó sẽ bị bỏ qua và bản ghi của giá trị đó sẽ bị loại bỏ. Các giá trị được ghi nhớ sẽ bị xoá khi bị xoá khỏi hệ phân cấp composition (bao gồm cả khi một giá trị bị xoá và được thêm lại để di chuyển đến một vị trí khác mà không sử dụng composable key hoặc MovableContent) hoặc được gọi bằng các tham số key khác.

Trong số các lựa chọn có sẵn, remember có thời gian tồn tại ngắn nhất và quên các giá trị sớm nhất trong số 4 hàm ghi nhớ được mô tả trong trang này. Do đó, loại chiến dịch này phù hợp nhất cho:

  • Tạo các đối tượng trạng thái nội bộ, chẳng hạn như vị trí cuộn hoặc trạng thái ảnh động
  • Tránh tạo lại đối tượng tốn kém trên mỗi lần kết hợp lại

Tuy nhiên, bạn nên tránh:

  • Lưu trữ mọi hoạt động đầu vào của người dùng bằng remember, vì các đối tượng được ghi nhớ sẽ bị quên khi có thay đổi về cấu hình Hoạt động và sự kiện bị buộc tắt do hệ thống gây ra.

rememberSaveable và rememberSerializable

rememberSaveable và rememberSerializable được xây dựng dựa trên remember. Chúng có tuổi thọ dài nhất trong số các hàm ghi nhớ được thảo luận trong hướng dẫn này. Ngoài việc ghi nhớ đối tượng theo vị trí trong các lần kết hợp lại, API này cũng có thể lưu các giá trị để có thể khôi phục các giá trị đó trong quá trình tạo lại hoạt động, bao gồm cả các thay đổi về cấu hình và bị buộc tắt (khi hệ thống buộc tắt quy trình của ứng dụng khi ứng dụng đang chạy ở chế độ nền, thường là để giải phóng bộ nhớ cho các ứng dụng trên nền trước hoặc nếu người dùng thu hồi quyền của ứng dụng khi ứng dụng đang chạy).

rememberSerializable hoạt động giống như rememberSaveable, nhưng tự động hỗ trợ việc duy trì các loại phức tạp có thể chuyển đổi tuần tự bằng thư viện kotlinx.serialization. Chọn rememberSerializable nếu loại của bạn được (hoặc có thể được) đánh dấu bằng @Serializable và rememberSaveable trong tất cả các trường hợp khác.

Điều này khiến cả rememberSaveable và rememberSerializable trở thành lựa chọn hoàn hảo để lưu trữ trạng thái liên quan đến hoạt động đầu vào của người dùng, bao gồm cả mục nhập trường văn bản, vị trí cuộn, trạng thái bật/tắt, v.v. Bạn nên lưu trạng thái này để đảm bảo người dùng không bao giờ mất dấu. Nói chung, bạn nên sử dụng rememberSaveable hoặc rememberSerializable để ghi nhớ mọi trạng thái mà ứng dụng của bạn không thể truy xuất từ một nguồn dữ liệu cố định khác, chẳng hạn như cơ sở dữ liệu.

Lưu ý rằng rememberSaveable và rememberSerializable lưu các giá trị được ghi nhớ bằng cách chuyển đổi tuần tự các giá trị đó thành Bundle. Điều này có hai hệ quả:

  • Các giá trị bạn ghi nhớ phải được biểu thị bằng một hoặc nhiều kiểu dữ liệu sau: Kiểu dữ liệu gốc (bao gồm Int, Long, Float, Double), String hoặc mảng của bất kỳ kiểu dữ liệu nào trong số này.
  • Khi một giá trị đã lưu được khôi phục, đó sẽ là một thực thể mới bằng (==), nhưng không phải là cùng một tệp đối chiếu (===) mà thành phần đã sử dụng trước đó.

Để lưu trữ các loại dữ liệu phức tạp hơn mà không cần sử dụng kotlinx.serialization, bạn có thể triển khai Saver tuỳ chỉnh để chuyển đổi tuần tự và chuyển đổi tuần tự ngược đối tượng thành các loại dữ liệu được hỗ trợ. Xin lưu ý rằng Compose hiểu các kiểu dữ liệu phổ biến như State, List, Map, Set, v.v. ngay từ đầu và tự động chuyển đổi các kiểu dữ liệu này thành các kiểu dữ liệu được hỗ trợ thay cho bạn. Sau đây là ví dụ về Saver cho lớp Size. Hàm này được triển khai bằng cách đóng gói tất cả thuộc tính của Size vào một danh sách bằng listSaver.

data class Size(val x: Int, val y: Int) {
    object Saver : androidx.compose.runtime.saveable.Saver<Size, Any> by listSaver(
        save = { listOf(it.x, it.y) },
        restore = { Size(it[0], it[1]) }
    )
}

@Composable
fun rememberSize(x: Int, y: Int) {
    rememberSaveable(x, y, saver = Size.Saver) {
        Size(x, y)
    }
}

retain

API retain nằm giữa remember và rememberSaveable/rememberSerializable về thời gian ghi nhớ các giá trị của API này. Hàm này có tên khác vì các giá trị được giữ lại cũng có vòng đời khác với các giá trị được ghi nhớ.

Khi một giá trị được giữ lại, giá trị đó sẽ được ghi nhớ theo vị trí và được lưu trong một cấu trúc dữ liệu phụ có vòng đời riêng biệt gắn liền với vòng đời của ứng dụng. Giá trị được giữ lại có thể tồn tại sau khi thay đổi cấu hình mà không cần chuyển đổi tuần tự, nhưng không thể tồn tại sau khi bị buộc tắt. Nếu một giá trị không được sử dụng sau khi hệ phân cấp thành phần được tạo lại, thì giá trị được giữ lại sẽ bị loại bỏ (tương đương với việc bị quên trong retain).

Để đổi lấy vòng đời ngắn hơn rememberSaveable này, retain có thể duy trì các giá trị không thể chuyển đổi tuần tự, chẳng hạn như biểu thức lambda, luồng và các đối tượng lớn như bitmap. Ví dụ: bạn có thể sử dụng retain để quản lý trình phát nội dung nghe nhìn (chẳng hạn như ExoPlayer) nhằm ngăn chặn tình trạng gián đoạn khi phát nội dung nghe nhìn trong quá trình thay đổi cấu hình.

@Composable
fun MediaPlayer() {
    // Use the application context to avoid a memory leak
    val applicationContext = LocalContext.current.applicationContext
    val exoPlayer = retain { ExoPlayer.Builder(applicationContext).apply { /* ... */ }.build() }
    // ...
}

retain đấu với ViewModel

Về cơ bản, cả retain và ViewModel đều cung cấp chức năng tương tự nhau trong khả năng được sử dụng phổ biến nhất để duy trì các thực thể đối tượng trong suốt quá trình thay đổi cấu hình. Việc chọn retain hay ViewModel phụ thuộc vào loại giá trị bạn đang duy trì, phạm vi của giá trị đó và liệu bạn có cần thêm chức năng hay không.

ViewModel là các đối tượng thường đóng gói hoạt động giao tiếp giữa giao diện người dùng và lớp dữ liệu của ứng dụng. Các lớp này cho phép bạn di chuyển logic ra khỏi các hàm composable, giúp cải thiện khả năng kiểm thử. ViewModel được quản lý dưới dạng singleton trong ViewModelStore và có thời gian hoạt động khác với các giá trị được giữ lại. Mặc dù ViewModel sẽ vẫn hoạt động cho đến khi ViewModelStore của nó bị huỷ, nhưng các giá trị được giữ lại sẽ bị loại bỏ khi nội dung bị xoá vĩnh viễn khỏi thành phần (ví dụ: đối với thay đổi về cấu hình, điều này có nghĩa là giá trị được giữ lại sẽ bị loại bỏ nếu hệ phân cấp giao diện người dùng được tạo lại và giá trị được giữ lại không được sử dụng sau khi thành phần được tạo lại).

ViewModel cũng bao gồm các tính năng tích hợp sẵn để chèn phần phụ thuộc với Dagger và Hilt, tích hợp với SavedState và hỗ trợ coroutine tích hợp sẵn để chạy các tác vụ trong nền. Điều này khiến ViewModel trở thành nơi lý tưởng để chạy các tác vụ trong nền và yêu cầu mạng, tương tác với các nguồn dữ liệu khác trong dự án, đồng thời tuỳ ý ghi lại và duy trì trạng thái giao diện người dùng quan trọng cần được giữ lại trong các thay đổi về cấu hình trong ViewModel và duy trì khi bị buộc tắt.

retain phù hợp nhất với các đối tượng thuộc phạm vi của các thực thể composable cụ thể và không yêu cầu sử dụng lại hoặc chia sẻ giữa các composable đồng cấp. Trong khi ViewModel là nơi phù hợp để lưu trữ trạng thái giao diện người dùng và thực hiện các tác vụ trong nền, thì retain là lựa chọn phù hợp để lưu trữ các đối tượng cho hệ thống giao diện người dùng như bộ nhớ đệm, trình theo dõi lượt hiển thị và số liệu phân tích, các phần phụ thuộc trên AndroidView và các đối tượng khác tương tác với Hệ điều hành Android hoặc quản lý các thư viện bên thứ ba như công ty xử lý thanh toán hoặc quảng cáo.

Đối với người dùng nâng cao thiết kế các mẫu cấu trúc ứng dụng tuỳ chỉnh ngoài các đề xuất về Cấu trúc ứng dụng Android hiện đại: retain cũng có thể được dùng để xây dựng một API nội bộ "giống ViewModel". Mặc dù không hỗ trợ sẵn coroutine và trạng thái đã lưu, nhưng retain có thể đóng vai trò là thành phần xây dựng cho vòng đời của các thành phần giống ViewModel có các tính năng này. Thông tin cụ thể về cách thiết kế một thành phần như vậy nằm ngoài phạm vi của hướng dẫn này.

retain

ViewModel

Xác định phạm vi

Không có giá trị dùng chung; mỗi giá trị được giữ lại và liên kết với một điểm cụ thể trong hệ phân cấp cấu trúc. Việc giữ lại cùng một loại ở một vị trí khác luôn hoạt động trên một thực thể mới.

ViewModel là các singleton trong một ViewModelStore

Sự huỷ diệt

Khi rời khỏi hệ phân cấp thành phần vĩnh viễn

Khi ViewModelStore bị xoá hoặc huỷ

Chức năng bổ sung

Có thể nhận lệnh gọi lại khi đối tượng nằm trong hệ phân cấp thành phần hoặc không

coroutineScope tích hợp sẵn, hỗ trợ SavedStateHandle, có thể chèn bằng Hilt

Do

RetainedValuesStore

ViewModelStore

Trường hợp sử dụng

  • Lưu giữ các giá trị dành riêng cho giao diện người dùng cục bộ cho từng phiên bản thành phần kết hợp
  • Theo dõi lượt hiển thị, có thể thông qua RetainedEffect
  • Thành phần cơ bản để xác định thành phần cấu trúc "giống ViewModel" tuỳ chỉnh
  • Trích xuất các hoạt động tương tác giữa giao diện người dùng và lớp dữ liệu vào một lớp riêng biệt, cho cả việc tổ chức mã và kiểm thử
  • Chuyển đổi đối tượng Flow thành đối tượng State và gọi các hàm tạm ngưng không bị gián đoạn do thay đổi cấu hình
  • Chia sẻ trạng thái trên các vùng giao diện người dùng lớn, chẳng hạn như toàn bộ màn hình
  • Khả năng tương tác với View

Kết hợp retain và rememberSaveable hoặc rememberSerializable

Đôi khi, một đối tượng cần có vòng đời kết hợp của cả retained và rememberSaveable hoặc rememberSerializable. Đây có thể là dấu hiệu cho thấy đối tượng của bạn phải là ViewModel, có thể hỗ trợ trạng thái đã lưu như mô tả trong hướng dẫn về mô-đun Trạng thái đã lưu cho ViewModel.

Bạn có thể sử dụng đồng thời retain và rememberSaveable hoặc rememberSerializable. Việc kết hợp chính xác cả hai vòng đời sẽ làm tăng đáng kể độ phức tạp. Bạn nên sử dụng mẫu này như một phần của các mẫu kiến trúc nâng cao và tuỳ chỉnh hơn, và chỉ khi tất cả những điều sau đây là đúng:

  • Bạn đang xác định một đối tượng bao gồm nhiều giá trị phải được giữ lại hoặc lưu (ví dụ: một đối tượng theo dõi hoạt động đầu vào của người dùng và một bộ nhớ đệm trong bộ nhớ không thể ghi vào ổ đĩa)
  • Trạng thái của bạn thuộc phạm vi của một thành phần kết hợp và không phù hợp với phạm vi hoặc vòng đời của singleton ViewModel

Khi tất cả những điều này xảy ra, bạn nên chia lớp của mình thành ba phần: Dữ liệu đã lưu, dữ liệu được giữ lại và một đối tượng "trung gian" không có trạng thái riêng và uỷ quyền cho các đối tượng được giữ lại và đã lưu để cập nhật trạng thái tương ứng. Mẫu này có dạng như sau:

@Composable
fun rememberAndRetain(): CombinedRememberRetained {
    val saveData = rememberSerializable(serializer = serializer<ExtractedSaveData>()) {
        ExtractedSaveData()
    }
    val retainData = retain { ExtractedRetainData() }
    return remember(saveData, retainData) {
        CombinedRememberRetained(saveData, retainData)
    }
}

@Serializable
data class ExtractedSaveData(
    // All values that should persist process death should be managed by this class.
    var savedData: AnotherSerializableType = defaultValue()
)

class ExtractedRetainData {
    // All values that should be retained should appear in this class.
    // It's possible to manage a CoroutineScope using RetainObserver.
    // See the full sample for details.
    var retainedData = Any()
}

class CombinedRememberRetained(
    private val saveData: ExtractedSaveData,
    private val retainData: ExtractedRetainData,
) {
    fun doAction() {
        // Manipulate the retained and saved state as needed.
    }
}

Bằng cách tách biệt trạng thái theo vòng đời, việc tách biệt trách nhiệm và lưu trữ sẽ trở nên rất rõ ràng. Việc dữ liệu lưu không thể được thao tác bằng dữ liệu giữ lại là có chủ đích, vì điều này ngăn chặn trường hợp cố gắng cập nhật dữ liệu lưu khi gói savedInstanceState đã được thu thập và không thể cập nhật. API này cũng cho phép kiểm thử các trường hợp tạo lại bằng cách kiểm thử hàm khởi tạo mà không cần gọi vào Compose hoặc mô phỏng quá trình tạo lại Hoạt động.

Xem mẫu đầy đủ (RetainAndSaveSample.kt) để biết ví dụ hoàn chỉnh về cách triển khai mẫu này.

Ghi nhớ vị trí và bố cục thích ứng

Ứng dụng Android có thể hỗ trợ nhiều kiểu dáng, bao gồm điện thoại, thiết bị gập, máy tính bảng và máy tính để bàn. Các ứng dụng thường cần chuyển đổi giữa các kiểu dáng này bằng cách sử dụng bố cục thích ứng. Ví dụ: một ứng dụng chạy trên máy tính bảng có thể hiển thị chế độ xem danh sách-chi tiết hai cột, nhưng có thể điều hướng giữa trang danh sách và trang thông tin chi tiết khi hiển thị trên màn hình điện thoại nhỏ hơn.

Vì các giá trị được ghi nhớ và giữ lại được ghi nhớ theo vị trí, nên chúng chỉ được sử dụng lại nếu xuất hiện ở cùng một điểm trong hệ phân cấp cấu trúc. Khi bố cục thích ứng với nhiều kiểu dáng, chúng có thể thay đổi cấu trúc của hệ phân cấp thành phần và dẫn đến các giá trị bị bỏ quên.

Đối với các thành phần có sẵn như ListDetailPaneScaffold và NavDisplay (từ Jetpack Navigation 3), đây không phải là vấn đề và trạng thái của bạn sẽ được duy trì trong suốt quá trình thay đổi bố cục. Đối với các thành phần tuỳ chỉnh thích ứng với hệ số hình dạng, hãy đảm bảo rằng trạng thái không bị ảnh hưởng bởi các thay đổi về bố cục bằng cách thực hiện một trong những thao tác sau:

  • Đảm bảo rằng các thành phần kết hợp có trạng thái luôn được gọi ở cùng một vị trí trong hệ phân cấp thành phần. Triển khai bố cục thích ứng bằng cách thay đổi logic bố cục thay vì di dời các đối tượng trong hệ phân cấp thành phần kết hợp.
  • Sử dụng MovableContent để di dời các thành phần kết hợp có trạng thái một cách linh hoạt. Các thực thể của MovableContent có thể di chuyển các giá trị được ghi nhớ và giữ lại từ vị trí cũ sang vị trí mới.

Ghi nhớ các hàm nhà máy

Mặc dù giao diện người dùng Compose được tạo thành từ các hàm composable, nhưng nhiều đối tượng sẽ tham gia vào việc tạo và sắp xếp một composition. Ví dụ phổ biến nhất về trường hợp này là các đối tượng composable phức tạp xác định state riêng, chẳng hạn như LazyList chấp nhận LazyListState.

Khi xác định các đối tượng tập trung vào Compose, bạn nên tạo một hàm remember để xác định hành vi ghi nhớ dự kiến, bao gồm cả tuổi thọ và dữ liệu đầu vào chính. Điều này cho phép người dùng trạng thái của bạn tự tin tạo các thực thể trong hệ phân cấp cấu trúc sẽ tồn tại và bị vô hiệu hoá như mong đợi. Khi xác định hàm nhà máy có khả năng kết hợp, hãy làm theo các nguyên tắc sau:

  • Thêm tiền tố remember vào tên hàm. Nếu việc triển khai hàm phụ thuộc vào đối tượng là retained và API sẽ không bao giờ phát triển để dựa vào một biến thể khác của remember, thì bạn có thể sử dụng tiền tố retain.
  • Sử dụng rememberSaveable hoặc rememberSerializable nếu bạn chọn trạng thái duy trì và có thể viết phương thức triển khai Saver chính xác.
  • Tránh các tác dụng phụ hoặc khởi tạo giá trị dựa trên CompositionLocal có thể không liên quan đến việc sử dụng. Hãy nhớ rằng nơi tạo trạng thái có thể không phải là nơi sử dụng trạng thái.

@Composable
fun rememberImageState(
    imageUri: String,
    initialZoom: Float = 1f,
    initialPanX: Int = 0,
    initialPanY: Int = 0
): ImageState {
    return rememberSaveable(imageUri, saver = ImageState.Saver) {
        ImageState(
            imageUri, initialZoom, initialPanX, initialPanY
        )
    }
}

data class ImageState(
    val imageUri: String,
    val zoom: Float,
    val panX: Int,
    val panY: Int
) {
    object Saver : androidx.compose.runtime.saveable.Saver<ImageState, Any> by listSaver(
        save = { listOf(it.imageUri, it.zoom, it.panX, it.panY) },
        restore = { ImageState(it[0] as String, it[1] as Float, it[2] as Int, it[3] as Int) }
    )
}