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

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

Mặc dù remember đóng vai trò là một 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 vòng đời của một thành phần. Trang này giải thích sự khác biệt giữa các API remember, retain, rememberSaveablerememberSerializable, 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ị đã ghi nhớ và giữ lại trong Compose.

Chọn thời gian tồn tại chính xác

Trong Compose, có một số hàm 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, rememberSaveablerememberSerializable. Các hàm này khác nhau về thời gian tồn tại và ngữ nghĩa, đồng thời mỗi hàm phù hợp để lưu trữ các loại trạng thái cụ thể. Bảng sau đây trình bày những điểm khác biệt:

remember

retain

rememberSaveable, rememberSerializable

Giá trị vẫn tiếp tục có hiệu lực khi kết hợp lại?

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

Hệ thống sẽ luôn trả về cùng một thực thể (===)

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

Các giá trị vẫn tồn tại sau khi bị buộc tắt?

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 sẽ bị rò rỉ nếu hoạt động bị huỷ

Phải có thể chuyển đổi thành chuỗi
(bằng Saver tuỳ chỉnh hoặc bằng kotlinx.serialization)

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

  • Các đối tượng được đặt phạm vi cho thành phần
  • Đối tượng cấu hình cho các 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 độ trung thực của giao diện người dùng
  • Bộ nhớ đệm
  • Các đố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ả nội dung 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 đó sẽ đượ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 thành phần kết hợp 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 sẽ trả về các giá trị từ thành phần trước đó thay vì thực thi lại phép tính.

Mỗi thực thể của một hàm có khả năng kết hợp đều có một tập hợp giá trị được ghi nhớ riêng, được gọi là ghi nhớ theo vị trí. Khi các giá trị được ghi nhớ được ghi nhớ để sử dụng trong các lần 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 thành phần. Nếu một thành phần kết hợp được dùng ở nhiều vị trí, thì mỗi thực thể trong hệ phân cấp thành phần sẽ có một nhóm giá trị riêng được ghi nhớ.

Khi không còn được dùng nữa, giá trị được ghi nhớ sẽ bị quên và bản ghi của giá trị đó sẽ bị loại bỏ. Các giá trị đã ghi nhớ sẽ bị quên khi bị xoá khỏi hệ phân cấp thành phần (bao gồm cả khi một giá trị bị xoá và thêm lại để di chuyển đến một vị trí khác mà không dùng thành phần kết hợp 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ó tuổi thọ 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. Điều này khiến phương pháp này phù hợp nhất với:

  • 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 việc 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 dữ liệu đầu vào của người dùng bằng remember, vì các đối tượng đã lưu sẽ bị quên trong cá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.

rememberSaveablerememberSerializable

rememberSaveablerememberSerializable đượ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ớ các đối tượng theo vị trí trong các lần tái cấu trúc, save cũng có thể lưu các giá trị để có thể khôi phục chúng trong các lần tạo lại hoạt động, kể cả khi có thay đổi về cấu hình và sự kiện bị buộc tắt do hệ thống gây ra (khi hệ thống buộc tắt quy trình của ứng dụng trong 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 chạy ở chế độ nền trước hoặc nếu người dùng thu hồi quyền của ứng dụng trong khi ứng dụng đang chạy).

rememberSerializable hoạt động giống như rememberSaveable, nhưng tự động hỗ trợ duy trì các loại phức tạp có thể được 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ể) đánh dấu bằng @SerializablerememberSaveable trong tất cả các trường hợp khác.

Điều này khiến cả rememberSaveablerememberSerializable đều là những lựa chọn hoàn hảo để lưu trữ trạng thái liên kết với dữ liệu đầ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ờ bị mất vị trí. Nói chung, bạn nên dùng rememberSaveable hoặc rememberSerializable để ghi nhớ mọi trạng thái mà ứng dụng không thể truy xuất từ một nguồn dữ liệu liên tục khác, chẳng hạn như cơ sở dữ liệu.

