iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0
Mobile Development

Android 性能戰爭:從 Profiler 開始的 30 天優化實錄系列 第 20

# Day 20:【流暢度戰爭】Compose 中的 Recomposition 戰爭

  • 分享至 

  • xImage
  •  

各位戰士,歡迎來到第二十天的戰場。至今為止,我們所有的戰鬥都圍繞著 Android 的傳統 View 體系展開:優化 XML 佈局、為 RecyclerView 提效。但戰爭的形式已經改變。Google 推出的 Jetpack Compose,以其聲明式的 UI 開發範式,徹底顛覆了我們構建介面的方式。

在 Compose 的世界裡,UI 不再是一個由 View 物件構成的靜態樹,而是一個狀態 (State) 的函式,即 UI = f(state)。當狀態改變時,Compose 會自動更新 UI 來反映這個變化。這個自動更新的過程,就是我們今天要打的這場戰爭的核心——Recomposition (重組)

Recomposition 是 Compose 性能的基石,也是性能問題的根源。過度或低效的重組,就是 Compose 世界裡的「Jank」。今天的任務,就是理解重組的規則,並學會如何像一個精密的外科醫生一樣,只重組「必要」的部分,避免「不必要」的性能浪費。


戰爭規則:Recomposition 如何運作?

要打贏戰爭,先要了解規則。Compose 的每一幀畫面,都經歷三個階段:

  1. Composition (組合):執行你的 @Composable 函式,生成一個描述 UI 的樹狀結構。
  2. Layout (佈局):測量和定位 UI 樹中的每一個元素。
  3. Drawing (繪製):將 UI 元素實際繪製到螢幕上。

當一個由 mutableStateOf() 建立的狀態發生改變時,Compose 會觸發 Recomposition。但它並非愚蠢地重新執行所有 Composable 函式,而是非常「聰明」地只重新執行那些直接讀取了該狀態的 Composable。這就是所謂的「智慧重組」或「範圍化重組 (Scoped Recomposition)」。

來看一個經典的例子:

@Composable
fun MyScreen() {
    var count by remember { mutableStateOf(0) }

    Log.d("Recomposition", "MyScreen is recomposed")

    Column {
        // 這個 Text 不讀取 count 狀態,它不會重組
        Text(text = "這是一個靜態的標題")

        Button(onClick = { count++ }) {
            Log.d("Recomposition", "Button is recomposed")
            // 這個 Text 讀取了 count 狀態,它會重組
            Text(text = "你點擊了 $count 次")
        }
    }
}

當你點擊按鈕時,你會在 Logcat 中看到 "MyScreen is recomposed" 和 "Button is recomposed" 的日誌。但 Text("這是一個靜態的標題") 這個 Composable 不會被重新執行,因為它完全不關心 count 的變化。Compose 精準地跳過了它。

我們的目標,就是盡可能地保護和利用 Compose 的這項智慧。

頭號敵人:不必要的重組與「不穩定」的類型

Jank 的來源,就是那些我們沒有預料到、範圍過大、或者過於頻繁的「不必要重組」。而導致這一切的罪魁禍首,通常是傳遞了「不穩定」(Unstable) 的類型作為 Composable 的參數。

  • 穩定 (Stable) 類型:Compose 編譯器可以 100% 確定其內部值是否發生了變化的類型。如果一個物件的所有公開屬性都是 val 且其類型也都是穩定的,那麼它就是穩定的。

    • 所有原始類型 (Int, String, Float 等) 都是穩定的。

    • 由 val 和穩定類型構成的 data class 是穩定的。

  • 不穩定 (Unstable) 類型:Compose 編譯器無法確定其值是否改變的類型。

    • 任何包含 var 屬性的普通 class 都是不穩定的。
    • 標準的 List, Set, Map 等集合類型都是不穩定的,因為它們是可變的。

為何這很重要?

當你將一個不穩定的物件傳遞給一個 Composable 時,Compose 無法確定這個物件的內部到底變了沒有。為了安全起見,它會採取一種保守策略:只要這個 Composable 的父節點發生重組,它自己也必須跟著重組,哪怕那個不穩定的物件內容根本沒變。

