直接 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 函數,它會:
-
尋找 ViewModelStoreOwner: 向上查找最近的 Activity、Fragment 或 NavBackStackEntry。
-
利用 ViewModelProvider: 透過 ViewModelProvider 取得或創建 ViewModel 實例。如果已存在,則返回現有實例;如果沒有,則創建並存儲,確保與其 ViewModelStoreOwner 生命週期綁定。
-
綁定生命週期: 這樣獲取的 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,徹底告別預設參數注入的生命週期暗礁!
—

發佈留言