Xin lưu ý rằng rememberSaveablerememberSerializable lưu các giá trị được ghi nhớ bằng cách chuyển đổi các giá trị đó thành Bundle. Điều này có hai hệ quả:

  • Bạn phải biểu thị các giá trị mà bạn ghi nhớ bằng một hoặc nhiều kiểu dữ liệu sau: Kiểu dữ liệu nguyên thuỷ (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 được khôi phục, giá trị đã lưu sẽ là một thực thể mới bằng (==) nhưng không phải là cùng một giá trị tham 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 dùng kotlinx.serialization, bạn có thể triển khai một Saver tuỳ chỉnh để chuyển đổi đối tượng của bạn thành các loại dữ liệu được hỗ trợ và ngược lại. 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 được hỗ trợ thay cho bạn. Sau đây là ví dụ về Saver cho lớp Size. Thao tác này được triển khai bằng cách đóng gói tất cả các thuộc tính của Size vào một danh sách bằng cách sử dụ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 rememberrememberSaveable/rememberSerializable về thời gian ghi nhớ các giá trị. Nó có tên khác vì các giá trị được giữ lại cũng trải qua một vòng đời khác so 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à lưu trong một cấu trúc dữ liệu phụ có thời gian tồn tại riêng biệt, gắn liền với thời gian tồn tại của ứng dụng. Giá trị được giữ lại có thể tồn tại sau các thay đổi về cấu hình mà không cần được 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 dùng sau khi hệ thống tạo lại hệ phân cấp thành phần, thì giá trị được giữ lại sẽ không còn được dùng nữa (tương đương với việc retain bị quên).

Để đổi lấy vòng đời ngắn hơn rememberSaveable, retain có thể duy trì các giá trị không thể được 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ể dùng retain để quản lý một 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 quá trình 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 gặp ViewModel

Về cơ bản, cả retainViewModel đều cung cấp chức năng tương tự trong khả năng thường dùng nhất của chúng là duy trì các thực thể đối tượng qua các thay đổi về cấu hình. Lựa chọn sử dụng retain hay ViewModel phụ thuộc vào loại giá trị mà bạn đang duy trì, cách bạn nên đặt phạm vi 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à các lớp dữ liệu của ứng dụng. Chúng cho phép bạn di chuyển logic ra khỏi các hàm kết hợp, 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 tồn tại 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 một thay đổi về cấu hình, điều này có nghĩa là một 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 chế độ tích hợp sẵn để chèn phần phụ thuộc bằng Dagger và Hilt, tích hợp với SavedState và hỗ trợ coroutine tích hợp để chạy các tác vụ ở chế độ 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 của bạ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à tồn tại khi bị buộc tắt.

retain phù hợp nhất với những đối tượng được giới hạn trong các thực thể kết hợp cụ thể và không yêu cầu sử dụng lại hoặc chia sẻ giữa các thành phần kết hợp ngang hàng. Trong đó, ViewModel đóng vai trò 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ụ ở chế độ nền, retain là lựa chọn phù hợp để lưu trữ các đối tượng cho cơ sở hạ tầng giao diện người dùng như bộ nhớ đệm, tính năng theo dõi lượt hiển thị và phân tích, các phần phụ thuộc vào 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 của bên thứ ba như bộ xử lý thanh toán hoặc quảng cáo.

Đối với những người dùng nâng cao thiết kế các mẫu kiến trúc ứng dụng tuỳ chỉnh bên ngoài Đề xuất về kiến trúc ứng dụng Android hiện đại: retain cũng có thể được dùng để tạo API "tương tự như ViewModel" nội bộ. Mặc dù không được hỗ trợ sẵn, nhưng coroutine và trạng thái đã lưu có thể đóng vai trò là khối dựng cho vòng đời của những ViewModel tương tự như vậy với các tính năng này được xây dựng trên cùng.retain 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

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 thành phần. 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 phiên bản mới.

ViewModel là các singleton trong ViewModelStore

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ể được chèn bằng Hilt

Chủ sở hữu:

RetainedValuesStore

ViewModelStore

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

  • Duy trì các giá trị dành riêng cho giao diện người dùng cục bộ đối với từng thực thể thành phần kết hợp
  • Theo dõi lượt hiển thị, có thể thông qua RetainedEffect
  • Khối dựng để xác định thành phần kiến trúc tuỳ chỉnh "tương tự như ViewModel"
  • 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 Flow thành các đối tượng State và gọi các hàm tạm ngưng không được thay đổi cấu hình làm gián đoạn
  • 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 retainrememberSaveable hoặc rememberSerializable

Đôi khi, một đối tượng cần có tuổi thọ kết hợp của cả retainedrememberSaveable 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 retainrememberSaveable 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 trong các mẫu kiến trúc tuỳ chỉnh và nâng cao hơn, đồng thời chỉ khi tất cả các điều kiện sau đều đú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 thông tin đầu vào của người dùng và bộ nhớ đệm trong bộ nhớ không thể ghi vào đĩa)
  • Trạng thái của bạn được giới hạn trong 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 3 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 cho phù hợp. Mẫu này có hình 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 trạng thái theo thời gian tồn tại, việc tách biệt trách nhiệm và bộ nhớ trở nên rất rõ ràng. Việc dữ liệu đã lưu không thể bị thao tác bằng dữ liệu giữ lại là có chủ ý, vì điều này ngăn chặn trường hợp dữ liệu đã lưu được cập nhật khi gói savedInstanceState đã được ghi lại và không thể cập nhật. Thư viện 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ử các 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.

