6

MVI到底是不是凑数的?通过案例与MVVM进行比较

 2 years ago
source link: http://www.androidchina.net/12518.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

MVI到底是不是凑数的?通过案例与MVVM进行比较 – Android开发中文站

最新消息:欢迎访问Android开发中文站!商务联系微信:loading_in
你的位置:Android开发中文站 > 热点资讯 > MVI到底是不是凑数的?通过案例与MVVM进行比较

最近看到不少介绍MVI架构,即Model-View-Intent的文章,有人留言说Google炒冷饭或者为了凑KPI“发明”了MVI这么一个词。和后端的朋友描述了一下,他们听了第一印象也是和MVVM好像区别不大。但是凭印象Google应该还没有到需要这样来凑数。

a104163856034cf193b6b5527c5fcea4~tplv-k3u1fbpfcp-zoom-in-crop-mark:1304:0:0:0.awebp?

去看了一下官网,发现完全没有提到MVI这个词。。但是推荐的架构图确实是更新了,用来演示MVI也确实很搭。

74b8ac56c41e4af2a21a0893b72b5bd1~tplv-k3u1fbpfcp-zoom-in-crop-mark:1304:0:0:0.awebp?
(官网图)

想了想,决定总结一下自己的发现,和掘友们一起讨论学习。

看过一些分析MVI的文章,里面实现的方法各种各样,细节也不尽相同。甚至对于Model边界的划分也会不一样。

下面先分享一下在特定场景下我的MVVMMVI实现(不重要的细节会省略)。

先预设一个场景,我们的界面(View/Fragment)里有一个锅。主要任务就是完成一道菜的烹饪:

几个需要注意的点:

  • 初始状态:开火
  • 加入材料时:都是异步获取材料,再加入锅中
  • 结束状态:出锅

本文主要是比较MVVMMVI,这里只分享这两种实现。

经典MVVM

为了加强对比,这里的实现比较接近Android Architecture Components刚发布时官网的的代码架构和片段:

28c10cdd1eeb4bdeba498b45f65c1ee8~tplv-k3u1fbpfcp-zoom-in-crop-mark:1304:0:0:0.awebp?
(当时的官网图)
// 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实现。

下图是我对这个思路的理解:

  • 单一信息源
  • 单向/环形数据流
1d5fcf9e715542d6bebf1e49ce13413a~tplv-k3u1fbpfcp-zoom-in-crop-mark:1304:0:0:0.awebp?

定义几个下面代码会用到的名称(不用细究命名,只要自己和团队觉得有意义叫什么都行):

  • 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的测试离。
  • 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差不多的原因可能正是如此。 其实也不足为奇,不少设计模式两两之间也很相似,但并不妨碍大家给他们安上不同的名字。只要我们把握住核心概念,合理运用,叫什么名字也不重要。正如官方的建议

arch_recommedation.jpg

就算叫MVI只是为了唬人,让人一听到就知道你运用了Redux/State machine的思路,而不是“经典”的安卓版MVVM,好像也是个不错的理由。

ab078e288c3b4297a8998dd322c30c37~tplv-k3u1fbpfcp-zoom-in-crop-mark:1304:0:0:0.awebp?

从官网架构图的变化产生的联想:

7db282526a5e489184df60cb6340f7b7~tplv-k3u1fbpfcp-zoom-in-crop-mark:1304:0:0:0.awebp?

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)。添加这么一层的确可以帮助我们解耦部分逻辑。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK