前言
最近看到不少介绍MVI
架构,即Model-View-Intent
的文章,有人留言说Google炒冷饭或者为了凑KPI“发明”了MVI
这么一个词。和后端的朋友描述了一下,他们听了第一印象也是和MVVM
好像区别不大。但是凭印象Google应该还没有到需要这样来凑数。
去看了一下官网,发现完全没有提到MVI
这个词。。但是推荐的架构图确实是更新了,用来演示MVI
也确实很搭。
想了想,决定总结一下自己的发现。
案例分享
看过一些分析MVI
的文章,里面实现的方法各种各样,细节也不尽相同。甚至对于Model
边界的划分也会不一样。
下面先分享一下在特定场景下我的MVVM
和MVI
实现(不重要的细节会省略)。
场景
先预设一个场景,我们的界面(View/Fragment
)里有一个锅。主要任务就是完成一道菜的烹饪:
flowchart LR
开火 --> 热油 --> 加菜 --> 加调料 --> 出锅
几个需要注意的点:
- 初始状态:开火
- 加入材料时:都是异步获取材料,再加入锅中
- 结束状态:出锅
本文主要是比较MVVM
和MVI
,这里只分享这两种实现。
经典MVVM
为了加强对比,这里的实现比较接近Android Architecture Components
刚发布时官网的的代码架构和片段:
// 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()
val fireStatus: LiveData = _fireStatus
private val _oilTemp = MutableLiveData()
val oilTemp: LiveData = _oilTemp
private val _ingredientsStatus = MutableLiveData()
val ingredientsStatus: LiveData = _ingredientsStatus
// 所有调料加好了才更新。这里Event内部会有flag提示这个LiveData的更新是否被使用过
//(当年我们还真用这种方式实现过单次消费的LiveData)。
private val _allPowderAdded = MutableLiveData>()
val allPowderAdded: LiveData> = _allPowderAdded
// 假设已经实现逻辑从repo获取是否有还在进行的数据获取
private val _loading = MutableLiveData()
val loading: LiveData = _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
是否显示loading
由Repository
决定(是否有正在进行的数据读取)。 - 对于观察的
LiveData
要做出何种操作,UI
层的逻辑代码往往无法避免。
很久以前也听说过用状态机(state machine)管理UI
界面,但是思路还是限制在使用多个LiveData
,使用时进行合并。虽然状态更清晰了,但是对于代码的可维护性并没有明显的帮助,甚至ViewModel
里还多了些合并LiveData
以及状态管理的代码。代码貌似还更复杂了。后来发现了Redux
式的思路,才有了下面这个版本的MVI
实现。
MVI
下图是我对这个思路的理解:
- 单一信息源
- 单向/环形数据流
定义几个下面代码会用到的名称(不用细究命名,只要自己和团队觉得有意义叫什么都行):
- 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): 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(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
优点:
-
State
是single source of truth
,单一信息源,不用担心各个View
的状态到处都是,甚至相互冲突。 - 伴随着预设的状态值,可以接受的意图
Intent
或者操作Action
也可以预设。不在计划里的意图/操作不会对UI界面产生影响,也不会有额外效果。审核代码只需要了解新增的意图对某一两个受影响的状态就足够,不用把整个界面的内容都复盘一遍。单元测试也是类似。也算是符合关注点分离(Separation of Concerns)。
缺点:
- 随着View变得复杂,可以有的状态以及能接受的意图也会迅速膨胀。
- 文件数量变多(这个和从MVC到MVP的感觉有点像)。
- 新手学习、理解起来不容易。
比较
两种架构都有优缺点。
因为大家都熟悉MVVM
,新团队的接受度肯定会好。
有些缺点也可以想办法改进。例如MVI
的状态膨胀可以通过划分为几个小的分状态来缓解。
对于复杂的场景,我个人更倾向于采用MVI
的全局状态管理的思路。主要还是觉得传统MVVM
每次添加新的LiveData
时(当然现在常常用Flow
),需要仔细检查其它所有的View
或者LiveData
,生怕漏掉什么改动,不利于高效开发和维护。
总结
我认为传统的MVVM
和MVI
主要的区别还是在于全局状态管理。而且这个全局状态管理的思路用传统MVVM
架构也能实现,很多人觉得MVI
和MVVM
差不多的原因可能正是如此。 其实也不足为奇,不少设计模式两两之间也很相似,但并不妨碍大家给他们安上不同的名字。只要我们把握住核心概念,合理运用,叫什么名字也不重要。正如官方的建议:
就算叫MVI
只是为了唬人,让人一听到就知道你运用了Redux/State machine
的思路,而不是“经典”的安卓版MVVM
,好像也是个不错的理由。
题外话
从官网架构图的变化产生的联想:
ViewModel 化身 LifecycleObserver
最近看到不少文章分享他们对于让ViewModel
也lifecycle-aware
的实验。从官方文档看,UI elements
和State holders
(在我看来就是Fragment/Activity
和ViewModel
)也在被视作一个整体的UI Layer
。不知道以后是不是会有这么一个趋势。
有时候,不经意间就会错过一些有趣实用的想法。回想2017年的时候,听到WeWork
的员工分享他们自制的Declarative UI
库。当时觉得都不能预览,应该不会好用到哪去吧。没想到后来官方发布了Compose
,预览功能都加入了Android Studio
。
选择性使用的 Domain Layer
也许是随着这几年Clean Architecture
的热度上升,看到不少团队开始加入领域层。官方推荐的架构图(开头提到)中也加入了Domain Layer (optional)
。添加这么一层的确可以帮助我们解耦部分逻辑。