Vòng đời thành phần kết hợp (composable)

Trong trang này, bạn sẽ tìm hiểu về vòng đời (lifecycle) của thành phần kết hợp cũng như cách Compose quyết định liệu thành phần kết hợp có cần kết hợp lại hay không.

Tổng quan về vòng đời

Như đã đề cập trong Tài liệu về quản lý trạng thái, thành phần Compose mô tả Giao diện người dùng của ứng dụng và được tạo ra bằng cách chạy các thành phần kết hợp. Một thành phần Compose là cấu trúc dạng cây của các thành phần kết hợp mô tả Giao diện người dùng.

Khi Jetpack Compose chạy các thành phần kết hợp lần đầu tiên, trong quá trình kết hợp ban đầu, bộ công cụ này sẽ theo dõi các thành phần kết hợp bạn gọi để mô tả Giao diện người dùng trong một thành phần Compose. Sau đó, khi trạng thái ứng dụng thay đổi, Jetpack Compose sẽ lên lịch kết hợp lại. Quá trình kết hợp lại xảy ra khi Jetpack Compose tái thực thi các thành phần kết hợp có thể thay đổi theo thay đổi về trạng thái, tiếp đến cập nhật thành phần Compose để phản ánh mọi thay đổi.

Thành phần Compose chỉ có thể được quá trình kết hợp ban đầu tạo ra và cập nhật bằng quá trình kết hợp lại. Phương pháp duy nhất để chỉnh sửa thành phần Compose là kết hợp lại.

Sơ đồ hiển thị vòng đời của một thành phần kết hợp

Hình 1. Vòng đời của một thành phần kết hợp trong thành phần Compose. Thành phần kết hợp này sẽ được nhập vào thành phần Compose, kết hợp lại từ 0 lần trở lên, cuối cùng ra khỏi thành phần Compose.

Quá trình kết hợp lại thường được kích hoạt khi có thay đổi đối với đối tượng State<T>. Compose sẽ theo dõi quá trình thay đổi này, đồng thời chạy tất cả thành phần kết hợp trong thành phần Compose có khả năng đọc State<T> và bất kỳ thành phần kết hợp quá trình này gọi mà không thể bỏ qua.

Nếu một thành phần kết hợp được gọi nhiều lần thì nhiều thực thể sẽ được đặt trong thành phần Compose. Mỗi lệnh gọi có vòng đời riêng trong thành phần Compose.

@Composable
fun MyComposable() {
    Column {
        Text("Hello")
        Text("World")
    }
}

Sơ đồ cho thấy cách sắp xếp phân cấp của các phần tử trong đoạn mã trước

Hình 2. Giá trị đại diện của MyComposable trong thành phần Compose. Nếu một thành phần kết hợp được gọi nhiều lần, thì nhiều thực thể sẽ được đặt trong thành phần Compose. Phần tử có màu riêng biểu thị một thực thể riêng biệt.

Phân tích một thành phần kết hợp trong thành phần Compose

Thực thể của một thành phần kết hợp trong thành phần Compose được xác định qua vị trí gọi. Trình biên dịch Compose xem mỗi vị trí gọi hoàn toàn riêng biệt với nhau. Thao tác gọi các thành phần kết hợp từ nhiều vị trí gọi sẽ tạo ra nhiều thực thể của thành phần kết hợp trong thành phần Compose.

Nếu trong quá trình kết hợp lại một thành phần kết hợp gọi các thành phần kết hợp khác những thành phần đã gọi trong quá trình kết hợp trước, Compose sẽ xác định các thành phần kết hợp nào đã được gọi. Đồng thời, đối với các thành phần được gọi trong cả hai quá trình kết hợp, Compose sẽ tránh kết hợp lại nếu giá trị đầu vào không đổi.

Việc bảo tồn mã nhận dạng là yếu tố quan trọng nhằm liên kết các hiệu ứng phụ với thành phần kết hợp. Nhờ đó, các hiệu ứng phụ này có thể hoàn tất thành công thay vì phải tái khởi động cho mỗi quá trình kết hợp lại.

Hãy xem ví dụ sau đây:

@Composable
fun LoginScreen(showError: Boolean) {
    if (showError) {
        LoginError()
    }
    LoginInput() // This call site affects where LoginInput is placed in Composition
}

@Composable
fun LoginInput() { /* ... */ }

Trong đoạn mã trên, LoginScreen sẽ gọi thành phần kết hợp LoginError theo điều kiện và sẽ luôn gọi thành phần kết hợp LoginInput. Mỗi lệnh gọi có một vị trí gọi và địa điểm nguồn duy nhất mà trình biên dịch sẽ sử dụng để xác định.

Sơ đồ cho thấy cách kết hợp lại mã trên khi cờ showError được thay đổi thành true. Bổ sung thành phần kết hợp LoginError, nhưng không kết hợp lại các thành phần kết hợp khác.

