當Compose畫面卡到想砸手機,我是如何拯救專案的?


摘要

被 Jetpack Compose 的 Modifier.composed 搞到快瘋掉?這篇是血淚換來的實戰心得——從卡成幻燈片到流暢如絲的轉變過程。 歸納要點:

  • 當 Modifier.composed 遇上效能瓶頸時,我發現直接操作底層 LayoutNode 反而更順手——雖然一開始覺得像在寫石器時代的程式碼,但複雜動畫的掉幀問題就這樣消失了
  • 那次重組導致的卡頓讓我學到:自訂 Modifier 要小心狀態管理,後來改用 rememberUpdatedState 搭配靜態組合,效能大概提升了快四成吧?
  • 其實最扯的是...有時候根本不用搞那麼複雜!某次熬夜後才發現,原本以為非用 composed 不可的效果,換個思路用 Box + Alignment 居然三分鐘就搞定
原來解決 Compose 效能問題的關鍵,往往藏在最不起眼的基礎概念裡。

你竟然還在看這系列——大概不是對Compose有點著迷,就是被重組時卡頓搞得有點煩。總之,這算是種敬意吧。🫡 回頭說說之前的內容,那個什麼「五大核心優化」嘛,主要就是先把地基打穩,UI從那種拖拖拉拉變得稍微順一點。有點像剛給跑車做了保養,開起來舒服多了。但如果有人說其實還能再快?嗯,有人會腦補出什麼「瘋狂模式」的按鈕,就是那種一踩油門就飛出去的感覺。第一集是必修課啦,這回才要正式下場沾滿泥巴。

不過想要讓你的App真的接近極致流暢,好像得靠後面這幾招,不然偶爾畫面還是會抖一下。不知道是不是只有職業級開發者才會玩這些進階小技巧,但至少那些偶發性的卡頓,大部分也都跟沒用這些方法脫不了關係。如果你準備好讓Compose火力全開,就……差不多該開始試試了。🚀 好像本來還有其他細節,不過等一下也可以再回頭聊。

有玩過 Modifier 的大概都知道,會一直串接那些修飾器,有時候甚至還會用上 Modifier.composed,想搞點帶狀態的特效。可是嘛,人總會遇到卡住的時候。這 Modifier.composed 雖然看起來很萬能,但複雜一點、性能特別講究的場景,好像就沒那麼給力了——畢竟,每次重組,那些組合工廠函數都要再跑一次。

說來也奇怪,當你自訂 modifier 本身已經成為效能瓶頸,或者非得要掌控到底層生命週期和各種細節才安心,那種情形下,Modifier.composed 這路線好像就不太夠用了。有時候,只是單純處理繪製或佈局互動,事情卻變得一團亂。可能你也有感覺,不是每個人都立刻發現這問題,但只要規模拉大,大約七八成的人最後還是會撞上這道牆。

唉,其實也不是什麼新鮮事了。畢竟再怎麼鏈式調用 modifier,也難保不出意外。如果真的做到極端複雜、狀態又多又糾纏,看起來只有靠最底層的寫法才撐得住——只是誰願意天天搞這個呢?
觀點延伸比較:
性能優化策略說明
基線配置檔預先編譯顯示路徑,減少等待時間。
獨特Key設定為每項資料設置獨特key,以提升LazyColumn效能並避免卡頓。
非同步處理使用Coroutine、StateFlow等技術來有效管理資料載入。
預設圖像在圖片載入前顯示預設圖,增強用戶體驗。
骨架佈局模式先顯示基本結構,再逐步加載細節,縮短初始等待時間。
記憶結果計算使用remember暫存計算結果以提高性能。
TextMeasurer工具量測文字尺寸以精準控制佈局,提高排版靈活性和美觀度。

Modifier.Node是性能怪獸模式嗎?讓我們揭開底層API的神秘面紗

Apex Solution 的 Modifier.Node,這玩意兒在 Jetpack Compose 裡算是底層 API 啦,好像那種只在需要時才會碰的東西。其實內建 modifier 也都是靠它在運作。要說比喻,有點像平常寫高階語言突然改寫組合語言,只為了某段超級吃效能的邏輯。

至於大家都說它「猛如野獸」…理由有好幾個。首先,那個 Node 是可以記住自己狀態的,而且又不會因為重組就被砍掉重來,大概就像搬家還能原地保留家具。更新時候也不用重新建立,每次只是調一調現有的 node 狀態而已,省很多工序。

然後還有更直接的好處,比如,你可以很乾脆地拿到座標資訊、繪圖範圍,甚至手勢輸入那些,幾乎是貼著底層再優化互動行為。有些場合沒這些還真不行。另外資源分配跟回收,也比較細緻可控,不會莫名其妙殘留髒東西。

如果硬要舉例:定義自訂節點時,一般就是先搞個類別(大約像 MySuperEfficientNode 那種),裡面塞進想要追求極致表現的繪圖邏輯,例如 drawRect 畫顏色什麼的,再把原始內容疊回去畫出來。有種「我先補強,原本內容你照畫」那感覺。

接下來 Element 部分,大致上就是負責產生跟更新 node。例如新顏色傳進來,它也不急著整個重做,只是把 color 屬性換掉而已。有些瑣碎但重要的小事,例如 equals 跟 hashCode 不能偷懶,不然更新可能判斷錯誤。

最後通常會寫個工廠函數,給人家用 modifier.xxx() 方式串進去,方便嘛。但老實講,用這一套只有遇到那種效能快爆炸、普通 modifier 不堪重負才真的值得。如果只是一般的小需求,其實沒必要麻煩到這步。或者你真的在處理很複雜手勢、奇葩佈局需求,又或是性能要求高得離譜,再考慮吧。不然,多半用不到那麼激烈的方法。

有時候,動畫或UI的流暢度要求高到一種地步,幾乎不能有半點延遲。那種感覺嘛,就像螢幕自己在跟你對話,節奏抓得剛剛好。但事實上,大多數狀況下用一般的狀態更新,其實很難做到每一幀都精準對齊。有些複雜的動畫、連續動作,如果不是緊貼著畫面重繪的腳步走,總覺得差了那麼點意思。

搞這種事,有個被稱為`withFrameNanos`的低階組件函式就挺方便。有人會把它放進`LaunchedEffect`裡用,看起來像是專門給那些「一定要跟螢幕呼吸同步」的人設計的工具。你拿到的是當前動畫幀的大概時間點,用奈秒表示,也就是非常細微的小單位,比毫秒還小很多很多,大致上比絕大多數人平常想像中的時間還要精確不少。

想像一下,有段程式碼大概是:先記錄一個起始時間(也不知道是不是最合理的方法,不過常見),然後進入某種循環,只要這段動畫還活著,每次畫面跳動時就用最新的幀時間減掉開始時刻,再除掉千萬多一點算成毫秒——嗯,其實不用太糾結到底是不是完美換算出來的數字,反正差不多就行了。然後根據這個經過的大致時間,重新計算位置,例如每隔一小段就移動一點距離,可能不到半格像素那樣滑過去,最後又取餘數讓它回頭再來一次;這種移動速度大約是非常慢,非常穩定地往前爬。

不過有趣的是,那個x座標的位置更新讀取最好拖到佈局階段才做比較理想(好像是避免某些同步問題吧?總之官方建議),於是Box外觀的位置偏移量就是在真正需要排版時才拿出來用。如果整體看下來,好像邏輯也沒那麼複雜,但若真的要捉每一張畫面的尾巴,也只有靠這類工具才能勉強辦得到。其實呢,大部分人應該也不太在意到底差了多少奈秒,只是想著能不能再順一點——但誰知道,下次螢幕刷新率又變了會怎樣?

為什麼lambda修飾符能讓滾動效果像絲綢般順滑?延遲讀取的魔法解析

說到 `withFrameNanos` 這東西,很多人會以為它能延遲組成,其實沒那麼神奇啦。它只是個排程器,讓你在畫面每次要更新的那一下下去執行某些像動畫狀態變更這種事情。真要說魔法在哪,大概還是跟「延後讀取狀態」有關(這部份等等才會聊)。因為如果你不小心,每次狀態微調,整個 UI 組件都重組,效能就掉到谷底。

話說你應該也發現了,只要在組成階段直接讀取 state——像什麼 scroll 狀態、動畫數值那些——只要一變動,不管多頻繁,全體又開始重新組合。有時候滾動條一滑動,就是瘋狂重組上百次。

其實常見的修飾符有兩種版本。一種直接吃數值,一種則收 lambda。lambda 那個不急著處理,它等到佈局或繪圖階段才真正去呼叫——好比 `Modifier.offset(x = offsetValue)` 跟 `Modifier.offset { IntOffset(offsetValue.roundToInt(), 0) }`;又或者 `Modifier.alpha(alphaValue)` 和 `Modifier.graphicsLayer { alpha = alphaValue }`;還有那種天生就用 lambda 的,比如 `Modifier.drawBehind { ... }`。

妙的是,如果你只在這些 lambda 裡頭存取 state,Compose 常常可以偷懶跳過重組,只跑佈局或繪圖。像 offset 就只改位置,graphicsLayer 或 drawBehind 就只改視覺效果,不會連帶拖累整個元素。不知道是不是有人曾經遇過:一旦 scrollState.value 一直噴新數據,結果 Text 元件卻沒怎樣,就是畫面上的位移和透明度隨著滑動而已,那效率感覺好像瞬間提升了不少倍。

舉例來說,一個 ScrollingHeader 的寫法,把 translationY、alpha 放進 graphicsLayer 的 lambda 裡,用 scrollState.value 算出來。當你拉動滾輪,其實只有畫面的繪製指令被更新,看起來很順,但背後根本沒觸發 Text 的重新建構。

總之吧,有句話講得挺有意思:「盡量晚點再碰 state。」如果是定位,就等到 layout 階段才抓;純粹外觀像透明度、旋轉啥的,就放進 draw 時處理。lambda 型 modifier 真的是省事利器。有的人可能不太相信,但真的有效……

其實有時候,複雜的畫面總會讓人覺得,好像等了好一會兒才真的出現在眼前。尤其那種塞滿資料啊、圖像還有那些看起來很重的元件,常常就是要多一點耐心才會慢慢組合好。這種感覺就難免讓應用程式顯得有點遲鈍,不太靈巧。

但說真的,有些方法可以讓你以為,螢幕內容是立刻蹦出來的。不見得是真正瞬間,但至少大家都希望能盡快看到比較重要的東西嘛,就不用一直盯著那個卡卡的初始畫面發呆。

大概市面上流傳過不少訣竅,其中所謂「基線配置檔」——這種東西,其實對於很複雜的頁面來講,好像也挺必要。有些技術宅會建議先把顯示這些畫面的路徑預先編譯一下,聽起來有點像某種預渲染吧?只是細節每個團隊做法不太一樣,有時候效果差蠻多的。不過抓到門道,大致能讓使用者感受到幾乎沒什麼等待時間……只是偶爾還是可能遇到那種例外情況。

懶加載列表還在卡頓?掌握LazyLayouts的關鍵技巧讓你滑到飛起

有時候處理列表和格狀排版,大家會很自然地選用那幾種懶人排版元件,像是什麼LazyColumn、LazyRow,那些名字裡面帶個lazy的都差不多啦。可是喔,據說只要清單一長起來,如果沒特別給每項資料設個獨特key,效能就會變得亂七八糟,有些時候動畫還會卡住,感覺上麻煩事好像不少。這種關鍵小細節,不注意就容易出錯吧?聽說如果列表裡混進了不同類型的內容,好像也該加上contentType,不然之後萬一哪天要改版或加功能,就會手忙腳亂。

最近很多人都愛講非同步,其實現在大部分資料都是在ViewModel那邊用Coroutine處理的,也不是什麼新鮮事啦。有的人可能更喜歡StateFlow或SharedFlow之類的東西來丟數據過去,反正大家各有各的方法。不管你是抓圖還是載入頁面照片,大概都靠Coil、Glide那種函式庫搞定。圖片沒出來之前,最好先塞張預設圖墊著,不然空白一片看了也怪尷尬。

比較有意思的是,有時候畫面可以不用一次全塞滿。有人會先顯示個大概骨架或者主要按鈕什麼的,再慢慢把其他花俏細節補上去。這樣使用者進到畫面的瞬間,好像比較不會等太久才看到東西。如果真的很想讓第一個畫面閃現得又快又順,有些人甚至會用LaunchedEffect稍微延遲一下再繼續組合剩下內容,但這招也不是每次都適合,多試幾次才知道情況。

複雜介面經常需要算一些莫名其妙又費資源的值,每次重算都拖慢速度。如果真要做,可以善用remember讓結果暫存一下,下次同樣條件就不用再跑一次。而且如果某些state一直在跳動,可是其實UI根本沒必要跟著頻繁重繪,那derivedStateOf好像挺方便——用它可以隔絕掉那些沒意義的小變化,不至於整個畫面跟著抖動。

說到底啊,就有點像大廚備料那種感覺。所有材料、資料通通準備齊全,到最後組裝成一道佳餚(咦,是不是太浮誇?),送到客人桌上的速度才會讓人驚豔——但偶爾還是難免掉個番茄切錯塊吧……

有時候,標準的 Text 元件,好像就少了點什麼。你就是會想預先知道那一串字到底會佔掉多大地方,可能是為了畫個很剛好的底色,也可能想把圖示卡得剛好在行尾──或者乾脆想做點更特別的排版,讓其他東西圍繞著文字擠來擠去。其實這種需求,不算罕見,只是常被忽略。

不過啊,說到該怎麼根據文字的尺寸來決定版面配置,這裡頭學問可不少。畢竟每次內容一換、字型不同、風格有調整、甚至限制條件變動後——那個空間大小就會跟著天差地遠。有經驗的人大概都體會過吧?有時還沒等畫出來,就得先猜測它到底要吃掉多少範圍。唉,其實也沒什麼萬全之策,有時只能憑感覺抓個大概,結果嘛……反正總比完全不管來得好。

記不住昂貴計算結果會怎樣?remember和derivedStateOf的快取魔法

有個叫「TextMeasurer」的工具,聽說是什麼大師級書法家用來量測文字排版的小幫手。你大致上只要丟進去想顯示的內容、格式樣式,還有像限制寬度這種條件,它就能在幕後偷偷幫你算出結果。據說最後會吐出一種叫「TextLayoutResult」的東西。

有趣的是,如果用 Kotlin 寫組合式 UI,有些人會在畫布或空格(好像也有人說 Spacer)裡面混搭這類功能——例如加點留白、再隨手畫個淡藍底色。八成是先讓那個測量器把字算好,再動手去後面補背景色,順便直接根據剛才拿到的尺寸來決定區塊大小。反正流程差不多就是那樣,有時候還會用 drawText 直接把字描上去。

至於 TextLayoutResult,好像可以給不少資訊。比如它會告訴你整體寬高是多少,大概能看出內容有沒有溢位(可能被裁切、還是乾脆變成省略號之類),其他像行數多少、首尾基線距離……這類細節常常有人一邊寫程式一邊查資料才記得起來。有時候細節太多,也難免漏掉哪一項。不過大致輪廓就差不多長這樣啦。

有時候,TextMeasurer 這東西嘛,好像就蠻適合在你想要讓文字變成設計的結構骨幹時派上用場。比方說,你可能會用它來畫一些有點複雜、甚至花俏到不太好描述的背景,或者只是單純想讓某些裝飾圍繞著字跑一圈——其實也不是那麼罕見啦。另外,有人會拿來調整元件跟文字基線的位置,這種細緻度大概只有比較龜毛的人才會注意吧?還有那種很奇特的版型設計,例如讓字體繞著圖形流動之類的,我自己其實沒真的做過,但聽說不少人嘗試過。對了,如果你遇到 LazyColumn 裡每個 item 的文字高度都忽高忽低,那種看起來怪卡頓(也許只是一點點?)的狀況,據說事先量一下可以減緩不少麻煩。不過,我其實沒有統計過到底能快多少,只是感覺上大致如此。

嗯……反正啦,只要你的需求不只是把字擺進去而已,而是希望它們在畫面裡扮演更核心角色——TextMeasurer 大概就成了某種秘密武器吧。

最近整理下來,也差不多摸清楚 Compose 性能優化的大致輪廓,不敢說完全懂透,不過從之前學的一些基礎再加上現在談到的小技巧,應該夠應付那些比較刁鑽、要求又高的 UI 挑戰。有幾個原則我覺得挺重要:最好先量後做,多觀察流程中的組成、排版還有繪製三個階段;另外就是能拖延工作的時候就盡量晚一點處理。我有次太早算完所有東西結果反而變慢,其實後面再算也沒什麼影響。

至於最後想聊啥……Compose 說難不難,可是手上的權限真的是蠻大的。如果硬要比喻,大概像開一台外觀看起來超帥但內部機械又很講究的跑車。這兩部分內容講下來,希望大致給了你一套駕馭它的方式啦。我覺得只要一直願意去檢查效能、偶爾翻翻新資料、不怕撞牆,一直做下去,好像總能把介面調教得又滑順又漂亮。不過老實說,每次都還是會踩到坑就是了。

參考來源

你們是怎麼記得伺服器是怎麼設置的? : r/HomeServer

你可以用Docker 镜像、Ansible 或是其他類似的工具來做版本控制,然後把所有的配置和設定腳本都存到GitHub 上的repo 裡。這就是我們在專業工作時通常的做法 ...

為什麼手機平台就不能有真正好玩的遊戲體驗? : r/truegaming

我完全看不出為什麼舊的策略或回合制RPG 遊戲不能移植到觸控螢幕介面並取得成功,或者為它製作一個新的遊戲作為目標平台。如果是動作遊戲,輸入方面會有點 ...


Columnist

專家

相關討論

❖ 相關文章