先說答案:會,而且這絕對是寫 Jetpack Compose 最容易被陰的坑。
很多人以為外層包了一層 ImmutableList,或是類別頭上蓋了 @Immutable 的印章,就拿到了免死金牌,以為底下所有元件都不會觸發無效重組了。
但 Compose 的穩定性判斷是遞迴檢查的:只要你最深層的元素包含了一般的 List,這個不穩定的破口就會直接把外層的不可變防護罩給刺穿,讓 Compose 的智慧跳過重組(Smart Recomposition)直接破功!
到底犯了什麼忌諱?我們直接看底層怎麼跑。
Compose 到底在防啥?為什麼這麼怕標準的 List?
在 Kotlin 裡,List<T> 表面上雖然是唯讀介面(只有 get(),沒有 add()),但它在底層本質上通常就是個 java.util.ArrayList:
val standardList: List<String> = mutableListOf("A", "B") // 向上轉型為唯讀 List
這在 Compose 眼裡有兩個致命問題:
- 介面根本防不住可變:外部只要隨便轉型
(standardList as MutableList).add("C"),裡面的內容就被改了,而且記憶體位址(Reference)完全沒變! - 編譯器猜不透它會不會變:Compose Compiler 在編譯時無法保證這份
List永遠不會變。為了避免「資料變了但畫面沒刷新」的事故,編譯器只能保守地把所有List、Set、Map通通打上 Unstable(不穩定) 的標籤。
一旦 Composable 函式的參數被標為 Unstable,只要父層一重組,Compose 就無法信任傳進來的參數,只能強制重新執行這個子元件,完全跳不過(Non-skippable)。
如果層層包進去,到底會發生啥事?4 種實測
如果我們把不可變與可變集合層層嵌套,到底會發生啥事?直接看這 4 種實測組合:
// 組合 1:外層不可變,內層可變
State<ImmutableList<MutableList<T>>>
// 組合 2:外層不可變,內層一般 List
State<ImmutableList<List<T>>>
// 組合 3:內外皆深度不可變
State<ImmutableList<ImmutableList<T>>>
// 組合 4:手動標註 @Immutable 包裝類
@Immutable
data class StableListWrapper<T>(val items: List<T>)
1. ImmutableList<MutableList<T>> —— 畫面裝死,改了也不重組
- 現象:你對內層的
MutableList呼叫.add(),畫面完全不會重組! - 原因:外層
ImmutableList的記憶體位址根本沒變,Compose 判定「外層很穩定,而且物件沒換」,直接跳過重組;而內層的一般MutableList又沒有 Compose 的 State 觀察機制。 - 下場:資料明明變了,UI 卻裝死不動,直到其他無關狀態觸發重組才突然跳出來,產生極其難查的幽靈 Bug。
2. ImmutableList<List<T>> —— 破洞外漏,每次必被強迫重組
- 現象:父層任何無關的 State 改變,接收這個參數的子元件每一次都會跟著被強制重組!
- 原因:雖然外層頂著
ImmutableList的光環,但 Compose Compiler 往泛型裡面一挖,看到了List<T>(Unstable)。編譯器直接宣告:「我不信任你的內部元素!」 - 實戰代碼看 Logcat 刷屏:
@Composable
fun ParentScreen() {
var counter by remember { mutableStateOf(0) }
// 外層是 ImmutableList,但內層是普通 List<String>
val nestedList: ImmutableList<List<String>> = remember {
persistentListOf(
listOf("Alice", "Android"),
listOf("Bob", "Backend")
)
}
Column {
Button(onClick = { counter++ }) { Text("無關計數器: $counter") }
// 💥 每次點擊按鈕,NestedUserList 都會被迫重組!
NestedUserList(data = nestedList)
}
}
@Composable
fun NestedUserList(data: ImmutableList<List<String>>) {
Log.d("Recomposition", "NestedUserList 正在重組...") // Logcat 每次點擊都瘋狂刷屏
LazyColumn {
items(data) { group ->
Text("組別: ${group.joinToString()}")
}
}
}
3. ImmutableList<ImmutableList<T>> —— 深度不可變,堅不可摧
- 現象:父層不管怎麼重組,只要
nestedList沒有換新實例,子元件 100% 完美跳過(Skippable)! - 原因:從外層到內層全部都是由
kotlinx.collections.immutable提供的不可變集合,編譯期推斷完全為 Stable,達成純粹的元件快取。
4. @Immutable data class Wrapper(val items: List<T>) —— 開發者的口頭支票
- 現象:元件確實能夠跳過重組,但這是一張有風險的切結書。
- 原因:
@Immutable是你在向編譯器發誓:「我保證這裡面的東西絕不修改」。編譯器相信了你,強制把它當 Stable 看待。 - 代價:如果有人在外面偷偷改了傳進來的 List 內容,Compose 是不會知道的,畫面一樣會裝死不更新。
那難道要一直手動包標籤?Strong Skipping Mode 出現了
在以前(Compose 1.5 之前),這件事真的是每個 Android 開發者的痛,常常要在專案到處貼 @Immutable,或者逼每個人都把 List 改寫成 ImmutableList。
但從 Compose Compiler 1.5.4+ 引入、並在 Kotlin 2.0 / Compose 1.7+ 預設啟用 的 Strong Skipping Mode(強跳過模式),遊戲規則變了!
Strong Skipping 帶來了啥改變?
- Unstable 參數也可以跳過重組了:
在過去,只要參數有List這種 Unstable 型別,該 Composable 就直接被宣判無法跳過。但在 Strong Skipping 下,所有 Composable 預設都可以跳過(Skippable)! - 比對方式變得更聰明:
- 遇到 Unstable 參數時,Compose 會先做參照比對(
===),若不同則退一步調用equals()做實質內容比對。如果內容根本沒變,就算是一般的List<String>,Compose 也會聰明地跳過重組!
- 遇到 Unstable 參數時,Compose 會先做參照比對(
既然有了 Strong Skipping,為什麼我們還是需要 ImmutableList?
很多人以為「有了 Strong Skipping,老子就可以隨便傳 List 了」,這又是另一個坑:
- 大清單的
equals()是 $O(N)$ 負擔:
如果你的List有 1,000 筆資料,每次父層重組,Strong Skipping 為了確認內容有沒有變,就得跑 1,000 次比對!重組是跳過了沒錯,但 CPU 全浪費在比對字串上了。 ImmutableList的持久化結構優勢:
kotlinx.collections.immutable(例如PersistentList)底層採用 Trie 樹結構共享。如果資料沒變,物件參照一定相同(===瞬間命中,常數時間搞定),根本不用全表遍歷。
總結:實務上該怎麼防重組?
| 場景 | 推薦做法 | 核心理由 |
|---|---|---|
| 單純平鋪列表展示 | kotlinx.collections.immutable.ImmutableList<T> |
表明不可變意圖,省下大清單無謂的深度 equals() 遍歷 |
| 巢狀分組資料結構 | ImmutableList<ImmutableList<T>> 或包成完整不可變 Model |
確保不可變防護「由內而外」徹底封閉,杜絕穿透 |
| 需要動態增刪改查 | mutableStateListOf<T>() |
由 Compose 官方 State 管理,增刪元素精確通知特定 Item 重組 |
| 跨模組/資料庫 Model | 啟用 Strong Skipping Mode,或透過 Mapper 轉成 UI Model | 避免在資料層或商業邏輯層到處依賴 Compose 專用標籤 |
乾淨的實戰範例:
// 1. 深度不可變的 UI Model
@Immutable
data class UserGroupUiState(
val groupTitle: String,
// 內層也必須是不可變集合,形成完整的閉環防護
val members: PersistentList<UserUiState> = persistentListOf()
)
@Immutable
data class UserUiState(
val id: String,
val name: String,
val avatarUrl: String
)
// 2. 呈現層 Composable:在任何版本下都 100% 穩定跳過重組
@Composable
fun GroupList(groups: PersistentList<UserGroupUiState>) {
LazyColumn {
items(groups, key = { it.groupTitle }) { group ->
GroupHeader(title = group.groupTitle)
group.members.forEach { user ->
UserRow(user = user)
}
}
}
}
記住一句話:外層包得再漂亮,只要裡面留了一個可變的缺口,Compose 的重組防護罩就會直接被刺穿。

發佈留言