Hình 3. Giá trị đại diện của LoginScreen trong thành phần Compose khi trạng thái thay đổi và quá trình kết hợp lại diễn ra. Cùng màu có nghĩa mã này vẫn chưa được kết hợp lại.

Mặc dù mức độ ưu tiên của việc gọi LoginInput chuyển từ thứ nhất sang thứ hai, nhưng LoginInput vẫn sẽ được giữ nguyên trong các thành phần kết hợp lại. Ngoài ra, do LoginInput không có bất kỳ tham số nào thay đổi trong quá trình kết hợp lại, Compose sẽ bỏ qua lệnh gọi LoginInput.

Thêm thông tin bổ sung nhằm hỗ trợ quá trình kết hợp lại thông minh

Việc gọi một thành phần kết hợp nhiều lần cũng sẽ thêm thành phần đó vào thành phần kết hợp Compose nhiều lần. Khi gọi thành phần kết hợp nhiều lần từ cùng một vị trí gọi, Compose không có bất kỳ thông tin nào để nhận dạng từng lệnh gọi đến thành phần trên. Do đó, thứ tự thực thi được sử dụng cùng vị trí gọi để tách biệt các thực thể. Hành vi này đôi khi cần thiết, nhưng trong một số trường hợp có thể gây ra hành vi không mong muốn.

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            // MovieOverview composables are placed in Composition given its
            // index position in the for loop
            MovieOverview(movie)
        }
    }
}

Trong ví dụ trên, Compose sử dụng thứ tự thực thi ngoài vị trí gọi để tách biệt thực thể trong thành phần Compose. Nếu một movie mới được thêm vào cuối của danh sách thì Compose có thể tái sử dụng các thực thể có sẵn trong thành phần Compose vì vị trí của chúng trong danh sách chưa thay đổi, do đó, giá trị đầu vào của những thực thể trên movie vẫn còn nguyên vẹn.

Sơ đồ cho thấy cách kết hợp lại mã trên nếu phần tử mới được thêm vào cuối danh sách. Các mục khác trong danh sách chưa thay đổi vị trí và sẽ không được kết hợp lại.

Hình 4. Giá trị đại diện của MoviesScreen trong thành phần Compose khi thêm một phần tử mới vào cuối danh sách. Có thể tái sử dụng thành phần kết hợp MovieOverview trong thành phần Compose MovieOverview cùng màu có nghĩa thành phần kết hợp chưa được kết hợp lại.

Tuy nhiên, nếu danh sách movies thay đổi bằng cách thêm vào phần đầu hoặc phần giữa thì việc xoá hoặc sắp xếp lại các mục sẽ tạo ra quá trình kết hợp lại trong tất cả lệnh gọi MovieOverview có tham số đầu vào đã thay đổi vị trí trong danh sách. Thao tác này có thể gây ảnh hưởng vô cùng nghiêm trọng. Chẳng hạn như MovieOverview tìm nạp hình ảnh phim bằng hiệu ứng phụ. Nếu quá trình kết hợp lại diễn ra khi hiệu ứng đang tiến hành thì hiệu ứng đó sẽ bị huỷ và phải bắt đầu lại.

@Composable
fun MovieOverview(movie: Movie) {
    Column {
        // Side effect explained later in the docs. If MovieOverview
        // recomposes, while fetching the image is in progress,
        // it is cancelled and restarted.
        val image = loadNetworkImage(movie.url)
        MovieHeader(image)

        /* ... */
    }
}

Sơ đồ cho thấy cách kết hợp lại mã trên nếu thêm phần tử mới vào đầu danh sách. Tất cả mục khác trong danh sách này sẽ thay đổi vị trí và sau đó phải kết hợp lại.

Hình 5. Giá trị đại diện của MoviesScreen trong thành phần Compose khi thêm một phần tử mới vào danh sách. Các thành phần kết hợp MovieOverview không thể được tái sử dụng, đồng thời tất cả hiệu ứng phụ sẽ khởi động lại. MovieOverview mang màu khác có nghĩa thành phần kết hợp đã được kết hợp lại.

Lý tưởng là chúng ta có thể xem mã nhận dạng của thực thể MovieOverview liên kết với mã nhận dạng của movie được truyền vào. Nếu sắp xếp lại danh sách phim, tốt nhất chúng ta nên sắp xếp lại các thực thể trong cây thành phần Compose thay vì kết hợp lại từng thành phần kết hợp MovieOverview với một thực thể movie (phim) khác. Bạn có thể sử dụng Compose để báo thời gian chạy giá trị muốn sử dụng nhằm xác định một phần của cây: thành phần kết hợp key.

Bằng cách gói một khối mã cùng lệnh gọi thành phần kết hợp khoá có một hoặc nhiều giá trị được truyền, những giá trị đó sẽ được kết hợp để xác định thực thể trong thành phần Compose. Giá trị của một key không nhất thiết phải là giá trị toàn cục mà chỉ cần là giá trị duy nhất trong các lệnh gọi của thành phần kết hợp tại vị trí gọi. Vì vậy, trong ví dụ này, mỗi movie cần có một key duy nhất trong số movies. Tuy vậy, nếu chia sẻ key này với các thành phần kết hợp tại vị trí khác của ứng dụng thì vẫn ổn.

