MVVM 升級版:MVI 架構來了_#架構

Jetpack Compose 成為主流後,把 Android 開發者往狀態驅動 UI 和單向數據流(UDF)的方向又推了一把。這下子,MVVM 和 MVI 哪個更好的爭論又火起來了。有人説 MVI 有 MVVM 沒有的“可預測性”,也有人覺得 MVVM 不用寫那麼多模板代碼,還能做到 MVI 能做的所有事。本文結合實際的好壞案例、歷史背景,來聊聊為什麼 MVVM 其實大多時候就夠用了。

一點歷史淵源

  • 最初的 MVI(2014 年):André Staltz 在他的“響應式 MVC”博客裏介紹了 MVI。他説這是一種響應式 UI 架構模式,通過單向數據流來管理應用裏的狀態和複雜度。
  • 最初的 MVVM(2005 年):John Gossman 在微軟為 WPF/Silverlight 搞出來的 MVVM。它強調多個可觀察對象,每個 UI 屬性都綁定到自己的可觀察數據源上。
  • 早期的 Android MVVM:模仿了上面的模式:每個 UI 字段(比如 isLoading、title、errorMessage)都用 ObservableField,後來又用 LiveData。
  • 現代 MVVM(Compose 時代):谷歌現在推薦的最佳實踐是:用一個不可變的 UI 狀態,通過 StateFlow 來管理,遵循 UDF 原則(“狀態向下,事件向上”)。

實踐對比

反面案例:“上帝式”的 Reducer

好多開發者把 MVI 和一個巨大的 onEvent() 或者 reducer 函數劃等號:

fun reducer(state: UiState, event: UiEvent): UiState {
    return when (event) {
        is UiEvent.Load -> state.copy(isLoading = true)
        is UiEvent.Login -> state.copy(userLoggedIn = true)
        is UiEvent.Navigate -> state.copy(navigationTarget = event.destination)
        is UiEvent.ShowDialog -> state.copy(isDialogOpen = true)
        // 還有 20 多個分支...
    }
}

想象一下這個函數就像一個超級大管家,要管的事情太多了。

  • 複雜度一上來,所謂的 “可預測性” 就沒了。就好比一個人要同時管幾十件事,很容易亂,你也不知道他下一步會怎麼處理。
  • Reducer 變成了 “上帝對象”,把導航、UI 和領域邏輯都混在一起了。本來導航是一個部分的事,UI 顯示是另一個部分的事,現在全塞一塊,就像把不同部門的工作都丟給一個人,又亂又難維護。
  • 每加一個新的屏幕功能,模板代碼就暴增。因為每次加功能都要往這個大函數里加分支,就像往一個已經很滿的抽屜裏硬塞東西,越來越亂。

這其實是對 MVI 的誤解,把它當成了一個巨大的 onEvent() 處理器。

正面案例:作用域化 Reducers
data class ItemListState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null,
)

sealed class ItemListEvent {
    object Load : ItemListEvent()
    data class Loaded(val items: List<Item>) : ItemListEvent()
    data class Failed(val error: String) : ItemListEvent()
}

fun itemListReducer(state: ItemListState, event: ItemListEvent): ItemListState {
    return when (event) {
        is ItemListEvent.Load -> state.copy(isLoading = true)
        is ItemListEvent.Loaded -> state.copy(isLoading = false, items = event.items)
        is ItemListEvent.Failed -> state.copy(isLoading = false, error = event.error)
    }
}
  • Reducer 簡潔,專注於特定功能。就像這個 Reducer 只負責處理 “物品列表” 相關的狀態,不管別的,這樣就很清晰。
  • 狀態轉換是可預測的。因為每個事件對應的狀態變化是明確的,比如 Load 事件來了,就把 isLoading 設為 true,很容易知道會發生什麼。
  • 副作用(比如網絡請求這些)在其他地方處理(中間件或者用例層)。這就好比,數據加載這種可能耗時的操作,不讓 Reducer 管,而是交給專門的 “助手”(中間件或用例層)去做,Reducer 只負責狀態的轉換,分工更明確。

不過要注意:這裏的做法其實不一定要用 MVI 才能實現。

反面案例:簡單頁面上過度使用 MVI
data class CounterState(val count: Int = 0)

sealed class CounterEvent {
    object Increment : CounterEvent()
    object Decrement : CounterEvent()
}

fun counterReducer(state: CounterState, event: CounterEvent): CounterState {
    return when (event) {
        CounterEvent.Increment -> state.copy(count = state.count + 1)
        CounterEvent.Decrement -> state.copy(count = state.count - 1)
    }
}

對於一個簡單的計數器屏幕,這就太小題大做了。用 MVVM 兩行代碼就能搞定:

class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count

    fun increment() { _count.value++ }
    fun decrement() { _count.value-- }
}
正面案例:使用 單一狀態 + Action 的 MVVM
data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val error: String? = null
)

class ItemsViewModel(
    private val useCase: UseCase
) : ViewModel() {
    private val _state = MutableStateFlow(UiState())
    val state: StateFlow<UiState> = _state

    fun loadItems() {
        _state.update { it.copy(isLoading = true, error = null) }
        viewModelScope.launch {
            try {
                val result = useCase.execute()
                _state.update {
                    it.copy(
                        isLoading = false,
                        items = result,
                        error = null
                    )
                }
            } catch (e: Exception) {
                _state.update {
                    it.copy(
                        isLoading = false,
                        error = e.message
                    )
                }
            }
        }
    }

    fun onItemClicked(item: Item) {
        // 更新選中的項目或者觸發導航事件
    }
}
  • 單一的不可變狀態。整個 UI 的狀態都在 UiState 裏,而且是不可變的,每次更新都是複製一份再改,這樣數據更安全,不容易亂。
  • 動作映射到 ViewModel 的函數(而不是用 Reducer)。用户的操作,比如加載數據,就調用 loadItems 函數,很直觀,不像 MVI 還要先搞個事件再給 Reducer 處理。
  • 沒有模板代碼,也不需要密封類事件。寫起來更簡潔,不用為了符合 MVI 的結構寫很多額外的類和代碼。

這其實就是 MVVM 裏的 UDF —— 和 MVI 提倡的原則一樣,但更簡單。

觀點討論

支持 MVI 的觀點
  • 結構化的 Reducer 和顯式的事件,帶來了可預測性。因為每個事件和狀態轉換都有明確的定義,就像走固定的流程,知道每一步會發生什麼。
  • 對於大型團隊和非常複雜、響應式的屏幕,表現更好。大團隊協作時,明確的結構能減少溝通成本,複雜屏幕的狀態多,結構化的管理更不容易出錯。
支持 MVVM 的觀點
  • 不用額外的模板代碼,也能實現 Single State + Action + UDF。寫起來更高效,不用為了 MVI 的結構寫很多重複的代碼。
  • 已經是主流了,谷歌的指導方針也朝着這個模式走。學起來有更多資料和支持,遇到問題也更容易解決。
  • 如果 ViewModel 作用域劃分得好(基於功能,不是 “上帝對象”),完全夠用。就像把 ViewModel 按功能分成不同的小模塊,每個模塊管自己的事,一樣能很好地管理複雜狀態。
我的最終想法
  • 簡單屏幕上用 MVI,純屬小題大做。就像做簡單的作業,用複雜的方法反而麻煩。
  • 複雜屏幕上,如果合理劃分作用域,MVI 能幫上忙,但用 MVVM + UDF 也能達到同樣的效果。就好比解決複雜問題,MVI 有它的工具,MVVM 也有自己的辦法,不一定非得用 MVI。
  • 可預測性不是 MVI 獨有的,清晰的狀態管理就能帶來可預測性,而 MVVM 絕對能做到這一點。只要把狀態管理好,MVVM 一樣能讓狀態變化清晰可預測。
  • 很多人誤解了 MVI,把它當成一個巨大的 onEvent () 函數,這並不比臃腫的 ViewModel 好到哪裏去。錯誤使用 MVI,結果和寫得不好的 MVVM 一樣亂。
  • 有了 Compose 和 StateFlow,MVVM 完全能解決 MVI 宣稱能解決的所有問題。兩者的區別往往只是語義和模板代碼的不同。所以對於大多數情況,MVVM 已經足夠好用了。