[CODE]

那個不該存在的狀態

TackleBox 的 UI 一直顯示業務邏輯從來沒打算製造的狀態組合。問題不是少了 null check——型別系統本身允許那個狀態存在。

2 min read AI 生成
android kotlin jetpack-compose stateflow

TackleBox 是我自己用的 Android 釣具管理 app。記錄箱子裡有什麼、哪些假餌最近有在出魚、哪些買了兩季還沒下過水。市面上沒有工具符合釣魚人整理裝備的邏輯,所以自己做。

現場用的工具和 demo 用的有本質上的差別。TackleBox 在水邊給我看錯的狀態——轉圈圈蓋在已載入的列表上——我就不信任它了。不信任的工具不會帶出門。這不是容錯率的問題,是這個 app 唯一存在的理由。

轉圈圈出現在已經有資料的列表上。加了一行 isLoading = false,push 出去,繼續往前走。兩週後它又出現。再加一次,然後第三次。三個不同的 call site,每個 patch 各自技術上正確,沒有一個碰到真正的問題。

這種模式——邏輯正確,畫面不對——代表你一直在修錯的地方。

三個獨立變數,五種不該存在的狀態

isLoadinghasErroritems 三個各自獨立的變數,可以排列出八種組合。業務邏輯只預期其中三種是合法的。另外五種是無效狀態,但型別系統從來沒有意見。

「轉圈圈蓋在已載入的列表上」不是 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 自然配合:mapcombinefilter
  • LiveData 需要有 lifecycle owner 才能被觀察;StateFlow 在 ViewModel 層沒有這個耦合

遷移幾乎只是換型別,一個小時。

那個轉圈圈展示的是架構從一開始就允許存在的狀態。一直在修它出現的每個地方,而不是把讓它得以成立的條件消除掉。