Hãy 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

Các ứng dụng Android có thể hỗ trợ nhiều kiểu dáng, bao gồm điện thoại, thiết bị có thể gập lại, 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 hệ số hình 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 chi tiết về danh sách gồm 2 cột, nhưng có thể điều hướng giữa danh sách và trang chi tiết khi xuất hiện 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 dùng lại nếu xuất hiện ở cùng một điểm trong hệ phân cấp thành phần. Khi bố cục của bạn thích ứng với nhiều kiểu dáng, bố cục đó có thể làm thay đổi cấu trúc của hệ thống phân cấp thành phần và dẫn đến các giá trị bị quên.

Đối với các thành phần có sẵn như ListDetailPaneScaffoldNavDisplay (từ Jetpack Navigation 3), đây không phải là vấn đề và trạng thái của bạn sẽ 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 chuyển các đối tượng trong hệ phân cấp thành phần.
  • Dùng MovableContent để di chuyển các thành phần kết hợp có trạng thái một cách mượt mà. 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 gốc

Mặc dù giao diện người dùng Compose được tạo thành từ các hàm có khả năng kết hợp, nhưng nhiều đối tượng sẽ tham gia vào quá trình tạo và sắp xếp một thành phần kết hợp. Ví dụ phổ biến nhất về trường hợp này là các đối tượng thành phần kết hợp phức tạp xác định trạng thái riêng, chẳng hạn như LazyList, chấp nhận một 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ả thời gian tồn tại và các đầ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ệ thống phân cấp thành phần sẽ tồn tại và bị vô hiệu hoá như dự kiến. Khi xác định một hàm tạo có khả năng kết hợp, hãy tuân 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, hãy dùng tiền tố retain.
  • Sử dụng rememberSaveable hoặc rememberSerializable nếu bạn chọn duy trì trạng thái và có thể viết một cách triển khai Saver chính xác.
  • Tránh các tác dụng phụ hoặc giá trị khởi tạo dựa trên CompositionLocal có thể không liên quan đến việc sử dụng. Hãy nhớ rằng trạng thái của bạn có thể được tạo ở một nơi nhưng lại được sử dụng ở một nơi khác.

@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) }
    )
}