TackleBox 是我自己用的 Android 釣具管理 app。記錄箱子裡有什麼、哪些假餌最近有在出魚、哪些買了兩季還沒下過水。市面上沒有工具符合釣魚人整理裝備的邏輯,所以自己做。
現場用的工具和 demo 用的有本質上的差別。TackleBox 在水邊給我看錯的狀態——轉圈圈蓋在已載入的列表上——我就不信任它了。不信任的工具不會帶出門。這不是容錯率的問題,是這個 app 唯一存在的理由。
轉圈圈出現在已經有資料的列表上。加了一行 isLoading = false,push 出去,繼續往前走。兩週後它又出現。再加一次,然後第三次。三個不同的 call site,每個 patch 各自技術上正確,沒有一個碰到真正的問題。
這種模式——邏輯正確,畫面不對——代表你一直在修錯的地方。
三個獨立變數,五種不該存在的狀態
isLoading、hasError、items 三個各自獨立的變數,可以排列出八種組合。業務邏輯只預期其中三種是合法的。另外五種是無效狀態,但型別系統從來沒有意見。
「轉圈圈蓋在已載入的列表上」不是 Compose 的渲染問題。Compose 忠實地渲染你傳進去的任何東西。問題是「loading 的同時顯示資料」在我自己的型別系統裡是個完全合法的狀態。每一個在 call site 貼的 patch,都是在蓋我自己挖的洞,不是堵死它。
把三個變數收進一個 data class:
data class TackleUiState(
val isLoading: Boolean = false,
val items: List<TackleItem> = emptyList(),
val error: String? = null
)
但 data class 本身不夠。關鍵是每次狀態轉換都換掉整個物件,而不是更新個別欄位。ViewModel 的 collect handler:
.collect { items -> _uiState.update { TackleUiState(items = items) } }
這個寫法建立全新的物件。新物件的 isLoading 預設是 false,所以「有資料且還在 loading」這個組合從結構上就不可能發生。對比 .copy(items = items)——這個寫法讓 isLoading 保持現有的值,只改了 items。
前者讓那個無效狀態無法被表達。後者只是讓它發生的機率低一點。「防守無效組合」和「讓那個組合無法存在」本質上是不同的解法。前者每多一個 call site 就要多一道防線。後者解一次,問題消失。
寫入只有一條路,由型別系統強制
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) } }
}
}
}
_uiState 設成 private 不是風格偏好,是功能性設計。ViewModel 對外有寫入口,它就不是唯一狀態來源——只是一個名字叫 ViewModel 的共享可變變數。
早期跳過這個限制,追了一段更新順序的問題:兩個並發更新交錯,產生出誰都沒有預期的狀態。把 _uiState 鎖住之後,那些問題全部消失。對外只暴露 read-only 的 StateFlow<TackleUiState>,所有的寫入都走 ViewModel 自己的 method。這個限制是型別系統在強制的,不是靠呼叫端記得遵守。
Composable 的工作只有渲染
@Composable
fun TackleScreen(viewModel: TackleViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()
when {
uiState.isLoading -> CircularProgressIndicator()
uiState.error != null -> ErrorMessage(uiState.error!!)
else -> TackleList(uiState.items)
}
}
故意這樣寫的。Composable 一旦開始做判斷,就同時在做兩件事。遲早狀態邏輯會悄悄滲進表示層,變得難測試、難追蹤。這個 function 只渲染,就這樣。(等 lifecycle 依賴接好之後,換成 collectAsStateWithLifecycle() 是一行,不是 refactor。)
不是每個操作都需要繞一圈
UiEvent sealed class 加 Channel 有它的場合:導航觸發、Snackbar、重新訂閱時不應該被 replay 的一次性 side effect。普通的刪除操作不需要那層:
fun deleteItem(id: String) {
viewModelScope.launch { repo.delete(id) }
}
在不需要抽象的地方加抽象,在當下看起來很有架構意識。六個月後沒有人記得它解決了什麼問題。
Room 的 Flow 讓架構值回票價
Room 的 DAO 回傳 Flow<List<Item>>,每次資料庫有寫入就自動 emit:
repo.observeItems()
.map { entities -> entities.map { it.toDomain() } }
.collect { items -> _uiState.update { it.copy(items = items, isLoading = false) } }
接 Room 之前,每次刪除都要手動觸發重載。「我有沒有記得刪完之後重抓資料」這個問題現在永久消失了。UI 直接跟著資料庫走。
前面那個月,StateFlow 架構是靠信念在付的成本:更嚴格的存取控制、整體替換的 overhead、多出來的型別層。第一次刪掉一筆資料,列表在沒有任何明確觸發的情況下自己更新了。那個瞬間架構才算真的站穩。在整個流程跑通之前,它是複雜度。跑通之後,它是撐住整件事的基礎設施。
LiveData 用了一個禮拜
換掉有具體原因:
- StateFlow 一定有初始值,第一次收集不會是
null - 純 Kotlin:ViewModel 不需要引入 Android framework,unit test 不需要 Robolectric
- coroutine operator 自然配合:
map、combine、filter - LiveData 需要有 lifecycle owner 才能被觀察;StateFlow 在 ViewModel 層沒有這個耦合
遷移幾乎只是換型別,一個小時。
那個轉圈圈展示的是架構從一開始就允許存在的狀態。一直在修它出現的每個地方,而不是把讓它得以成立的條件消除掉。