最近在帶新人和看一些 code review 的時候,發現很多人對 Jetpack Compose 的一個核心...該說是魔法嗎?...就是 Recomposition (重組) 這件事,有點一知半解。就是會用,但又說不出所以然。然後面試,特別是稍微資深一點的 Android 職位,這裡幾乎是必考題。
大家都會說「喔對啊 Compose 就是 state 變了 UI 就會自己更新,很棒。」但面試官一追問「那它是怎麼『知道』要更新的?只更新變動的部分嗎?底層是怎麼做到的避免畫面閃爍或錯亂?」...然後就一片寂靜。
老實說,一開始我也覺得這東西有點黑魔法。但搞懂之後,你會發現它不是魔法,是一套設計得超級精密的系統。今天就來拆解一下這個東西,用比較...嗯,好懂的方式。
重點一句話
Recomposition 不是 Compose 亂槍打鳥把整個畫面重畫,而是靠一個叫做「Snapshot (快照)」的系統,精準追蹤哪個 state 被誰讀取了,當 state 一變,它就像有個帳本一樣,只回頭去更新那些「有讀取過這個 state」的 Composable。就這樣,沒了。
Compose 到底怎麼把你的 Code 變成畫面的?
在講 recomposition 之前,要先很快地同步一下,你的 Kotlin code 是怎麼一步步變成我們在手機上看到的像素的。這過程大概分三步。
1. Composable function:這不是 View,是藍圖
你寫的每一個 `@Composable` function,它本身不等於任何一個 UI 元件。這點要先建立觀念。它不是 Button、不是 TextView。它更像是一個「宣告」,一份「UI 說明書」或「藍圖」。
你只是在 function 裡面描述:「嘿,在『現在這個狀態』下,我希望 UI 長得像一個按鈕,上面文字是 XXX」。你沒有去 `new` 一個 `Button` 物件。你只是在描述它。
2. Composition Tree:UI 的結構圖
好,你寫了一堆這種「說明書」,Compose 編譯器會把它們全部組合起來,變成一棵樹,這就是 Composition Tree。這棵樹基本上就代表了你整個 UI 的結構。誰是誰的爸爸、誰是誰的小孩,清清楚楚。
這棵樹超重要的,因為 Recomposition 就是在這棵樹上動手術的。它讓 Compose 有一張地圖,可以追蹤狀態變化的影響範圍。
3. Layout & Draw (Skia 上場)
樹建好了,Compose 就要開始「蓋房子」了。它會走過這棵樹,做兩件事:
- Layout (佈局):先算一下每個元件要多大、要放哪裡。這過程很像在量尺寸、畫線。 -
- Draw (繪製):尺寸位置都定好了,就真的把它畫出來。但 Compose 不自己畫,它會把繪製指令交給一個叫 Skia 的圖形函式庫。Chrome、Flutter 也是用它。你可以把 Skia 想像成一個超強的繪圖引擎,負責把那些「畫一個圓」、「填上藍色」的指令變成螢幕上的像素。
所以整個流程是:Composable Code (藍圖) → Composition Tree (結構圖) → Skia (繪圖工人) → 螢幕上的像素。
Recomposition 的觸發與 Snapshot 系統
OK,背景知識有了。那到底是什麼觸發了 recomposition?答案很簡單:任何被 Composable「讀取」的 `State
@Composable
fun Counter() {
// 1. 建立一個 State 物件,並用 remember 包起來
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { // 3. 在這裡「寫入」State
// 2. 在 Text 裡「讀取」了 count 這個 State
Text("Count: $count")
}
}
以上面這個經典的計數器為例:
- `Text`這個 Composable 在執行的時候,它讀取了 `count` 的值。
- Compose 就會默默記下一筆:「喔,`Text` 這個傢伙跟 `count` 這個 state 有關係喔。」
- 當你點擊 `Button`,`count++` 這行程式碼執行了,它「寫入」了 `count` state。
- Compose 發現 `count` 變了,就去翻它的帳本,找到所有跟 `count` 有關的 Composable(在這裡就是 `Text`),然後只重新執行 `Text` 的 function。注意,`Button` 本身並沒有讀取 `count` 的值(它只是改變它),所以 `Button` 幾乎不會被 recompose。這就是所謂的 Smart Recomposition。
那...Compose 怎麼做到這一切的?這就要講到它背後最核心的機制:Snapshot System (快照系統)。
你可以把 Snapshot 想像成是...資料庫的 transaction,或是 Git 的 branch。當 recomposition 開始時,Compose 會為這次更新建立一個「暫時的、獨立的」世界,一個 Snapshot。在這個世界裡,所有 state 的讀取都會被記錄下來。所有的 state 寫入,也都只會發生在這個暫時的世界裡。
等到 recomposition 的計算全部完成,Compose 覺得「嗯,新的 UI 藍圖沒問題了」,它才會 `commit` 這次的 snapshot,把所有變動「一次性地」應用到主畫面上。
這樣做有什麼好處?
- 一致性:確保在一次 recomposition 過程中,你讀到的 state 都是同一個版本的,不會讀到一半,某個 state 被其他 thread 改掉,造成 UI 顯示不一致或閃爍。
- 原子性:所有 UI 的更新是一起發生的,要嘛全部成功,要嘛全部不動。避免了「部分更新」的尷尬情況。
- 隔離性:State 的改變被隔離在一個 snapshot 裡,直到準備好才應用,這讓 Compose 可以做很多優化,甚至在背景 thread 預先計算好 recomposition。
我自己是覺得,這個 Snapshot 系統才是 Compose 真正強大的地方,它讓 UI 的狀態管理變得非常安全和可預測。
幾個你可能不知道,但面試超愛問的細節
好了,理論講完了。接下來說幾個實務上,或說...有點冷知識,但其實很重要,也常常是區分新手和老手的點。
1. Composition Tree 其實是個 DAG
我們都叫它 Tree (樹),但更精確的說法是 Directed Acyclic Graph (有向無環圖)。差在哪?「無環」是重點。這代表你的 Composable 之間的呼叫關係,永遠是單向的,從上到下,不會有 A 呼叫 B,B 又回頭呼叫 A 這種無限迴圈。這個結構保證了 recomposition 的流程永遠會結束,不會卡死。
2. `remember` 記住的不是值,是「位置」
很多人誤以為 `remember { mutableStateOf(0) }` 是記住了 `0` 這個值。不對。`remember` 做的事情是,在 Composition Tree 的目前這個「位置」上,儲存一個物件。當 recomposition 發生,function 重新執行時,只要這個 Composable 還在樹上的同一個位置,`remember` 就能把上次存在這裡的那個物件 (`mutableStateOf` instance) 拿回來給你。所以你的 state 才不會被重置。如果你在 `LazyColumn` 裡用了 `remember` 但沒加 `key`,上下滑動導致 item 位置改變,state 就會亂掉,就是這個原因。
3. 官方文件跟現實專案的差距
說到這個,就不得不提一下... 像 Google 的官方文件或很多國外的教學,他們用的範例都很單純。但在台灣或亞洲市場,我們常常要面對各種奇奇怪怪的中低階手機,效能參差不齊。官方說 recomposition 很快,但在那些低階手機上,不必要的 recomposition 累積起來,掉幀感還是很明顯的。
這就意味著,相較於那些只看官方文件的人,我們在寫 code 時必須更斤斤計較。比如 state hoisting (狀態提升) 要做得更徹底,盡可能把 state 限制在最小的範圍內。這點反而是我們在地開發者,因為硬體環境的限制,被迫練出來的「體感」。
實戰優化:常見問題與解法
所以,知道了原理,到底要怎麼寫出效能好的 Compose UI?我整理了一個簡單的對照表,就是我平常在 code review 最常抓的幾個問題。
| 常見效能問題 | 為什麼會這樣?(白話文) | 怎麼修正?(我的建議) |
|---|---|---|
| 整個 List 在單一項目更新時全部重組 | 你沒給 `key`!Compose 看起來每個 item 都長一樣,分不出來誰是誰,只好全部重畫一次求心安。 | 在 `LazyColumn` 或 `LazyRow` 的 `items` 裡,一定要加上 `key = { it.id }`。用一個穩定且唯一的 ID 當作鑰匙。 |
| 只是點個按鈕,半個頁面都在閃(重組) | 你的 state 放太高了。一個頂層的 state 變動,所有讀取它的子元件全部都要跟著動。 | State Hoisting 做確實點。把 state 往下移到「真正需要讀取它的最小範圍」。如果只是要觸發事件,改用 lambda `()` -> `Unit` 把「事件」傳上去,而不是把整個 state 物件往下丟。 |
| 一個由其他 state 算出來的值,明明結果沒變,還是觸發重組 | 你可能直接在 Composable 裡面寫 `val isEnabled = name.isNotEmpty() && password.length > 5`。每次重組它都重算一次。 | 用 `remember { derivedStateOf { ... } }` 把這個計算包起來。它很聰明,會先看裡面的 state (`name` or `password`) 有沒有變,變了才重算,算完再看結果 (`isEnabled`) 跟上次一不一樣,真的不一樣才觸發下游更新。 |
| 在 Composable 裡呼叫 ViewModel 的 function,導致非預期的重組 | 如果那個 function 不是 `suspend` function,它會在 composition 階段同步執行,可能會有效能問題或非預期行為。 | 用 `LaunchedEffect`。把這種只需要執行一次的「副作用」(Side Effect),比如網路請求、跳頁、秀 Toast,都丟進 `LaunchedEffect(key1 = Unit) { ... }` 裡面。它會保證這個 block 只在 key 改變時(或第一次進入畫面時)執行,而且會跟著 Composable 的生命週期走,離開畫面就自動取消。 |
所以,學這個到底要幹嘛?
我自己是覺得,搞懂 recomposition 和 snapshot system,最大的好處不是讓你可以到處去跟人家說文解字。而是你在寫 code 的時候,腦中會有一幅「畫面」。
你會開始思考:「我這個 state 放在這裡,會影響到誰?」「這個 lambda 傳下去,是傳一個穩定的 reference,還是每次 recompose 都會產生一個新物件?」「這裡用 `derivedStateOf` 是不是可以省下很多不必要的計算?」
當你腦中有這張「效能地圖」,你寫出來的 Compose code 自然就會更優雅、更高效。這也是從「會用」到「精通」的必經之路吧。
那你呢?你在學習或使用 Jetpack Compose 的過程中,有沒有遇過什麼讓你頭痛的 recomposition 問題?或是有什麼 debug 的小技巧?在下面留言分享一下吧!