這就破壞了「智慧重組」的原則,導致了大量不必要的性能開銷。

// 不穩定的 Class,因為 name 是 var
class User(var name: String)

@Composable
fun UserGreeting(user: User) {
    Text("Hello, ${user.name}")
}

// 穩定的 data class,因為 name 是 val
data class StableUser(val name: String)

@Composable
fun StableUserGreeting(user: StableUser) {
    Text("Hello, ${user.name}")
}

在上面的例子中,UserGreeting 很可能會被頻繁地重組,而 StableUserGreeting 則能更好地利用智慧重組的優勢。

致勝戰術:如何避免不必要重組

  1. 戰術一:擁抱不可變性 (Immutability)
  • 盡可能使用 data classval 來定義你的狀態物件。

  • 當需要傳遞集合給 Composable 時,優先使用 kotlinx.collections.immutable 函式庫中的不可變集合 (ImmutableList, ImmutableSet 等),而不是標準的 List。

  1. 戰術二:延遲狀態讀取
  • 原則:狀態在哪裡被讀取,重組的範圍就在哪裡。因此,應盡可能地將狀態的讀取「推遲」到最需要它的、層級最深的 Composable 中。

  • 技巧:不要直接傳遞狀態值,而是傳遞一個讀取該值的 Lambda (() -> T)。

// 不好的寫法:MyParent 讀取了狀態,會跟著 Header 一起重組
@Composable
fun MyParent(viewModel: MyViewModel) {
    val headerText = viewModel.headerText.value // 在這裡讀取
    Header(text = headerText)
}

// 好的寫法:MyParent 不讀取狀態,只傳遞 lambda。重組被限制在 Header 內部
@Composable
fun MyParent(viewModel: MyViewModel) {
    // MyParent 不再關心 headerText 的值,它不會因為 headerText 變化而重組
    Header(textProvider = { viewModel.headerText.value })
}

@Composable
fun Header(textProvider: () -> String) {
    val text = textProvider() // 在這裡才讀取
    Text(text = text)
}
  1. 戰術三:使用 derivedStateOf 處理衍生狀態
  • 當某個狀態是由一或多個其他狀態計算得出時(例如 val buttonEnabled = name.isNotEmpty()),應該使用 derivedStateOf
  • derivedStateOf 會建立一個新的 State 物件,它只在其計算結果真正發生改變時,才會通知讀取它的 Composable 進行重組,避免了因無關狀態變化導致的重算和重組。
val isButtonEnabled by remember {
    derivedStateOf {
        username.value.isNotBlank() && password.value.length > 5
    }
}

偵查工具:Layout Inspector

Android Studio 的 Layout Inspector 現在完全支援 Compose。它有一個殺手級功能:顯示 Composable 的重組計數。你可以打開它,操作你的 UI,實時地看到哪些 Composable 在高亮閃爍,以及它們被重組了多少次。這是揪出不必要重組的最直觀的工具。

今日總結

今天,我們踏入了 Compose 的性能世界,打響了 Recomposition 戰爭。

  • 我們理解了 Compose 的核心是智慧重組。

  • 我們鎖定了頭號敵人是因不穩定類型導致的不必要重組。

  • 我們掌握了三大致勝戰術:使用穩定類型、延遲狀態讀取、使用 derivedStateOf

Compose 的性能優化是一門精細的藝術。明天,我們將討論 UI 流暢度戰爭中的最後一個共通原則,無論是傳統 View 還是 Compose 都必須遵守的鐵律:【主執行緒的守護者:嚴禁 I/O 操作】,並再次強調協程 (Coroutines) 在這場戰爭中的決定性地位。

我們明天見!


上一篇
# Day 19:【流暢度戰爭】RecyclerView 優化:不只是 ViewHolder
下一篇
# Day 21:【流暢度戰爭】主執行緒的守護者:嚴禁 I/O 操作
系列文
Android 性能戰爭:從 Profiler 開始的 30 天優化實錄22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言