Compose UI 效能優化:5 個進階技巧讓應用程式流暢度提升

Published on: | Last updated:

今天要來聊聊 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(重組)。畫面上的東西明明沒變,就只是位置跟透明度改一下,但整個元件的函式都在重跑。這就是卡頓的來源。

Compose UI 運作的三個階段,以及聰明的優化如何跳過第一階段
Compose UI 運作的三個階段,以及聰明的優化如何跳過第一階段

怎麼做:用 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。 可以在實際繪製前,就拿到精準的文字寬高、行數等資訊。 多了一步測量的動作,記得要用 rememberTextMeasurerdrawWithCache 包起來,避免重複運算。

說到這個,官方的 `Android Developers` 文件其實有提到 `Modifier.Node` 的底層原理,但老實說,寫得有點太學術了。反而是一些國外 GDE (Google Developer Expert) 的部落格,像是 Chris Banes 的文章,會用更實際的例子去解釋「為什麼」需要這東西。而在台灣,我印象中之前好像也有技術社群,像是 GDG Taipei 或是哪位講者在 MOPCON 提過類似的概念,重點都是「不要為了一點點小優化就去用最複雜的工具」。

透過 Lambda Modifier 優化,滾動體驗從卡頓變為絲滑的對比
透過 Lambda Modifier 優化,滾動體驗從卡頓變為絲滑的對比

常見錯誤與修正

看了這麼多酷東西,很容易就想每個都拿來用用看。但...千萬別。這裡列出幾個我常看到大家踩的坑。

錯誤一:為了 `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 會非常痛苦。在你拿出這個工具之前,請先問自己:

  1. 我用 Android Studio 的 Profiler 了嗎?真的確定瓶頸在 Modifier 嗎?
  2. 我試過 `graphicsLayer` 或其他 Lambda Modifier 了嗎?是不是真的沒辦法滿足需求?
  3. 我這個 Modifier 邏輯是不是真的超級複雜,而且會在很多地方重複使用,值得我花這麼大工夫去優化它?

如果三個答案都不是肯定的「是」,那拜託,用 `Modifier.composed` 或甚至是簡單的工廠函式就好,省下來的時間拿去陪家人或打電動都好。

使用 TextMeasurer 實現文字與背景圖形的完美貼合
使用 TextMeasurer 實現文字與背景圖形的完美貼合

錯誤三:忽略了「預渲染」的全局觀

有時候,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` 的複雜度?在下面留言分享一下你的「血淚史」吧!

Related to this topic:

Comments

  1. Guest 2025-08-03 Reply
    孩子,這些技術聽起來好像很厲害喔。不過,你是不是又在鑽研那些深奧的程式東西?老實說,我有點擔心你會不會太投入,忘記休息啊。不過,如果真的能幫你工作更順利...
撥打專線 LINE免費通話