动态

详情 返回 返回

MVI到底是不是湊數的?通過案例與MVVM進行比較 - 动态 详情

前言

最近看到不少介紹MVI架構,即Model-View-Intent的文章,有人留言説Google炒冷飯或者為了湊KPI“發明”了MVI這麼一個詞。和後端的朋友描述了一下,他們聽了第一印象也是和MVVM好像區別不大。但是憑印象Google應該還沒有到需要這樣來湊數。

去看了一下官網,發現完全沒有提到MVI這個詞。。但是推薦的架構圖確實是更新了,用來演示MVI也確實很搭。

image.png

(官網圖)

想了想,決定總結一下自己的發現,和掘友們一起討論學習。

案例分享

看過一些分析MVI的文章,裏面實現的方法各種各樣,細節也不盡相同。甚至對於Model邊界的劃分也會不一樣。

下面先分享一下在特定場景下我的MVVMMVI實現(不重要的細節會省略)。

場景

先預設一個場景,我們的界面(View/Fragment)裏有一個鍋。主要任務就是完成一道菜的烹飪:

flowchart LR
開火 --> 熱油 --> 加菜 --> 加調料 --> 出鍋 

幾個需要注意的點:

  • 初始狀態:開火
  • 加入材料時:都是異步獲取材料,再加入鍋中
  • 結束狀態:出鍋

本文主要是比較MVVMMVI,這裏只分享這兩種實現。

經典MVVM

為了加強對比,這裏的實現比較接近Android Architecture Components剛發佈時官網的的代碼架構和片段:

image.png

(當時的官網圖)

// PotFragment.kt
class PotFragment {
    ...
    // 觀察是否點火
    viewModel.fireStatus.observe(
        viewLifecycleOwner, 
        Observer {
            updateUi()
            if (fireOn) addOil() 
        }
    )
    // 觀察油温
    viewModel.oilTemp.observe(
        viewLifecycleOwner, 
        Observer {
            updateUi()
            if (oilHot) addIngredients() 
        }
    )
    // 觀察菜熟沒熟
    viewModel.ingredientsStatus.observe(
        viewLifecycleOwner, 
        Observer {
            updateUi()
            if (ingredientsCooked) {
                // 加調料
                addPowder(SALT)
                addPowder(SOY_SAUCE)
            }
        }
    )
    // 觀察油鹽是否加完
    viewModel.allPowderAdded.observe(
        viewLifecycleOwner, 
        Observer {
            // 出鍋!
        }
    )
    
    viewModel.loading.observe(
        viewLifecycleOwner, 
        Observer {
            if (loading) {
                // 顛勺
            } else {
                // 放下鍋
            }
        }
    )
    
    // 一切準備就緒,點火
    turnOnFire()
    ...
}

// PotViewModel.kt
class PotViewModel(val repo: CookingRepository) {
    
    private val _fireStatus = MutableLiveData<FireStatus>()
    val fireStatus: LiveData<FireStatus> = _fireStatus
    
    private val _oilTemp = MutableLiveData<OilTemp>()
    val oilTemp: LiveData<OilTemp> = _oilTemp
    
    private val _ingredientsStatus = MutableLiveData<IngredientsStatus>()
    val ingredientsStatus: LiveData<IngredientsStatus> = _ingredientsStatus
    
    // 所有調料加好了才更新。這裏Event內部會有flag提示這個LiveData的更新是否被使用過
    //(當年我們還真用這種方式實現過單次消費的LiveData)。
    private val _allPowderAdded = MutableLiveData<Event<Boolean>>()
    val allPowderAdded: LiveData<Event<Boolean>> = _allPowderAdded
    
    // 假設已經實現邏輯從repo獲取是否有還在進行的數據獲取
    private val _loading = MutableLiveData<Boolean>()
    val loading: LiveData<Boolean> = _loading
    
    fun turnOfFire() {}
    
    // 假設下面都是異步獲取材料,這裏簡化一下代碼
    fun addOil() {
        repo.fetchOil()
    }
    
    fun addIngredients() {
        repo.fetchIngredients()
    }
    
    fun addPowder(val powderType: PowderType) {
        repo.fetchPowder(powderType)
        // 更新_allPowderAdded的邏輯會在這裏
    }
    ...
}

特點:

  • 使用多個LiveData觀察不同的數據,並以此來更新UI。每個LiveData都是一個State,每個View有自己的State
  • UI是否顯示loadingRepository決定(是否有正在進行的數據讀取)。
  • 對於觀察的LiveData要做出何種操作,UI層的邏輯代碼往往無法避免。

很久以前也聽説過用狀態機(state machine)管理UI界面,但是思路還是限制在使用多個LiveData,使用時進行合併。雖然狀態更清晰了,但是對於代碼的可維護性並沒有明顯的幫助,甚至ViewModel裏還多了些合併LiveData以及狀態管理的代碼。代碼貌似還更復雜了。後來發現了Redux式的思路,才有了下面這個版本的MVI實現。

MVI

下圖是我對這個思路的理解:

  • 單一信息源
  • 單向/環形數據流

image.png

定義幾個下面代碼會用到的名稱(不用細究命名,只要自己和團隊覺得有意義叫什麼都行):

  • State:不管數據從哪裏來,經過什麼處理,都會歸於現在的狀態
  • Event:上圖中的意圖產生或代表的事件,也可以理解為Intent或者Action,最終產生Event讓我們更新State
  • Reducer:驅動狀態變化的核心。這個例子裏可以想象成廚師的手,用來改變鍋的狀態。
  • Side effects:用户無感知,就當它是“額外效果”(或者“副作用”)。對於數據的請求或者記錄上傳用户操作的代碼都歸於此類。

下面開始展示~偽~代碼:

// PotState.kt
sealed class PotState {
    object Initial: CookingStatus()
    object FireOn: CookingStatus()
    class Cooking(val data: List<EdibleStuff>): CookingStatus()
    object Finished: CookingStatus()
}

// CookEvent.kt
sealed class CookEvent {
    object TurnOnFire(): CookEvent()

    object RequestOil(): CookEvent()
    object AddOil(): CookEvent()
    
    class RequestIngredient(val ingredientType: IngredientType): CookEvent()
    class AddIngredient(val ingredient: Ingredient): CookEvent()
    
    class RequestPowder(val powderType: PowderType): CookEvent()
    class AddPowder(val powder: Powder): CookEvent()
    
    object ServeFood()
}

// models.kt
interface EdibleStuff

data class Oil(...) implements EdibleStuff
data class Ingredient(...) implements EdibleStuff
data class Powder(...) implements EdibleStuff

// PotReducer.kt
class PotReducer {
    
    fun reduce(state: PotState, event: CookEvent) = 
        when (state) {
            Initial -> reduceInitial(event)
            FireOn -> reduceFireOn(event)
            is Cooking -> reduceCooking(event)
            Finished -> reduceFinished(state, event)
        }
        
    // 每個狀態只接受某些特定的Event,其它的會忽略(無法影響當前狀態)
    private fun reduceInitial(state: PotState, event: CookEvent) = 
        when (event) {
            TurnOnFire -> flowOf(FireOn) // 生成一個Cooking狀態並加好油
            else -> // handle exception
        }
    
    private fun reduceFireOn(state: PotState, event: CookEvent) = 
        when (event) {
            AddOil -> flowOf(Cooking(mutableListOf<Cooking>(Oil)) // 生成一個Cooking狀態並加好油
            else -> // handle exception
        }
        
    private fun reduceCooking(state: PotState, event: CookEvent) = 
        when (event) {
            AddIngredient -> flowOf(state.apply { data.add(event.ingredient) }) // 加菜
            AddPowder -> flowOf(state.apply { data.add(event.powder) }) // 加調料
            else -> // handle exception
        }
            
    private fun reduceFinished(state: PotState, event: CookEvent) = 
        when (event) {
            ServeFood -> flowOf(Finished) // 出鍋
            else -> // handle exception
        }
}

// PotViewModel.kt
class PotViewModel(val potReducer: PotReducer, val repo: CookingRepository) {
    ...
    var potState: PotState = Initial
    
    // 生成下一狀態,更新Flow
    fun processEvent(event: CookEvent) =
        potReducer.reduce(potState, event)
            .updateState()
            .handleSideEffects(event)
            .launchIn(viewModelScope)
            
    // 對於不直接影響UI的事件,當做side effects處理
    private fun handleSideEffects(event: CookEvent) = 
        onEach { event ->
            when (event) {
                is RequestOil -> fetchOil()
                is RequestIngredient -> fetchIngredient(...)
                is RequestPowder -> fetchPowder(...)
            }
        }
        
    // 收到Repository傳來的食料,啓動新Event:添加入鍋
    private fun fetchOil() = repo.fetchOil().onEach { processEvent(AddOil) }.collect()
    // fetchIngredient(...) 與 fetchPowder(...) 也類似
    ...
}

// PotFragment.kt
class PotFragment {
    ...
    @Composable
    fun Pot(viewModel: PotViewModel) {
        
        val state by viewModel.potState.collectAsState()

        Column {
         //Render toolbar
         Toolbar(...)
         //Render screen content
         when (state) {
            FireOn -> // render UI
            is Cooking -> // render UI
            Finished -> // render UI:出鍋!
          }
        }
    }
    
