Strong Skipping Mode không làm cho các type trở nên stable
Strong Skipping Mode là một trong những tính năng bị hiểu lầm nhiều nhất trong Jetpack Compose. Nhiều ng cho rằng khi bật tính năng này, tất cả các type (kiểu dữ liệu) đều trở nên stable, và chúng ta không cần bận tâm đến vấn đề stability nữa. Điều này hoàn toàn sai.
Strong Skipping Mode không hề thay đổi tính ổn định của bất kỳ type nào. Những type Unstable vẫn sẽ là unstable. Cái thay đổi ở đây là cách runtime xử lý các tham số unstable trong quá trình kiểm tra việc bỏ qua (skip check): Thay vì luôn luôn thực thi lại composable, runtime sẽ so sánh các giá trị unstable bằng cách sử dụng so sánh tham chiếu (===) và bỏ qua nếu chính instance (thực thể) đó được truyền vào. Đây là một sự tối ưu hóa có ý nghĩa, nhưng nó không thể thay thế cho việc hiểu về stability, và nó cũng không ngăn được việc recomposition (tái cấu trúc) không cần thiết trong nhiều pattern phổ biến.
Trong bài viết này, chúng ta sẽ tìm hiểu xem Strong Skipping Mode thực sự thay đổi điều gì ở cấp độ compiler và runtime; mã nguồn được tạo ra khác biệt thế nào giữa tham số stable và unstable; tại sao so sánh tham chiếu lại vô dụng khi các instance mới được tạo ra ở mỗi lần recomposition; cách memoization (ghi nhớ) lambda hoạt động khác đi; và những trường hợp thực tế mà stability vẫn cực kỳ quan trọng dù đã bật strong skipping.
Những hiểu lầm phổ biến
Hiểu lầm này thường xuất hiện dưới vài dạng như: "Strong Skipping Mode làm mọi thứ trở nên stable." "Bạn không cần @Stable hay @Immutable nữa.", "Sự ổn định giờ là vấn đề đã được giải quyết." Những tuyên bố này đều chung một lỗi: Nhầm lẫn giữa khả năng có thể bỏ qua (skip) với tính ổn định thực tế (stability).
Stability là thuộc tính của một type. Nó có nghĩa là compiler có thể đảm bảo rằng trạng thái quan sát được của giá trị sẽ không thay đổi mà Compose không được thông báo. Int, String, và các data class @Immutable là stable. List<T> (một interface có thể là mutable), các class có thuộc tính var, và các type từ các module bên ngoài không được Compose compiler xử lý là unstable. Strong Skipping Mode không thay đổi bất kỳ phân loại nào trong số này. Một List<Item> vẫn là unstable khi bật strong skipping. Cái bitmask $stable mà compiler tạo ra cho mỗi class là y hệt nhau bất kể strong skipping đang bật hay tắt.
Cái thay đổi chính là skip decision. Nếu không có strong skipping, một composable có bất kỳ tham số unstable nào sẽ không bao giờ có thể skip. Có strong skipping, composable đó có thể skip nếu tham số unstable đó chính là instance đã dùng ở lần composition trước.
Compiler thực sự tạo ra cái gì?
Cách tốt nhất để hiểu sự khác biệt là nhìn vào mã nguồn mà Compose compiler tạo ra. Hãy xem xét một composable nhận vào một tham số unstable:
@Composable
fun Test(x: Foo) {
A(x)
}
Trong đó Foo là một class unstable (ví dụ: nó có thuộc tính var hoặc thuộc module bên ngoài).
Khi bật Strong Skipping Mode (Mặc định trong Compose mới nhất)
Compiler tạo ra một lệnh skip check sử dụng changedInstance():
@Composable
fun Test(x: Foo, %composer: Composer?, %changed: Int) {
%composer = %composer.startRestartGroup(<>)
val %dirty = %changed
if (%changed and 0b0110 == 0) {
%dirty = %dirty or if (%composer.changedInstance(x)) 0b0100 else 0b0010
}
if (%dirty and 0b0011 != 0b0010) {
A(x, %composer, 0b1110 and %dirty)
} else {
%composer.skipToGroupEnd()
}
%composer.endRestartGroup()
}
Điểm mấu chốt nằm ở changedInstance(x). Phương thức này so sánh giá trị hiện tại của x với giá trị trước đó bằng toán tử !== (so sánh tham chiếu). Nếu cùng một instance được truyền vào, composable sẽ skip. Nếu một instance khác được truyền vào, ngay cả khi chúng có nội dung giống hệt nhau (structural equality), composable vẫn sẽ thực thi lại.
Khi KHÔNG có Strong Skipping Mode
Nếu không có strong skipping, compiler sẽ đặt mightSkip = false cho bất kỳ composable nào có tham số bắt buộc là unstable. Khi mightSkip là false, compiler thậm chí không tạo ra lệnh skip check. Composable sẽ luôn luôn thực thi lại.
Tham số Stable sử dụng so sánh nội dung (structural equality) trong cả hai chế độ
Để so sánh, một composable với tham số stable như Int luôn sử dụng changed() với so sánh nội dung:
@Composable
fun Test(x: Int, %composer: Composer?, %changed: Int) {
// ...
if (%changed and 0b0110 == 0) {
%dirty = %dirty or if (%composer.changed(x)) 0b0100 else 0b0010
}
// ...
}
changed(x) sử dụng toán tử != (structural equality). Điều này có nghĩa là hai giá trị Int được coi là bằng nhau ngay cả khi chúng là các đối tượng khác nhau.
changed() vs changedInstance(): Sự khác biệt ở Runtime
Hai phương thức trong interface Composer đã nói lên toàn bộ câu chuyện:
override fun changed(value: Any?): Boolean {
return if (nextSlot() != value) { // so sánh nội dung (equals)
updateValue(value)
true
} else {
false
}
}
override fun changedInstance(value: Any?): Boolean {
return if (nextSlot() !== value) { // so sánh tham chiếu
updateValue(value)
true
} else {
false
}
}
Sự khác biệt duy nhất là != và !==. So sánh nội dung (!=) sẽ gọi hàm equals(). So sánh tham chiếu (!==) kiểm tra xem hai tham chiếu có trỏ cùng vào một đối tượng duy nhất trong bộ nhớ hay không.
Sự phân biệt này giải thích cả sức mạnh và hạn chế của strong skipping. Với một data class stable User(val name: String, val age: Int), hai instance có cùng tên và tuổi sẽ có nội dung bằng nhau, nên changed() trả về false và composable skip. Với một type unstable dưới chế độ strong skipping, changedInstance() chỉ trả về false nếu chính xác đối tượng đó được truyền vào, chứ không phải một bản copy có cùng giá trị.
Những trường hợp Strong Skipping không giúp ích gì
Hiểu về kiểm tra tham chiếu sẽ giúp ta thấy những trường hợp mà strong skipping trở nên vô dụng:
1. Instance mới được tạo ra ở mỗi lần recomposition
@Composable
fun Screen() {
val items = listOf(Item("A"), Item("B"), Item("C"))
ItemList(items = items)
}
Mỗi khi Screen recompose, listOf() tạo ra một instance List mới. Dù nội dung y hệt, nhưng items !== previousItems vì nó là đối tượng mới. changedInstance() trả về true, và ItemList thực thi lại. Strong skipping không giúp gì ở đây vì không có sự tái sử dụng instance.
2. Data class sử dụng hàm copy()
@Composable
fun UserCard(user: User) { /* ... */ }
@Composable
fun Screen(viewModel: MyViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
UserCard(user = state.user)
}
Nếu UiState được tạo lại ở mỗi lần emit state (phổ biến với hàm copy() trong pattern MVI), state.user có thể là một instance mới ngay cả khi dữ liệu user không đổi. changedInstance() thấy tham chiếu khác đi và kích hoạt recomposition. Nếu User là stable, changed() sẽ dùng equals() và skip chính xác.
3. Lambda capture các giá trị unstable
@Composable
fun Screen(items: List<Item>, filter: Filter) {
ItemList(
items = items,
onClick = { item -> applyFilter(item, filter) }
)
}
Lambda { item -> handleClick(item) } không capture bất kỳ unstable value nào, nên compiler có thể memoize nó và reuse cùng một instance. Nhưng nếu lambda capture một unstable value:
@Composable
fun Screen(items: List<Item>, filter: Filter) {
ItemList(
items = items,
onClick = { item -> applyFilter(item, filter) }
)
}
Lambda này capture filter, mà filter là unstable. Compiler sử dụng changedInstance(filter) để quyết định có tạo lambda instance mới hay không:
val onClick = %composer.cache(%composer.changedInstance(filter)) {
{ item -> applyFilter(item, filter) }
}
Nếu filter là new instance ở mỗi lần recomposition, thì lambda cũng sẽ bị recreate mỗi lần. Strong skipping memoize lambda dựa trên referential equality của các captured values, nhưng điều này chỉ hiệu quả khi các giá trị được capture là cùng một instance.
4.Mixed stable and unstable captures
Bộ test của compiler cho thấy chính xác cách xử lý khi lambda capture cả stable type (Bar) và unstable type (Foo). Compiler sẽ generate các comparison khác nhau cho từng loại:
// Source
@Composable
fun Test() {
val foo = Foo(0) // unstable
val bar = Bar(1) // stable
val lambda = { foo; bar }
}
Code được generate:
val lambda = %composer.cache(
%composer.changedInstance(foo) or %composer.changed(bar)
) {
{ foo; bar }
}
Điều này cho thấy strong skipping không treat tất cả captures giống nhau:
Capture unstable → dùng changedInstance (referential equality ===)
Capture stable → dùng changed (structural equality equals())
Nếu foo là new instance nhưng structurally equivalent với instance trước đó, lambda vẫn bị recreate.
Nếu bar là new instance nhưng equals() vẫn true, thì cache không bị invalid bởi capture đó.
Khi nào Strong Skipping thực sự giúp ích?
Strong skipping thực sự cải thiện hiệu năng trong hai kịch bản:
Singleton và tham chiếu đối tượng (object references)
Khi tham số là cùng một instance đối tượng qua các lần recomposition (ví dụ: Singleton).
val config = AppConfig.getInstance() // singleton, always same instance
ConfigDisplay(config = config) // changedInstance returns false, skips
Các State object không bị tạo lại:
@Composable
fun Screen() {
val scrollState = rememberScrollState() // same instance across recompositions
ScrollableContent(state = scrollState)
}
Ví dụ ScrollState không được đánh dấu @Stable, nhưng nhờ rememberScrollState(), chính instance đó được tái sử dụng, nên strong skipping sẽ nhận diện được và skip.
Những gì Stability mang lại mà Strong Skipping không thể
Stability cho phép so sánh bằng nội dung (structural equality). Điều này quan trọng khi bạn có các giá trị tương đương nhưng là các instance khác nhau:
- Data class được tạo lại qua copy(): Stable data class so sánh bằng equals(), strong skipping so sánh bằng ===.
- Collection được dựng lại từ cùng một dữ liệu: ImmutableList với các phần tử giống nhau sẽ bằng nhau về nội dung, còn một List mới sẽ khác nhau về tham chiếu.
- Các giá trị đi qua StateFlow: mỗi lần emit sẽ tạo ra một instance mới. Stability giúp runtime nhận ra rằng nội dung thực chất không hề thay đổi.
Strong skipping giống như một “lưới an toàn”, chỉ bắt được những trường hợp mà cùng một instance được reuse lại. Còn stability là một sự đảm bảo mạnh hơn: nó giúp nhận diện rằng các giá trị tương đương vẫn được coi là giống nhau, bất kể chúng có phải cùng instance hay không.
Khi nào việc kiểm tra Stability là thừa thãi?
Khía cạnh ngược lại của sự hiểu lầm này cũng rất đáng để lưu tâm. Giống như việc Strong Skipping không thể thay thế hoàn toàn Stability, thì bản thân Stability không phải lúc nào cũng mang lại giá trị. Có những trường hợp việc cố gắng làm cho các type trở nên stable lại tạo ra gánh nặng hệ thống (overhead) mà không mang lại lợi ích thiết thực nào.
StateFlow vốn đã tự loại bỏ các dữ liệu trùng lặp (Deduplication)
StateFlow thực hiện so sánh bằng nội dung (structural equality) ngay bên trong nó. Khi bạn gọi emit(newValue) hoặc cập nhật .value, StateFlow sẽ kiểm tra newValue == currentValue và chặn việc phát tán dữ liệu nếu chúng bằng nhau. Điều này có nghĩa là giá trị đến được tới collectAsStateWithLifecycle() vốn đã vượt qua một bước kiểm tra bằng nhau trước khi Compose kịp nhìn thấy nó.
Nếu UI state của bạn đi qua một StateFlow và các type được triển khai hàm equals() chính xác, thì việc Compose kiểm tra stability lần nữa chỉ là một bước so sánh thừa trên những giá trị vốn dĩ đã được xác nhận là khác nhau rồi. Trong kịch bản này, việc biến type đó thành stable chỉ thêm một lần gọi equals() dư thừa ở lớp Compose. Composable vẫn sẽ thực thi lại (re-execute) vì StateFlow chỉ emit những giá trị thực sự mới.
Điều này không có nghĩa là Stability vô dụng khi dùng với StateFlow. Nó vẫn giúp ích khi một composable cha thực thi lại vì một lý do không liên quan đến StateFlow (ví dụ: một state anh em cùng cấp thay đổi), vì khi đó các tham số con đã stable có thể skip qua equals() mặc dù cha của nó đang chạy lại. Nhưng đối với đối tượng tiêu thụ trực tiếp StateFlow, việc khử trùng lặp dữ liệu đã xảy ra từ trước đó rồi.
So sánh bằng nội dung có thể rất tốn kém
Stability cho phép so sánh bằng equals(), và hàm equals() không hề miễn phí. Đối với một data class chứa một List<Item> có hàng trăm phần tử, việc so sánh nội dung sẽ phải duyệt qua mọi phần tử trong danh sách đó. Nếu composable dù sao cũng sẽ thực thi lại (vì dữ liệu thực sự đã thay đổi), thì việc so sánh equals() trước đó chỉ là công cốc.
Chi phí này đặc biệt đáng ngại với các cấu trúc dữ liệu lớn và lồng nhau sâu. Một UiState với nhiều list, map và các nested object có thể có hàm equals() tốn kém hơn cả việc thực thi lại thân hàm composable. Trong những trường hợp này, sử dụng remember với một key được chọn lọc thường hiệu quả hơn là dựa vào stability:
val processedItems = remember(items.size, filterKey) {
items.filter { it.matchesFilter(filterKey) }
}
Cách làm này tránh được cả chi phí so sánh nội dung lẫn chi phí recomposition bằng cách memoize (ghi nhớ) derived values với các lightweight keys.
Derived values có thể được memoize mà không cần stability
Khi một composable biến đổi hoặc lọc dữ liệu, kết quả biến đổi có thể được ghi nhớ bằng remember bất kể tính ổn định của input type:
@Composable
fun FilteredList(items: List<Item>, query: String) {
val filtered = remember(items, query) {
items.filter { it.name.contains(query) }
}
LazyColumn {
items(filtered) { ItemRow(it) }
}
}
Lệnh gọi remember sẽ cache danh sách đã lọc và chỉ tính toán lại khi items hoặc query thay đổi (dựa trên so sánh tham chiếu của các key). Điều này hoạt động ngay cả khi List<Item> là unstable. Bạn không cần phải làm cho danh sách trở nên stable để việc memoize hoạt động. Việc so sánh key của remember sử dụng cùng một cơ chế so sánh tham chiếu mà Strong Skipping sử dụng, vì vậy hai phương pháp này bổ trợ cho nhau.
Sự cân bằng trong thực tế
Câu hỏi đúng đắn không phải là "Tôi có nên làm cho type này stable không?" mà là "Stability có thêm giá trị gì cho tham số này trong ngữ cảnh này không?".
Nếu giá trị đi qua StateFlow và composable là đối tượng tiêu thụ trực tiếp, stability sẽ tạo ra một lần check thừa.
Nếu type đó có hàm equals() tốn kém và thay đổi thường xuyên, stability sẽ tạo ra gánh nặng overhead.
Nếu giá trị có thể được memoize bằng remember với các key nhẹ, cách đó có thể hiệu quả hơn là so sánh equals() trên toàn bộ object.
Stability mang lại giá trị lớn nhất khi:
Các instance tương đương được tạo ra ở các điểm khác nhau trong cây composition.
Khi composable cha recompose và truyền các giá trị không đổi xuống composable con.
Khi chi phí của hàm equals() là thấp so với chi phí thực thi thân hàm composable.
Mô hình tư duy cho hai cơ chế
Hãy coi Stability và Strong Skipping như hai lớp của một cây quyết định.
Câu hỏi đầu tiên mà runtime đưa ra là: "Tham số này có cùng một instance không?". Nếu có, skip. Đây là bước kiểm tra changedInstance() mà Strong Skipping mang lại cho các unstable types.
Câu hỏi thứ hai, chỉ dành cho các stable types, là: "Tham số này có bằng nhau về nội dung so với giá trị trước đó không?". Nếu có, skip. Đây là bước kiểm tra changed() mà Stability cho phép thực hiện.
Nếu không có Strong Skipping, các tham số unstable sẽ không bao giờ chạm tới cả hai câu hỏi này và composable luôn thực thi lại. Có Strong Skipping, các tham số unstable được đi qua câu hỏi thứ nhất nhưng không có câu hỏi thứ hai. Chỉ các tham số stable mới được đi qua cả hai, đó là lý do tại sao Stability vẫn cung cấp một sự đảm bảo mạnh mẽ hơn.
Mô hình hai lớp này làm rõ khi nào mỗi cơ chế phát huy tác dụng: Strong Skipping bắt được trường hợp dễ (cùng một đối tượng được truyền lại). Stability bắt được trường hợp khó hơn (đối tượng khác nhưng nội dung giống hệt). Cả hai đều cần thiết để có một UI được tối ưu hóa hoàn toàn.
Kết luận
Trong bài viết này, chúng ta đã tìm hiểu Strong Skipping Mode thực sự thay đổi điều gì ở cấp độ compiler và runtime. Compiler tạo ra changedInstance() (so sánh tham chiếu) cho các tham số unstable thay vì vô hiệu hóa hoàn toàn việc skip, trong khi các tham số stable tiếp tục sử dụng changed() (so sánh nội dung). Các lambda capture cũng tuân theo mô hình tương tự. Bản thân các type thì không thay đổi; không có sự phân loại stability nào bị biến đổi bởi Strong Skipping.
Hiểu được sự khác biệt này mang lại những bài học thực tiễn. Nếu composable của bạn nhận một List<Item> được dựng lại sau mỗi lần emit từ StateFlow, Strong Skipping sẽ không ngăn được recomposition vì mỗi lần emit tạo ra một instance danh sách mới. Việc làm cho type đó trở nên stable (thông qua ImmutableList hoặc annotation @Stable) sẽ cho phép runtime so sánh theo nội dung và skip khi các item không đổi. Điều tương tự cũng áp dụng cho các data class được tạo bằng copy(), các object được khởi tạo inline, và các lambda capture chứa giá trị unstable.
Bài học thực tế rút ra ở đây rất sâu sắc: Stability không phải lúc nào cũng cần thiết (khử trùng lặp của StateFlow, memoization bằng remember, và chi phí equals() trên các type phức tạp là những lý do chính đáng để bỏ qua annotation stability). Nhưng Stability cũng không hề lỗi thời: nó vẫn là cơ chế duy nhất cho phép Compose phát hiện các giá trị bằng nhau về nội dung nhưng khác nhau về tham chiếu. Strong Skipping là một "lưới an toàn" ngăn chặn trường hợp tệ nhất là các composable không-bao-giờ-skip, chứ không phải là sự thay thế cho việc thấu hiểu công cụ nào mang lại giá trị trong từng ngữ cảnh.
Cảm ơn mọi ngưỡi đã quan tâm, hẹn gặp lại ở bài viết sau !!!!
Nguồn tham khảo : https://doveletter.dev/articles/strong-skipping-mode-misconceptions
All rights reserved