剛開始做 TackleBox——以及規劃 CastLoop_Android(那個還在早期設定階段)——的時候,我花了不少時間在想狀態到底該放哪裡。Compose 很好用,但它不會告訴你怎麼組織架構,它就只是把你給它的東西畫出來。試了一陣子之後,StateFlow + ViewModel 這個組合變成我的標準答案。這篇整理一下我的理解。
基本結構
我最後落定的模式大概是這樣:ViewModel 持有 StateFlow<UiState>,Composable 去收集它。
data class TackleUiState(
val isLoading: Boolean = false,
val items: List<TackleItem> = emptyList(),
val error: String? = null
)
class TackleViewModel(private val repo: TackleRepository) : ViewModel() {
private val _uiState = MutableStateFlow(TackleUiState())
val uiState: StateFlow<TackleUiState> = _uiState.asStateFlow()
init {
loadItems()
}
private fun loadItems() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
repo.getItems()
.catch { e -> _uiState.update { it.copy(isLoading = false, error = e.message) } }
.collect { items ->
_uiState.update { TackleUiState(items = items) }
}
}
}
}
有幾個地方值得特別說。MutableStateFlow 是 private 的,對外只暴露 StateFlow。這很重要——如果你把可寫版本漏出去,任何地方都可以改你的狀態,那 ViewModel 管理狀態的意義就沒了。剛開始很容易犯這個錯。
把 UiState 寫成一個 data class,裡面放 isLoading、error、data 也解決了一個我踩過的坑:以前我有時候更新了 items 卻忘記清掉 isLoading,結果 UI 一直轉圈圈。用一個整體的 state object,每次都是整個替換,比較不容易漏掉。
在 Compose 裡收集狀態
Composable 這邊的寫法:
@Composable
fun TackleScreen(viewModel: TackleViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when {
uiState.isLoading -> CircularProgressIndicator()
uiState.error != null -> ErrorMessage(uiState.error!!)
else -> TackleList(uiState.items)
}
}
比較推薦的做法是用 collectAsStateWithLifecycle() 而不是普通的 collectAsState()。有 lifecycle 感知的版本會在 app 進背景的時候暫停收集,不會做多餘的事,電池也比較省。這個差別我是看文件才注意到的——兩個方法名字太像了,很容易直接用錯的那個。
不過目前 TackleBox 還是用普通的 collectAsState(),因為還沒有引入 lifecycle-runtime-compose 這個依賴。等之後補上這個依賴,再做遷移——改動其實不大,但行為會更正確。
使用者操作的處理方式
對於使用者的互動,我直接在 ViewModel 上暴露 function,不用事件 channel,也不用 sealed event class——至少對簡單的操作來說是這樣。
// ViewModel
fun deleteItem(id: String) {
viewModelScope.launch {
repo.delete(id)
}
}
// Composable
TackleItem(
item = item,
onDelete = { viewModel.deleteItem(item.id) }
)
有些教學會用 UiEvent sealed class 搭配 Channel。那種做法在需要一次性效果的時候有用,比如顯示 Snackbar 或導航跳頁——這些不應該存在 UI 持久狀態裡。但一般的資料操作,直接呼叫 function 就夠了,而且更容易讀懂。
Room 的整合
接上 Room 之後,DAO 回傳 Flow<List<Item>> 跟這個架構非常搭:
// DAO
@Query("SELECT * FROM tackle_items ORDER BY name ASC")
fun observeItems(): Flow<List<TackleItemEntity>>
// ViewModel 收集並 mapping
repo.observeItems()
.map { entities -> entities.map { it.toDomain() } }
.collect { items -> _uiState.update { it.copy(items = items, isLoading = false) } }
Room 每次資料庫有變動就會 emit 一個新的 list,UI 自然就跟著更新,不需要手動去刷新。這是少數「就這樣直接通了」的體驗之一。
StateFlow 和 LiveData 的差別
我一開始用 LiveData,因為大部分舊教學都這樣。後來換成 StateFlow 就沒回頭過。主要幾個原因:
- StateFlow 需要初始值,所以第一次收集不會拿到
null - 它是純 Kotlin,ViewModel 不需要 import Android 的東西,測試也比較方便
- 可以直接用 coroutine 的 operator 像是
map、combine、filter LiveData需要 lifecycle owner 才能觀察;StateFlow在 ViewModel 層沒有這個限制
Migration 也不難,主要就是換類型,把 MutableLiveData 改成 MutableStateFlow。
我最常犯的錯誤
把邏輯寫在 Composable 裡。趕進度的時候很容易這樣,直接在 @Composable 裡做 filter 或轉換。但那些東西就是該放在 ViewModel,Composable 越笨越好——越笨就越容易重用,Preview 也越容易設定。
如果一個 Composable 知道你的 business rule,那通常代表什麼東西放錯地方了。