    // 準備就緒,挑個合適的時機開火
    viewModel.processEvent(TurnOnFire)
    ...
}

特點:

  • Fragment/Activity只負責渲染
  • 用户意圖會產生Event,並被ViewModel中的Reducer處理
  • 特定的狀態下,只會接收能被處理的Event

分析

經典MVVM

優點:

  • 相比MVC或者MVP,相信大家都熟悉。

缺點:

  • 每個View有自己的State。很多View混合在一起時,代碼和我們的思路都容易變混亂。審核代碼也需要對全局有很好的理解。
  • 需要觀察的數據多了之後,LiveData管理可以變得很複雜。
  • 可以看到,Fragment中無論何時都在觀察並接收所有LiveData的更新。仔細想想,其實這當中是包含了一些邏輯的。比如説,開火之後我們不希望接收加調料的操作。這些邏輯不容易單獨拿出來寫測試,通常要被包含在Fragment的測試離。

MVI

優點:

  • Statesingle source of truth,單一信息源,不用擔心各個View的狀態到處都是,甚至相互衝突。
  • 伴隨着預設的狀態值,可以接受的意圖Intent或者操作Action也可以預設。不在計劃裏的意圖/操作不會對UI界面產生影響,也不會有額外效果。審核代碼只需要瞭解新增的意圖對某一兩個受影響的狀態就足夠,不用把整個界面的內容都覆盤一遍。單元測試也是類似。也算是符合關注點分離(Separation of Concerns)。

缺點:

  • 隨着View變得複雜,可以有的狀態以及能接受的意圖也會迅速膨脹。
  • 文件數量變多(這個和從MVC到MVP的感覺有點像)。
  • 新手學習、理解起來不容易。

比較

兩種架構都有優缺點。

因為大家都熟悉MVVM,新團隊的接受度肯定會好。

有些缺點也可以想辦法改進。例如MVI的狀態膨脹可以通過劃分為幾個小的分狀態來緩解。

對於複雜的場景,我個人更傾向於採用MVI全局狀態管理的思路。主要還是覺得傳統MVVM每次添加新的LiveData時(當然現在常常用Flow),需要仔細檢查其它所有的View或者LiveData,生怕漏掉什麼改動,不利於高效開發和維護。

總結

我認為傳統的MVVMMVI主要的區別還是在於全局狀態管理。而且這個全局狀態管理的思路用傳統MVVM架構也能實現,很多人覺得MVIMVVM差不多的原因可能正是如此。 其實也不足為奇,不少設計模式兩兩之間也很相似,但並不妨礙大家給他們安上不同的名字。只要我們把握住核心概念,合理運用,叫什麼名字也不重要。正如官方的建議:

image.png

就算叫MVI只是為了唬人,讓人一聽到就知道你運用了Redux/State machine的思路,而不是“經典”的安卓版MVVM,好像也是個不錯的理由。

題外話

從官網架構圖的變化產生的聯想:

image.png

ViewModel 化身 LifecycleObserver

最近看到不少文章分享他們對於讓ViewModellifecycle-aware的實驗。從官方文檔看,UI elementsState holders(在我看來就是Fragment/ActivityViewModel)也在被視作一個整體的UI Layer。不知道以後是不是會有這麼一個趨勢。

有時候,不經意間就會錯過一些有趣實用的想法。回想2017年的時候,聽到WeWork的員工分享他們自制的Declarative UI庫。當時覺得都不能預覽,應該不會好用到哪去吧。沒想到後來官方發佈了Compose,預覽功能都加入了Android Studio

選擇性使用的 Domain Layer

也許是隨着這幾年Clean Architecture的熱度上升,看到不少團隊開始加入領域層。官方推薦的架構圖(開頭提到)中也加入了Domain Layer (optional)。添加這麼一層的確可以幫助我們解耦部分邏輯。

user avatar u_15878077 头像 mannayang 头像 qiyuxuanangdelvdou 头像 djz1234 头像 kangkaidesuancaiyu 头像 mstech 头像 yangrd 头像 sishuiliunian_58f891c129ab1 头像 airenaodexianrenqiu 头像 5gz5hi3e 头像 xuexiangjys 头像 323duqpq 头像
点赞 17 用户, 点赞了这篇动态!
点赞

Add a new 评论

Some HTML is okay.