MVI到底是不是凑数的?通过案例与MVVM进行比较
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.
MVI到底是不是凑数的?通过案例与MVVM进行比较 – Android开发中文站
最近看到不少介绍MVI
架构,即Model-View-Intent
的文章,有人留言说Google炒冷饭或者为了凑KPI“发明”了MVI
这么一个词。和后端的朋友描述了一下,他们听了第一印象也是和MVVM
好像区别不大。但是凭印象Google应该还没有到需要这样来凑数。
去看了一下官网,发现完全没有提到MVI
这个词。。但是推荐的架构图确实是更新了,用来演示MVI
也确实很搭。
想了想,决定总结一下自己的发现,和掘友们一起讨论学习。
看过一些分析MVI
的文章,里面实现的方法各种各样,细节也不尽相同。甚至对于Model
边界的划分也会不一样。
下面先分享一下在特定场景下我的MVVM
和MVI
实现(不重要的细节会省略)。
先预设一个场景,我们的界面(View/Fragment
)里有一个锅。主要任务就是完成一道菜的烹饪:
几个需要注意的点:
- 初始状态:开火
- 加入材料时:都是异步获取材料,再加入锅中
- 结束状态:出锅
本文主要是比较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<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
是否显示loading
由Repository
决定(是否有正在进行的数据读取)。- 对于观察的
LiveData
要做出何种操作,UI
层的逻辑代码往往无法避免。
很久以前也听说过用状态机(state machine)管理UI
界面,但是思路还是限制在使用多个LiveData
,使用时进行合并。虽然状态更清晰了,但是对于代码的可维护性并没有明显的帮助,甚至ViewModel
里还多了些合并LiveData
以及状态管理的代码。代码貌似还更复杂了。后来发现了Redux
式的思路,才有了下面这个版本的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<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的测试离。
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)
。添加这么一层的确可以帮助我们解耦部分逻辑。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK