嗯...今天要來聊點 Compose 效能裡,那種...比較深,甚至有點煩人的東西。不是之前說的 `remember` 啊、`derivedStateOf` 那種改了馬上就看得到效果的。這次要說的是那種...你感覺 App 就是卡,但又說不出個所以然,問題可能藏在你看不到的地方,像是編譯過程、或是你自己寫的 Layout 裡。
重點一句話
如果你的 App 在啟動、或第一次滑動時特別卡,或是某個畫面一直「不該重組卻重組」,那兇手很可能不是你的業務邏輯,而是藏在編譯設定、編譯器報告,或是自訂 Layout 的測量方式裡。
為什麼 App 第一次打開總是卡卡的?
這個應該很多人有經驗。剛裝好的 App,第一次點開,哇,那個動畫、那個滑動,就是會掉幀。這通常不是你手機爛,也不是你 code 寫得特別糟。問題出在 Just-In-Time (JIT) 編譯。
簡單說,就是你的程式碼要到執行的那一刻,系統才開始把它翻譯成機器看得懂的指令。第一次執行嘛,自然就慢了半拍。後續會好一點,因為系統會快取。但那個第一印象...真的很差。
Google 官方的解法,叫做 Baseline Profiles。這東西...嗯,我自己是覺得,有點殺雞用牛刀,但對那種使用者超多、很重視啟動速度的 App 來說,又不能不做。
所以... Baseline Profiles 到底是在幹嘛?
它就是讓你「預先告知」系統,哪些程式碼是使用者最常用到的「關鍵路徑」。比如說,從打開 App 到看到主畫面、滑動列表。然後呢,在 App 安裝的時候,系統就會先把這些路徑的程式碼做 Ahead-of-Time (AOT) 編譯,也就是「預先編譯」。
這樣一來,使用者真的點擊操作時,程式碼早就準備好了,自然就順暢很多。聽起來很美好,對吧?不過設定起來...老實說,有點麻煩。
你得先在專案裡加一個叫 `macrobenchmark` 的模組。對,又多一個 module。然後寫一個測試,用 UI Automator 這種自動化工具,去模擬使用者的操作,把那些「關鍵路徑」跑一遍。
給你看一下大概長怎樣,但先別急著複製貼上,感受一下那個...流程。
// 在你的 :macrobenchmark module (src/androidTest)
@OptIn(ExperimentalBaselineProfilesApi::class)
@RunWith(AndroidJUnit4::class)
class AppBaselineProfileGenerator {
@get:Rule
val baselineProfileRule = BaselineProfileRule()
@Test
fun generate() = baselineProfileRule.collect(
packageName = "你的.app.package.name",
) {
// 這邊就是模擬使用者操作的地方
pressHome()
startActivityAndWait() // 等待主畫面 Activity 算好
// TODO: 真的要自己加上跟 App 互動的程式碼
// 像是...
// device.findObject(By.text("登入")).click()
// device.wait(Until.hasObject(By.text("歡迎回來")), 5_000)
// device.findObject(By.res("main_feed_list"))?.fling(Direction.DOWN)
}
}
跑完這個測試後,它會產生一個 `baseline-prof.txt` 檔案,然後你得手動把它複製到主 App 模組的 `src/main/` 資料夾底下。對,手動。我自己是覺得這個步驟有點...原始。當然你可以寫 script 自動化啦。總之,有了這個檔案,打包 AAB 上傳到 Google Play,Play 就會幫你處理後續的 AOT 編譯了。
這點跟我們在台灣普遍的開發習慣很不一樣,很多團隊可能覺得麻煩就不做了。但說真的,根據 Google 官方的說法,這對啟動時間和滑動流暢度改善,特別是在那些中低階手機上,效果是肉眼可見的。不像在美國,開發者可能人手一台 Pixel 最新款,他們很難體會到那種卡頓感。
當 Compose 一直「亂 re-compose」的時候
這個應該是進階一點的開發者都會遇到的痛。你明明覺得傳進 Composable 的參數都沒變,但它就是一直重組 (recomposition),效能就這樣被浪費掉了。Layout Inspector 看半天也看不出個所以然。
這時候,你就得去偷看 Compose 編譯器的「小本本」了,也就是 Compiler Metrics。
打開編譯器的小本本
你得在 `build.gradle.kts` 裡加一些設定,讓編譯器在編譯完之後,吐一份報告給你。設定有點長,像這樣:
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
compilerOptions.freeCompilerArgs.addAll(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
project.buildDir.absolutePath + "/compose_reports"
)
compilerOptions.freeCompilerArgs.addAll(
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
project.buildDir.absolutePath + "/compose_reports"
)
}
加完之後,跑一次 clean build,你就會在 app/build/compose_reports 這個資料夾(當然路徑是你自己設的)裡面找到幾個 txt 檔。我們要看的是 `_composables.txt` 和 `_classes.txt`。
打開 `_composables.txt`,你會看到你所有的 Composable function,後面會標記著 `restartable`、`skippable` 這些字樣。最理想的狀態是 `restartable skippable`,代表這個 Composable 在參數不變時可以被跳過,是最棒的效能狀態。
但你更該注意的是參數後面被標記的 `unstable`。只要有一個參數是 `unstable`,那整個 Composable 通常就 `skippable` 不了了。報告甚至會告訴你為什麼,例如 `C::MyDataClass is not stable`。
常見原因就是...你用了 `List` 而不是 `ImmutableList`,或是傳了一個 data class,但裡面某個屬性是 `var`。解法通常就是去 `_classes.txt` 找到那個被點名的 class,看看能不能把它改成 truly immutable,或是...逼不得已,就直接在 class 上面加 `@Immutable` 或 `@Stable` 標籤,等於是跟編譯器打包票說「相信我,它是穩定的」。但這招要小心,如果你騙了編譯器,結果亂改裡面的值,可能會出現更難抓的 bug。
那個你引以為傲的自訂 Layout,為什麼會卡?
用 `Layout(...)` composable 來做自訂 Layout 真的蠻酷的,可以排出 `Row` 或 `Column` 做不到的特殊排版。但...這裡面的水也很深。自己寫的 Layout 如果沒寫好,很容易變成 App 裡最卡的那個部分。
問題通常出在「測量」這個階段。一個 Composable 在畫到螢幕上之前,要經過測量 (measure) 跟放置 (place) 兩個步驟。如果你的測量邏輯寫得很沒效率,例如讓子元件被重複測量好幾次,那畫面就會掉幀。
兩個你必須搞懂的概念:Intrinsics 和 SubcomposeLayout
嗯...這兩個名詞聽起來就很勸退。我試著簡單講。
Intrinsic Measurements (內在尺寸測量):這就像是在正式測量前,先做個「民調」。父佈局可以先問子元件:「在不考慮外部空間限制下,你最想多寬/多高?」或「你最小需要多寬/多高?」。這讓父佈局可以做一些預先的規劃,避免掉一些不必要的重複測量。例如,一個 `Row` 想讓所有子元件都跟最寬的那個一樣寬,它就可以先用 intrinsics 問一圈,找出最寬的尺寸,然後再用這個尺寸去正式測量大家。
SubcomposeLayout:這個更強大,也更危險。它允許你在「測量階段」才去「組合 (compose)」一部分的 UI。最經典的例子就是 `BoxWithConstraints`,它先測量自己有多大,然後才把這個尺寸資訊提供給它的 content,讓 content 根據可用空間來決定要顯示什麼。但...它的代價很高,每一次 sub-compose 都像是一個迷你的、全新的 composition 過程,有效能開銷。如果你在一個滾動列表的每個 item 裡都濫用它,那效能肯定會崩。
所以,結論是,除非你真的、真的需要根據「測量結果」來動態決定「要組合什麼內容」,否則盡量不要用 `SubcomposeLayout`。很多時候,透過自訂 `MeasurePolicy` 裡的測量邏輯,或是把尺寸資訊往下傳,就能達到類似效果了。
所以...這些高深工具,我到底該用哪個?
老實說,不是每個專案都需要動到這些。我自己的感覺是這樣啦,可以參考看看。
| 工具 | 它主要修理什麼問題? | 投入的精力 / 麻煩程度 | 我的幾句話 |
|---|---|---|---|
| Baseline Profiles | App 首次啟動卡頓、第一次滑動掉幀。就是那個第一印象很差的問題。 | 高。要搞一個新模組,寫 UI 自動化測試,還要記得更新...真的很煩。 | 如果你的 App 使用者基數很大,或是有那種很挑剔的客戶,那這個投資是值得的。對小專案或內部工具...算了啦。 |
| Compose 編譯器報告 | 畫面不明原因一直重組,效能莫名其妙地差。感覺像在跟 Compose 框架本身打架。 | 中等。設定不難,但讀懂報告、找到 `unstable` 的根源需要經驗,像偵探一樣。 | 我覺得這個應該要變成日常習慣。特別是當你開始用一堆自訂的 data class 當作 state 時,定期掃一下報告可以救你一命。 |
| 自訂 Layout 優化 | 你自己寫的那個很炫的 Layout 在列表裡滑起來會卡。 | 極高。你需要很深入地理解 Compose 的渲染管線,特別是測量那塊。 | 99% 的情況下,用官方提供的 Layout 元件組合一下就夠了。真的要自己幹之前,先問自己三次:「真的有必要嗎?」 |
總之,這些工具都像是效能調校的最後手段。當你把基本的 `remember`、`key`、`derivedStateOf` 都做到最好了,App 還是有地方不順,那才需要從這些...有點硬核的角度去切入。它們不是萬靈丹,比較像是給醫生的核磁共振儀,而不是給一般人的OK繃。
你呢?在這三個「專家級」工具中,你有在真實專案中被迫用過哪一個嗎?是什麼樣的「啊,完蛋了」的瞬間,讓你決定撩下去學的?在下面分享一下你的故事吧。
