今天要來聊聊 Jetpack Compose 的效能優化,而且是進階版。如果你已經看過一些基本教學,知道怎麼用 `remember`、`derivedStateOf` 這些東西,但 App 滑起來還是偶爾會「抖」一下... 那這篇就是為你準備的。
老實說,把 Compose 效能搞好,基礎功大概就佔了八成。但剩下的兩成,就是區分高手跟普通人的關鍵。這些技巧不常用,但一旦用上,效果真的會讓你嚇一跳。今天不講那些老掉牙的,我們直接切入幾個比較...嗯,比較「硬核」的工具。
先說結論:把狀態讀取的時間點往後推
如果今天講的這五個技巧你只能記住一件事,那就是:盡可能延後讀取狀態(State)的時間點。意思就是,不要在 Composition 階段就急著去讀那些會頻繁變動的值。如果能拖到 Layout 階段或甚至 Draw 階段再讀,就能省下大把的 recomposition,這也是今天很多技巧背後的共通邏輯。好,記住這點,我們開始看一些實際的「妖術」。
頭號殺手:捲動時的卡頓,元兇可能是它
想像一個很常見的情境:你要做一個有視差滾動(Parallax scrolling)效果的 Header。使用者往下滾動頁面,Header 圖片要跟著慢慢往上移動,可能還帶點透明度變化。聽起來很簡單吧?
很多人直覺會這樣寫:
@Composable
fun BadScrollingHeader(scrollState: ScrollState) {
// ⚠️ 每次捲動,scrollState.value 都會變
// ⚠️ 在 Composition 階段讀取這個值,會導致 Text 不斷 recompose
val alpha = 1f - (scrollState.value / 500f).coerceIn(0f, 1f)
val translationY = -scrollState.value * 0.5f
Text(
"我的標題",
modifier = Modifier
.alpha(alpha) // 直接傳入值
.offset(y = translationY.dp) // 這邊也是
)
}
看起來沒問題,但這段程式碼有個天大的效能陷阱。`scrollState.value` 在使用者手指滑動的每一毫秒都在瘋狂改變。而你,在 Composable 的第一層就讀取了它 (`val alpha = ...`)。這會導致什麼後果?
就是 `BadScrollingHeader` 這個 Composable 會以每秒 60 次甚至更高的頻率,不斷地進行 recomposition(重組)。畫面上的東西明明沒變,就只是位置跟透明度改一下,但整個元件的函式都在重跑。這就是卡頓的來源。
怎麼做:用 Lambda Modifier 把計算推遲
所以解法是什麼?就是我們前面說的,把讀取 `scrollState.value` 這件事,往後推。推到 Layout 或 Draw 階段再做。Compose 很貼心地提供了很多 Modifier 的「Lambda 版本」,讓我們可以做到這件事。
來看修改後的版本:
@Composable
fun GoodScrollingHeader(scrollState: ScrollState) {
Text(
"我的標題",
// 改用 graphicsLayer,它接收一個 lambda
modifier = Modifier.graphicsLayer {
// ✅ 只有在 Draw 階段,這裡面的程式碼才會被執行
// ✅ 在這裡才讀取 scrollState.value
translationY = -scrollState.value * 0.5f
alpha = 1f - (scrollState.value / 500f).coerceIn(0f, 1f)
}
)
}
看到差別了嗎?我們改用了 `Modifier.graphicsLayer`。這個 Modifier 吃的是一個 Lambda (`{ ... }`)。這個 Lambda 裡面的程式碼,並不是在 Composition 階段執行的,而是在後面的 Draw 階段。
所以,當 `scrollState.value` 改變時,Compose 會說:「喔,`GoodScrollingHeader` 這個 Composable 本身沒有讀取這個 state,所以我不用去 recompose 它。我只需要在最後要『畫』它的時候,重新執行那個 `graphicsLayer` 裡的 lambda 就好了。」
這一來一往,就從「整個函式重跑」變成了「只重跑繪圖指令」,效能當然是天差地遠。這招超級實用,幾乎是處理動畫跟滾動相關效能問題的必備起手式。
常見的 Lambda Modifier 包括:
- `Modifier.offset { ... }`:用在 Layout 階段,處理位置偏移。
- `Modifier.graphicsLayer { ... }`:用在 Draw 階段,處理透明度、縮放、旋轉等。
- `Modifier.drawBehind { ... }` 或 `Modifier.drawWithContent { ... }`:一樣在 Draw 階段,用來做自訂繪圖。
情境變體:另外幾個「核彈級」工具
Lambda Modifier 可以解決大概 80% 的進階效能問題。但總有些極端狀況,需要更厲害的工具。這邊我直接用一個表格來比較,什麼時候該用哪個,這樣比較清楚。
| 工具名稱 | 使用時機(口語化解釋) | 優點 | 缺點/注意事項 |
|---|---|---|---|
| Lambda Modifiers (例如 graphicsLayer) |
最常用!只要你的 Modifier 參數會跟著動畫或滾動一直變,就先用這個。 | 寫起來簡單,效果又好,能跳過 recomposition。 | 功能有限,就是那些位移、旋轉、縮放... 客製化能力不強。 |
withFrameNanos |
當你需要做「物理引擎」等級的超精準動畫時。像是要根據每一幀 (frame) 經過的奈秒數去計算物體位置。 | 可以拿到目前這一幀的時間戳,做到最平滑、最同步的動畫。 | 本身只是個計時器,你還是得自己管理狀態。通常會寫在 LaunchedEffect 裡面,而且一定要搭配 Lambda Modifier,不然會變成效能災難。 |
Modifier.Node |
終極武器。當你用 Profiler 發現效能瓶頸是某個 Modifier 本身,或是你要開發一個超級複雜、需要自己管理狀態和生命週期的共用 Modifier 時。 | 極致的效能。它直接繞過 Composable 層級,操作底層節點,幾乎沒有額外開銷。 | 寫起來超麻煩!要處理 create、update、還要管 `equals` 跟 `hashCode`。我自己是覺得,除非你在寫元件庫,不然 99% 的情況用不到。 |
TextMeasurer |
當你需要「預知」一段文字會佔多大空間時。例如你想幫文字畫一個剛剛好貼身的背景,或是在文字後面精準對齊一個 icon。 | 可以在實際繪製前,就拿到精準的文字寬高、行數等資訊。 | 多了一步測量的動作,記得要用 rememberTextMeasurer 和 drawWithCache 包起來,避免重複運算。 |
說到這個,官方的 `Android Developers` 文件其實有提到 `Modifier.Node` 的底層原理,但老實說,寫得有點太學術了。反而是一些國外 GDE (Google Developer Expert) 的部落格,像是 Chris Banes 的文章,會用更實際的例子去解釋「為什麼」需要這東西。而在台灣,我印象中之前好像也有技術社群,像是 GDG Taipei 或是哪位講者在 MOPCON 提過類似的概念,重點都是「不要為了一點點小優化就去用最複雜的工具」。
常見錯誤與修正
看了這麼多酷東西,很容易就想每個都拿來用用看。但...千萬別。這裡列出幾個我常看到大家踩的坑。
錯誤一:為了 `withFrameNanos` 而 `withFrameNanos`
很多人看到 `withFrameNanos` 覺得好酷,可以拿到每一幀的時間,就到處用它來跑動畫。但就像我剛剛在表格裡說的,它本身只是個「觸發器」,在每一幀的開頭叫你一下而已。
如果你在裡面做了很普通的事情,比如:
// 錯誤示範
LaunchedEffect(Unit) {
while(true) {
withFrameNanos {
// 每次都直接更新一個普通的 state
myState.value = someNewValue(it)
}
}
}
// ...
// 然後在某個地方直接讀取 myState.value
Text("Value: ${myState.value}")
那你等於是手動建立了一個每秒會觸發 60 次 recomposition 的超級大災難。正確用法是,`withFrameNanos` 裡面更新的 state,必須在 UI 層透過 Lambda Modifier 來讀取,這樣才能跳過 recomposition。
錯誤二:濫用 `Modifier.Node`
這真的要再三強調。`Modifier.Node` 是給你造輪子用的,不是給你開車用的。它的複雜度非常高,而且一旦寫錯,debug 會非常痛苦。在你拿出這個工具之前,請先問自己:
- 我用 Android Studio 的 Profiler 了嗎?真的確定瓶頸在 Modifier 嗎?
- 我試過 `graphicsLayer` 或其他 Lambda Modifier 了嗎?是不是真的沒辦法滿足需求?
- 我這個 Modifier 邏輯是不是真的超級複雜,而且會在很多地方重複使用,值得我花這麼大工夫去優化它?
如果三個答案都不是肯定的「是」,那拜託,用 `Modifier.composed` 或甚至是簡單的工廠函式就好,省下來的時間拿去陪家人或打電動都好。
錯誤三:忽略了「預渲染」的全局觀
有時候,App 卡不是單一 Composable 的問題,而是整個畫面的「初次載入」太慢。使用者點進一個複雜頁面,白畫面一閃,然後元件一個一個「長」出來,感覺就很差。
原文提到一個概念叫 "Pre-rendering",但我覺得「漸進式渲染」或「智慧載入」可能更貼切。這不是單一技巧,而是一套組合拳:
- Baseline Profiles: 這東西超重要,尤其對複雜頁面。它能預先編譯好使用者最常走的畫面路徑,讓 App 啟動跟畫面切換快一個檔次。這大概是 Android 官方給你最接近「預渲染」的工具了。
- 非同步載入: 老生常談了。資料從網路抓,圖片用 Coil 或 Glide 這種函式庫在背景載入。在資料回來前,顯示骨架屏 (Skeleton screen)。
- 善用 Lazy Layouts: 只要是列表,無腦用 `LazyColumn` / `LazyRow`。但千萬記得,一定要提供 `key`!這能幫助 Compose 在資料變動時,聰明地重複使用、移動、或刪除 item,而不是整個列表重畫。有不同 item 型別的話,也別忘了加上 `contentType`。
- 分階段顯示: 先把最重要的、最基本的 UI 畫出來。然後再用 `LaunchedEffect` 或其他 state 變化,去觸發次要內容的顯示。這樣使用者會感覺「秒開」,即使所有東西還沒載入完。
所以,與其鑽牛角尖在單一 Composable 的奈秒級優化,有時候退一步看看整個畫面的載入流程,效果可能更好。
我自己是覺得,學這些進階技巧,最重要的不是背 API,而是理解背後的原理。理解了 Compose 的三階段(Composition, Layout, Draw),你自然就知道什麼時候該做什麼事。希望今天的分享對大家有幫助!
討論一下吧:今天提到的這些技巧,哪一個是你覺得在自己專案中最難搞定,或是踩過最多坑的?是 `withFrameNanos` 的時序問題,還是 `Modifier.Node` 的複雜度?在下面留言分享一下你的「血淚史」吧!
