@Immutable @Stable 如果 immutableList 內包含 list .這樣會造成 recompstion 嗎?

先說答案:會,而且這絕對是寫 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 眼裡有兩個致命問題:

  1. 介面根本防不住可變:外部只要隨便轉型 (standardList as MutableList).add("C"),裡面的內容就被改了,而且記憶體位址(Reference)完全沒變!
  2. 編譯器猜不透它會不會變: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 帶來了啥改變?

  1. Unstable 參數也可以跳過重組了:
    在過去,只要參數有 List 這種 Unstable 型別,該 Composable 就直接被宣判無法跳過。但在 Strong Skipping 下,所有 Composable 預設都可以跳過(Skippable)!
  2. 比對方式變得更聰明:
    • 遇到 Unstable 參數時,Compose 會先做參照比對(===),若不同則退一步調用 equals() 做實質內容比對。如果內容根本沒變,就算是一般的 List<String>,Compose 也會聰明地跳過重組!

既然有了 Strong Skipping,為什麼我們還是需要 ImmutableList?

很多人以為「有了 Strong Skipping,老子就可以隨便傳 List 了」,這又是另一個坑:

  1. 大清單的 equals() 是 $O(N)$ 負擔:
    如果你的 List 有 1,000 筆資料,每次父層重組,Strong Skipping 為了確認內容有沒有變,就得跑 1,000 次比對!重組是跳過了沒錯,但 CPU 全浪費在比對字串上了。
  2. 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 的重組防護罩就會直接被刺穿。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *