Jetpack Compose 重組機制解析:Snapshot System 運作原理與效能優化要點

Published on: | Last updated:

最近在帶新人和看一些 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 有一張地圖,可以追蹤狀態變化的影響範圍。

Recomposition 的概念示意:只更新需要的部分
Recomposition 的概念示意:只更新需要的部分

3. Layout & Draw (Skia 上場)

樹建好了,Compose 就要開始「蓋房子」了。它會走過這棵樹,做兩件事:

  • Layout (佈局):先算一下每個元件要多大、要放哪裡。這過程很像在量尺寸、畫線。
  • -
  • Draw (繪製):尺寸位置都定好了,就真的把它畫出來。但 Compose 不自己畫,它會把繪製指令交給一個叫 Skia 的圖形函式庫。Chrome、Flutter 也是用它。你可以把 Skia 想像成一個超強的繪圖引擎,負責把那些「畫一個圓」、「填上藍色」的指令變成螢幕上的像素。

所以整個流程是:Composable Code (藍圖) → Composition Tree (結構圖) → Skia (繪圖工人) → 螢幕上的像素

Recomposition 的觸發與 Snapshot 系統

OK,背景知識有了。那到底是什麼觸發了 recomposition?答案很簡單:任何被 Composable「讀取」的 `State` 物件,只要它的 `.value` 變了,就會觸發。


@Composable
fun Counter() {
    // 1. 建立一個 State 物件,並用 remember 包起來
    var count by remember { mutableStateOf(0) }

    Button(onClick = { count++ }) { // 3. 在這裡「寫入」State
        // 2. 在 Text 裡「讀取」了 count 這個 State
        Text("Count: $count") 
    }
}

以上面這個經典的計數器為例:

  1. `Text`這個 Composable 在執行的時候,它讀取了 `count` 的值。
  2. Compose 就會默默記下一筆:「喔,`Text` 這個傢伙跟 `count` 這個 state 有關係喔。」
  3. 當你點擊 `Button`,`count++` 這行程式碼執行了,它「寫入」了 `count` state。
  4. 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,把所有變動「一次性地」應用到主畫面上。

Snapshot 系統就像替當前的狀態拍下一張照片來進行比對
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 的流程永遠會結束,不會卡死。

Composition Tree 的結構其實是 Directed Acyclic Graph (DAG),保證了單向的資料流
Composition Tree 的結構其實是 Directed Acyclic Graph (DAG),保證了單向的資料流

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 的小技巧?在下面留言分享一下吧!

Related to this topic:

Comments

  1. Guest 2025-06-05 Reply
    孩子剛學Jetpack Compose,老爸我也跟著一起啃書。這技術真的很酷,一開始有點複雜,但慢慢就懂了。聽說狀態管理和重組機制超級重要,我們一起加油鑽研!
  2. Guest 2025-06-04 Reply
    老實說,Jetpack Compose看起來很複雜耶!我之前用Flutter和React Native都還順暢,不知道為啥要換這個。雖然聽說效能不錯,但學習成本真的有點高,搞那麼多技術名詞,誰受得了?
撥打專線 LINE免費通話