Hiểu sâu về cơ chế tái tổ hợp trong Jetpack Compose

Mở đầu

Sau khi nắm vững cách quản lý trạng thái trong Jetpack Compose, bài viết này tập trung vào cơ chế tái tổ hợp (recomposition) — lõi vận hành của UI phản ứng. Không giống mô hình View truyền thống, Compose không "cập nhật" phần tử mà tái thực thi các hàm @Composable khi dữ liệu liên quan thay đổi. Việc hiểu rõ bản chất và giới hạn của tái tổ hợp là chìa khóa để xây dựng giao diện hiệu quả, an toàn và dễ bảo trì.

1. Khi nào tái tổ hợp được kích hoạt?

1.1 Nguyên nhân trực tiếp: Thay đổi trạng thái có thể quan sát

Tái tổ hợp chỉ xảy ra khi một State<T> hoặc đối tượng trạng thái tương đương (ví dụ: SnapshotStateList) bị thay đổi về mặt ngữ nghĩa, không chỉ về mặt tham chiếu. Xét ví dụ sau:

@Composable
fun CounterList() {
    val items = remember { mutableStateListOf(10, 20, 30) }
    
    Column {
        Button(onClick = { items.add(items.last() + 10) }) {
            Text("Thêm phần tử")
        }
        items.forEach { value ->
            Text("Giá trị: $value")
        }
    }
}

Ở đây, mutableStateListOf tạo ra một danh sách đặc biệt — mỗi lần gọi add(), remove() hay set() đều đánh dấu trạng thái nội bộ là "đã thay đổi", từ đó kích hoạt tái tổ hợp cho các thành phần phụ thuộc. Ngược lại, nếu dùng mutableListOf thông thường bên trong mutableStateOf, việc thêm phần tử sẽ không gây tái tổ hợp vì đối tượng danh sách vẫn giữ nguyên tham chiếu — Compose không biết nội dung đã thay đổi.

1.2 Các lớp trạng thái chuyên biệt

Bên cạnh mutableStateListOf, Compose cung cấp nhiều lớp tối ưu hóa khác:

  • mutableStateMapOf(): Cho cấu trúc key-value, đảm bảo thay đổi giá trị theo khóa kích hoạt tái tổ hợp.
  • mutableStateSetOf(): Hỗ trợ tập hợp với hành vi tương tự.
  • derivedStateOf { ... }: Tạo trạng thái phái sinh — chỉ tái tổ hợp khi kết quả biểu thức bên trong thực sự thay đổi (dựa trên so sánh bằng equals).

2. Đặc điểm then chốt của tái tổ hợp

2.1 Thông minh và chọn lọc

Compose không tái tổ hợp toàn bộ cây UI. Nó chỉ "đánh dấu" và thực thi lại những phần tử trực tiếp đọc trạng thái đã thay đổi. Ví dụ:

@Composable
fun UserProfile() {
    var username by remember { mutableStateOf("Người dùng") }
    var isOnline by remember { mutableStateOf(true) }

    Column {
        // Chỉ phụ thuộc vào `username` → chỉ tái tổ hợp khi `username` thay đổi
        Text(text = "Xin chào, $username!")

        // Phụ thuộc vào `isOnline` → chỉ tái tổ hợp khi `isOnline` thay đổi
        Icon(
            painter = if (isOnline) painterResource(R.drawable.ic_online) 
                      else painterResource(R.drawable.ic_offline),
            contentDescription = null
        )

        // Không phụ thuộc vào trạng thái nào → KHÔNG bao giờ tái tổ hợp
        Divider()
    }
}

Trình biên dịch Compose tự động chèn mã so sánh tại thời điểm gọi hàm, giúp loại bỏ hoàn toàn việc thực thi không cần thiết.

2.2 Thứ tự thực thi không xác định

Các hàm @Composable trong cùng một phạm vi (ví dụ: trong Row hoặc Box) không đảm bảo thứ tự gọi. Hệ thống có thể hoán đổi thứ tự để tối ưu hóa hoặc do chính sách bố cục (ví dụ: phần tử ở lớp trên cùng trong Box có thể được xử lý trước). Vì vậy, không nên dựa vào thứ tự gọi để điều khiển luồng logic.

2.3 Có thể chạy song song

Tái tổ hợp không nhất thiết diễn ra trên Main Thread. Compose có thể phân phối các tác vụ tái tổ hợp lên nhiều luồng nền để tận dụng CPU đa nhân. Điều này đòi hỏi các hàm @Composable phải thread-safe: tránh truy cập đồng thời vào tài nguyên chia sẻ không được bảo vệ.

2.4 Có thể xảy ra nhiều lần trong ngắn hạn

Một trạng thái có thể thay đổi nhanh chóng (ví dụ: trong animation hoặc xử lý input liên tục), dẫn đến hàng chục lần tái tổ hợp trong vài mili giây. Do đó, các hàm @Composable phải được thiết kế nhẹ nhàng, tránh logic nặng như tính toán phức tạp, I/O hoặc khởi tạo đối tượng tốn kém.

