fun GameScreen(gameViewModel: GameViewModel = GameViewModel()) 這樣寫有啥問題?

直接 new GameViewModel()

class GameViewModel : ViewModel() {
    var score by mutableStateOf(0)
    init { println("ViewModel init!") } // 每次重組都會執行
    override fun onCleared() { println("ViewModel cleared!") } // 永遠不會被系統呼叫
}

@Composable
fun GameScreenWrong(gameViewModel: GameViewModel = GameViewModel()) {
    
    // ... UI 顯示 gameViewModel.score ...
    Button(onClick = { gameViewModel.incrementScore() }) { /* ... */ }
}

使用 viewModel() 函數

@Composable
fun GameScreenCorrect(
    gameViewModel: GameViewModel = viewModel() // <-- 正確方式!
) {
    // ... UI 顯示 gameViewModel.score ...
    Button(onClick = { gameViewModel.incrementScore() }) { /* ... */ }
}

viewModel() 是一個 Composable 函數,它會:

  1. 尋找 ViewModelStoreOwner: 向上查找最近的 Activity、Fragment 或 NavBackStackEntry。

  2. 利用 ViewModelProvider: 透過 ViewModelProvider 取得或創建 ViewModel 實例。如果已存在,則返回現有實例;如果沒有,則創建並存儲,確保與其 ViewModelStoreOwner 生命週期綁定。

  3. 綁定生命週期: 這樣獲取的 ViewModel 會在 ViewModelStoreOwner 銷毀時由框架自動清理,並呼叫 onCleared()。


深度拆解:直接 new GameViewModel() 到底在底層犯了什麼忌諱?

1. 重組呼叫棧對預設參數的重複求值(Default Argument Re-evaluation)

Kotlin 的預設參數不是編譯期常數,而是一個由編譯器生成的動態求值表達式(Dynamic Expression)。
在 Compose 編譯期轉換後,只要外層觸發 Recomposition(重組),且呼叫端未顯式傳入實例,運行期呼叫棧每次都會重新執行一次 new GameViewModel()!一秒鐘若重組多次,就會頻繁在 JVM 堆上建立大量短命物件。

2. 生命週期脫軌:缺少 ViewModelStoreOwner 的字典映射

正統的 viewModel() 是透過 LocalViewModelStoreOwner.current,在 ViewModelStore 的 HashMap 內部以 CanonicalName 為 Key 尋找快取;存在則直接返回,不存在才呼叫 Factory 實例化並綁定 Lifecycle。
直接 new GameViewModel() 僅僅是在 JVM 堆上產生一個孤兒 Java 物件:

  • 螢幕旋轉狀態歸零:沒有掛載到 Activity / Fragment 的 ViewModelStore,Configuration Change 發生時物件直接隨畫面銷毀,狀態全部蒸發。
  • onCleared() 永不觸發:Android 系統壓根不知道它的存在,ViewModel 內部啟動的協程、監聽器將產生靜默記憶體洩漏。

現代正統架構:狀態提升(State Hoisting)與 Stateful/Stateless 路由分離

在生產級 Android Compose 架構中,最優雅的做法並非在 Composable 參數直接依賴 ViewModel,而是將 ViewModel 鎖定在路由層,呈現層維持純粹無狀態(Stateless):

// 1. 路由層(Stateful):對接 ViewModelStore 與 Navigation 生命週期
@Composable
fun GameRoute(viewModel: GameViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    GameScreen(
        uiState = uiState,
        onAction = viewModel::dispatch
    )
}

// 2. 呈現層(Stateless):純粹由 State 驅動渲染,預覽與單元測試 0 ViewModel 依賴
@Composable
fun GameScreen(
    uiState: GameUiState,
    onAction: (GameAction) -> Unit
) {
    Text(text = "Score: ${uiState.score}")
    Button(onClick = { onAction(GameAction.Increment) }) { Text("+1") }
}

如此一來,UI 預覽只需傳入 Mock State,徹底告別預設參數注入的生命週期暗礁!

—

發佈留言

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