各位戰士,歡迎來到第二十天的戰場。至今為止,我們所有的戰鬥都圍繞著 Android 的傳統 View 體系展開:優化 XML 佈局、為 RecyclerView 提效。但戰爭的形式已經改變。Google 推出的 Jetpack Compose,以其聲明式的 UI 開發範式,徹底顛覆了我們構建介面的方式。
在 Compose 的世界裡,UI 不再是一個由 View 物件構成的靜態樹,而是一個狀態 (State) 的函式,即 UI = f(state)
。當狀態改變時,Compose 會自動更新 UI 來反映這個變化。這個自動更新的過程,就是我們今天要打的這場戰爭的核心——Recomposition (重組)。
Recomposition 是 Compose 性能的基石,也是性能問題的根源。過度或低效的重組,就是 Compose 世界裡的「Jank」。今天的任務,就是理解重組的規則,並學會如何像一個精密的外科醫生一樣,只重組「必要」的部分,避免「不必要」的性能浪費。
要打贏戰爭,先要了解規則。Compose 的每一幀畫面,都經歷三個階段:
@Composable
函式,生成一個描述 UI 的樹狀結構。當一個由 mutableStateOf()
建立的狀態發生改變時,Compose 會觸發 Recomposition。但它並非愚蠢地重新執行所有 Composable 函式,而是非常「聰明」地只重新執行那些直接讀取了該狀態的 Composable。這就是所謂的「智慧重組」或「範圍化重組 (Scoped Recomposition)」。
來看一個經典的例子:
@Composable
fun MyScreen() {
var count by remember { mutableStateOf(0) }
Log.d("Recomposition", "MyScreen is recomposed")
Column {
// 這個 Text 不讀取 count 狀態,它不會重組
Text(text = "這是一個靜態的標題")
Button(onClick = { count++ }) {
Log.d("Recomposition", "Button is recomposed")
// 這個 Text 讀取了 count 狀態,它會重組
Text(text = "你點擊了 $count 次")
}
}
}
當你點擊按鈕時,你會在 Logcat 中看到 "MyScreen is recomposed" 和 "Button is recomposed" 的日誌。但 Text("這是一個靜態的標題")
這個 Composable 不會被重新執行,因為它完全不關心 count
的變化。Compose 精準地跳過了它。
我們的目標,就是盡可能地保護和利用 Compose 的這項智慧。
Jank 的來源,就是那些我們沒有預料到、範圍過大、或者過於頻繁的「不必要重組」。而導致這一切的罪魁禍首,通常是傳遞了「不穩定」(Unstable) 的類型作為 Composable 的參數。
穩定 (Stable) 類型:Compose 編譯器可以 100% 確定其內部值是否發生了變化的類型。如果一個物件的所有公開屬性都是 val 且其類型也都是穩定的,那麼它就是穩定的。
所有原始類型 (Int, String, Float 等) 都是穩定的。
由 val 和穩定類型構成的 data class 是穩定的。
不穩定 (Unstable) 類型:Compose 編譯器無法確定其值是否改變的類型。
當你將一個不穩定的物件傳遞給一個 Composable 時,Compose 無法確定這個物件的內部到底變了沒有。為了安全起見,它會採取一種保守策略:只要這個 Composable 的父節點發生重組,它自己也必須跟著重組,哪怕那個不穩定的物件內容根本沒變。
這就破壞了「智慧重組」的原則,導致了大量不必要的性能開銷。
// 不穩定的 Class,因為 name 是 var
class User(var name: String)
@Composable
fun UserGreeting(user: User) {
Text("Hello, ${user.name}")
}
// 穩定的 data class,因為 name 是 val
data class StableUser(val name: String)
@Composable
fun StableUserGreeting(user: StableUser) {
Text("Hello, ${user.name}")
}
在上面的例子中,UserGreeting
很可能會被頻繁地重組,而 StableUserGreeting
則能更好地利用智慧重組的優勢。
盡可能使用 data class
和 val
來定義你的狀態物件。
當需要傳遞集合給 Composable
時,優先使用 kotlinx.collections.immutable
函式庫中的不可變集合 (ImmutableList
, ImmutableSet
等),而不是標準的 List。
原則:狀態在哪裡被讀取,重組的範圍就在哪裡。因此,應盡可能地將狀態的讀取「推遲」到最需要它的、層級最深的 Composable
中。
技巧:不要直接傳遞狀態值,而是傳遞一個讀取該值的 Lambda (() -> T)。
// 不好的寫法:MyParent 讀取了狀態,會跟著 Header 一起重組
@Composable
fun MyParent(viewModel: MyViewModel) {
val headerText = viewModel.headerText.value // 在這裡讀取
Header(text = headerText)
}
// 好的寫法:MyParent 不讀取狀態,只傳遞 lambda。重組被限制在 Header 內部
@Composable
fun MyParent(viewModel: MyViewModel) {
// MyParent 不再關心 headerText 的值,它不會因為 headerText 變化而重組
Header(textProvider = { viewModel.headerText.value })
}
@Composable
fun Header(textProvider: () -> String) {
val text = textProvider() // 在這裡才讀取
Text(text = text)
}
derivedStateOf
處理衍生狀態derivedStateOf
。derivedStateOf
會建立一個新的 State 物件,它只在其計算結果真正發生改變時,才會通知讀取它的 Composable
進行重組,避免了因無關狀態變化導致的重算和重組。val isButtonEnabled by remember {
derivedStateOf {
username.value.isNotBlank() && password.value.length > 5
}
}
Android Studio 的 Layout Inspector 現在完全支援 Compose。它有一個殺手級功能:顯示 Composable 的重組計數。你可以打開它,操作你的 UI,實時地看到哪些 Composable 在高亮閃爍,以及它們被重組了多少次。這是揪出不必要重組的最直觀的工具。
今天,我們踏入了 Compose 的性能世界,打響了 Recomposition 戰爭。
我們理解了 Compose 的核心是智慧重組。
我們鎖定了頭號敵人是因不穩定類型導致的不必要重組。
我們掌握了三大致勝戰術:使用穩定類型、延遲狀態讀取、使用 derivedStateOf
。
Compose 的性能優化是一門精細的藝術。明天,我們將討論 UI 流暢度戰爭中的最後一個共通原則,無論是傳統 View 還是 Compose 都必須遵守的鐵律:【主執行緒的守護者:嚴禁 I/O 操作】,並再次強調協程 (Coroutines) 在這場戰爭中的決定性地位。
我們明天見!