2.5 Tiếp cận "lạc quan" (optimistic)

Khi trạng thái thay đổi liên tục, Compose có thể hủy bỏ một lần tái tổ hợp đang chạy dở để ưu tiên phiên bản mới nhất. Kết quả của phiên bản bị hủy không bao giờ xuất hiện trên màn hình. Hệ thống luôn đảm bảo rằng phiên bản cuối cùng — phản ánh trạng thái mới nhất — sẽ được hiển thị đầy đủ và chính xác.

3. Phạm vi tái tổ hợp (Recomposition Scope)

Phạm vi tái tổ hợp được xác định bởi phạm vi khai báo của trạng thái và cách trạng thái được đọc. Chỉ những khối mã nằm trong phạm vi mà trạng thái được truy cập mới có khả năng bị đánh dấu là "cần tái tổ hợp".

Xét ví dụ sau:

@Composable
fun ScopeDemo() {
    var count by remember { mutableStateOf(0) }

    Box { // Khối A
        Column { // Khối B
            Log.d("Scope", "A: Box executed")
            Log.d("Scope", "B: Column executed")

            Button(onClick = { count++ }) {
                Log.d("Scope", "C: Button executed")
                Text("Tăng $count")
            }

            Text("Số lần nhấn: $count") // Đọc `count` → phụ thuộc
            Text("Thông tin cố định")   // Không đọc trạng thái → không phụ thuộc
        }
    }

    Card { // Khối D — không đọc `count`
        Log.d("Scope", "D: Card executed")
    }
}

Khi count thay đổi, chỉ các log trong khối A, B và C sẽ xuất hiện lại — vì chúng nằm trong cùng phạm vi khai báo và khối C đọc count. Khối D (Card) không bao giờ tái tổ hợp vì nó hoàn toàn độc lập với trạng thái count.

Lưu ý: Các hàm @Composable được đánh dấu inline (như Column, Box) không tạo phạm vi riêng — phạm vi tái tổ hợp mở rộng lên hàm cha chứa chúng.

4. Tính ổn định của kiểu dữ liệu (Type Stability)

4.1 Tại sao tính ổn định lại quan trọng?

Compose dựa vào tính ổn định để quyết định liệu một tham số truyền vào @Composable có thể bỏ qua tái tổ hợp hay không. Một kiểu được coi là ổn định nếu:

  • Giá trị của nó không thay đổi khi tham chiếu không đổi (tức là bất biến hoặc có hành vi bất biến rõ ràng).
  • Việc so sánh bằng equals() là đủ để xác định sự thay đổi.

Ví dụ:

data class User(val id: Long, val name: String) // Ổn định — tất cả thuộc tính là `val`

data class MutableUser(var id: Long, var name: String) // Không ổn định — có thể thay đổi nội bộ

Khi truyền MutableUser vào một @Composable, Compose không thể chắc chắn rằng giá trị hiển thị vẫn đúng sau khi đối tượng bị sửa — nên nó buộc phải tái tổ hợp mỗi lần, dù tham chiếu không đổi. Đây là lý do vì sao Text(text = user.name) vẫn được gọi lại dù user chưa thay đổi tham chiếu.

4.2 Kiểm soát tính ổn định bằng chú giải

Để hướng dẫn trình biên dịch Compose, ta sử dụng hai chú giải chính:

  • @Stable: Áp dụng cho lớp, hàm hoặc thuộc tính. Báo hiệu rằng giá trị của đối tượng không thay đổi theo cách ảnh hưởng đến UI miễn là tham chiếu không đổi. Có thể dùng cho các lớp có var nhưng đảm bảo tính bất biến hợp lý (ví dụ: setter chỉ thay đổi khi cần thiết và được kiểm soát).
  • @Immutable: Mạnh hơn @Stable, yêu cầu lớp hoàn toàn bất biến (toàn bộ thuộc tính là val, không có hàm thay đổi trạng thái). Thường dùng cho các DTO thuần dữ liệu.

Ví dụ áp dụng:

@Stable
data class Profile(
    var avatarUrl: String? = null,
    var bio: String = "",
    val userId: Long
) {
    // Setter có thể kiểm soát để đảm bảo tính ổn định
    fun updateBio(newBio: String) {
        if (bio != newBio) {
            bio = newBio
        }
    }
}

Với chú giải @Stable, Compose sẽ tin tưởng rằng Profile chỉ cần tái tổ hợp nếu tham chiếu thay đổi — giúp tối ưu hiệu năng đáng kể trong danh sách dài hoặc UI phức tạp.

Thẻ: jetpack-compose recomposition state-management Kotlin android-ui

Đăng vào ngày 31 tháng 5 lúc 15:48