深入解析 Android Compose UI @Composable 背後原理
在上一章中,我們知道 @Composable 是用來定義 UI 的函式。但這不僅僅是一個普通的 Annotation,它是 Jetpack Compose 運作的核心魔法。本章將帶你深入編譯器 (Compiler) 和執行時期 (Runtime) 層面,看看 @Composable 到底對你的程式碼做了什麼手腳。
這不只是 Annotation,這是一種「新語言」
雖然 @Composable 看起來像是一個普通的 annotation,但它實際上改變了函式的型別 (Type)。
編譯器 Plugin (Compose Compiler Plugin) 會介入編譯過程,將所有標記為 @Composable 的函式進行轉換 (Transformation)。這意味著,@Composable 函式和普通函式是完全不相容的。
- 普通函式:只能呼叫普通函式。
- Composable 函式:可以呼叫普通函式,也可以呼叫其他 Composable 函式。
編譯器轉換 (Compiler Transformation)
當你寫下這樣一個函式時:
@Composable
fun Greeting(name: String) {
Text("Hello $name")
}
編譯器實際上把它改寫成了類似這樣的東西(偽代碼):
fun Greeting(
name: String,
$composer: Composer, // 隱藏參數 1:Composer
$changed: Int // 隱藏參數 2:變動位元遮罩
) {
$composer.startRestartGroup(123456) // 唯一的 key
if ($changed ...) {
// 如果輸入有變,執行 UI 邏輯
Text("Hello $name", $composer, ...)
} else {
// 如果沒變,告訴 Composer "跳過" 這段
$composer.skipToGroupEnd()
}
$composer.endRestartGroup()?.updateScope { nextComposer ->
// 註冊 lambda 以便下次 Recomposition 時重新執行
Greeting(name, nextComposer, ...)
}
}
關鍵變數解析
$composer(Composer): 這是 Compose 的核心物件,負責管理 UI 樹狀結構的建立與更新。它會被隱式地傳遞給所有子 Composable 函式。$changed(Change Bitmask): 這是一個整數,用來進行效能優化。Compose 使用位元運算 (Bitwise Operations) 來標記每個參數是否發生了改變。如果所有參數都沒變,Compose 就會直接跳過這個函式的執行 (Skipping),這就是智慧重組 (Smart Recomposition) 的基礎。
執行模型:Slot Table 與 Gap Buffer
Compose 不會像 Android View 系統那樣,建立一個巨大的 View 物件樹並保留在記憶體中等待你去 setText。
相反,Compose 使用了一種稱為 Gap Buffer 或 Slot Table 的資料結構。這是一個線性的陣列 (Array),非常緊湊且高效。
什麼是 Slot Table?
你可以把它想像成一個巨大的陣列,裡面依序記錄了 UI 的結構和狀態。
當 Composable 函式執行時,它會依序「寫入」或「讀取」這個 Table:
第一次執行 (Initial Composition):
Composer處於 Insert Mode。它會把函式的參數、呼叫的子元件、remember的值,依序填入 Table 中。重組 (Recomposition):
Composer處於 Applier Mode。它會依序「比對」Table 裡的舊資料和這次執行的新資料。- 如果資料相同 -> 跳過 (Skip),不更新 UI。
- 如果資料不同 -> 更新 (Update) Table 中的值,並標記需要重繪 UI。
為什麼這很快?
- 線性存取:陣列的存取速度遠快於物件參考 (Pointer Chasing)。
- 空間局部性 (Spatial Locality):資料在記憶體中是連續的,對 CPU Cache 非常友善。
- Gap Buffer:這是一種文字編輯器常用的資料結構。它允許在陣列的中間高效地插入或刪除節點(例如當 UI 結構因
if/else而改變時),而不需要搬移整個陣列。
這使得函式具備了「記憶」能力 (Positional Memoization)
普通的函式是無狀態的 (Stateless),呼叫完就結束了。但 @Composable 函式因為有了 Slot Table 和 $composer 的幫助,它有了「記憶」。
這就是為什麼 remember { ... } 可以運作。
@Composable
fun Demo() {
val count = remember { mutableStateOf(0) }
}
編譯器會把它轉成類似:
fun Demo($composer: Composer, ...) {
$composer.startGroup(...)
// 查詢 Slot Table 目前位置的值
var value = $composer.rememberedValue()
// 如果是第一次 (Empty),則執行 lambda 並存入 Table
if (value === Composer.Empty) {
value = mutableStateOf(0)
$composer.updateRememberedValue(value)
}
val count = value
$composer.endGroup()
}
所謂的「記憶」,其實就是根據程式碼執行的位置 (Position),去 Slot Table 的對應索引位置拿資料。這也解釋了為什麼 Composable 函式的呼叫順序不能改變(例如不能在 Loop 或隨機的 if/else 中動態改變呼叫結構而不改變 Group Key)。
副作用與生命週期 (Side Effects & Lifecycle)
因為 @Composable 函式可能會被頻繁地重新執行 (Recomposition),或者被跳過 (Skipping),甚至在不同的 Thread 執行。所以你絕對不能在 @Composable 函式本體中直接執行有副作用 (Side Effects) 的操作,例如:
- 寫入資料庫 / 檔案
- 發送網路請求
- 註冊 / 取消註冊 Callback
危險示範:
@Composable
fun BadCode() {
// 錯誤!每次 Recomposition 都會發送請求,可能一秒鐘幾十次!
api.fetchData()
Text("Bad Example")
}
你必須使用 Compose 提供的 Effect Handlers (如 LaunchedEffect, SideEffect, DisposableEffect) 來管理這些操作。這些 Handler 會確保副作用只在適當的生命週期時間點(如進入畫面、參數改變時)執行。
總結
@Composable 不僅僅是語法糖,它啟動了強大的編譯器魔法:
- 傳遞 Context:自動傳遞
$composer和$changed參數。 - 建立結構:將程式碼執行流轉換為 Slot Table 的讀寫操作。
- 智慧優化:利用
$changed位元遮罩和 Group Key 來與舊資料比對,實現精準的跳過 (Skipping) 與重組 (Recomposition)。
理解了這些,你就更能明白為什麼 Compose 需要遵循單向資料流,以及為什麼 remember 和 Side Effects 的使用規則如此重要。