@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            key(movie.id) { // Unique ID for this movie
                MovieOverview(movie)
            }
        }
    }
}

Với các tuỳ chọn nêu trên, ngay cả khi các phần tử trong danh sách thay đổi, Compose vẫn nhận ra các lệnh gọi riêng lẻ đến MovieOverview và có thể tái sử dụng các lệnh gọi đó.

Sơ đồ cho thấy cách kết hợp lại mã trên nếu thêm phần tử mới vào đầu danh sách. Vì các mục của danh sách được xác định bằng khoá nên Compose biết rằng không cần kết hợp lại các mục, mặc dù vị trí của các mục đó đã thay đổi.

Hình 6. Giá trị đại diện của MoviesScreen trong thành phần Compose khi thêm một phần tử mới vào danh sách. Vì thành phần kết hợp MovieOverview có các khoá riêng biệt nên Compose nhận ra thực thể MovieOverview nào không thay đổi và có thể sử dụng lại các thực thể đó; các hiệu ứng phụ của những thực thể này vẫn tiếp tục được thực thi.

Một vài thành phần kết hợp đã có hỗ trợ sẵn cho thành phần kết hợp key. Ví dụ: LazyColumn chấp nhận việc chỉ định một key tuỳ chỉnh trong DSL items.

@Composable
fun MoviesScreen(movies: List<Movie>) {
    LazyColumn {
        items(movies, key = { movie -> movie.id }) { movie ->
            MovieOverview(movie)
        }
    }
}

Bỏ qua nếu giá trị đầu vào không đổi

Nếu thành phần kết hợp có sẵn trong thành phần Compose thì quá trình kết hợp lại có thể được bỏ qua nếu tất cả giá trị đầu vào đã ổn định và không đổi.

Kiểu ổn định phải tuân thủ hợp đồng sau:

  • Kết quả equals cho hai thực thể sẽ vĩnh viễn giống nhau với cùng hai thực thể trên.
  • Nếu thuộc tính công khai của kiểu ổn định thay đổi, thành phần Compose sẽ được thông báo.
  • Mọi kiểu thuộc tính công cộng đều ổn định.

Một số kiểu phổ biến quan trọng thuộc hợp đồng trên mà trình biên dịch soạn thảo sẽ coi là ổn định, mặc dù chúng không được đánh dấu một cách rõ ràng bằng việc sử dụng chú giải @Stable như:

  • Tất cả kiểu giá trị gốc: Boolean, Int, Long, Float, Char, v.v.
  • Chuỗi
  • Tất cả kiểu hàm (lambdas)

Vì không thể thay đổi, tất cả kiểu trên đều có thể tuân theo hợp đồng ổn định. Vì các kiểu không thể thay đổi luôn giữ nguyên nên không cần phải thông báo thay đổi cho thành phần Compose, do đó, việc tuân thủ hợp đồng sẽ dễ dàng hơn rất nhiều.

Một kiểu ổn định nhưng có thể thay đổi đáng lưu ý là kiểu MutableState của Compose. Nếu một giá trị được nắm giữ trong MutableState thì tổng thể đối tượng trạng thái sẽ được coi là ổn định vì Compose sẽ được thông báo về mọi thay đổi đối với thuộc tính .value của State.

Khi tất cả các kiểu được truyền dưới dạng tham số đến thành phần kết hợp đều ổn định, các giá trị tham số sẽ được so sánh bằng nhau dựa trên vị trí có thể kết hợp trong cây Giao diện người dùng. Quá trình kết hợp lại được bỏ qua nếu tất cả các giá trị không thay đổi kể từ lệnh gọi trước.

Compose chỉ xem một kiểu ổn định nếu có thể chứng minh kiểu này. Ví dụ: giao diện thường được coi như không ổn định, các kiểu thuộc tính công khai có thể thay đổi mà thao tác triển khai không đổi cũng được coi như không ổn định.

Nếu Compose không thể dự đoán nhưng bạn muốn buộc phải xem kiểu này là một kiểu ổn định, hãy đánh dấu bằng chú giải @Stable.

// Marking the type as stable to favor skipping and smart recompositions.
@Stable
interface UiState<T : Result<T>> {
    val value: T?
    val exception: Throwable?

    val hasError: Boolean
        get() = exception != null
}

Trong đoạn mã trên, vì UiState là một giao diện nên Compose thường có thể coi kiểu này là không ổn định. Bằng cách thêm chú giải @Stable, bạn cho Compose biết kiểu trên ổn định và cho phép bộ công cụ này ưu tiên quá trình kết hợp lại thông minh. Điều này cũng có nghĩa Compose sẽ coi tất cả thao tác triển khai là ổn định nếu giao diện được sử dụng làm kiểu tham số.