关于安卓的MVI架构
近些年安卓的架构发展的真是非常迅速,笔者入行不久,就已经从MVC,MVP一路干到MVVM,自以为对MVVM非常熟悉,略有心得的时候,谷歌稍稍的更新了开发者文档(应用架构指南 | Android 开发者 | Android Developers (google.cn))
虽然通篇没有涉及到MVI,但是许多业内的小伙伴,特别是前端开发表示,开发文档中提到的单向数据流,唯一数据源,不正是MVI区别于MVVM的最显著的特征吗?
作为一个常年上班摸鱼钻研的新油条,果断研究起来,于是在翻阅了谷歌的开发文档、掘金上大佬写的文章以及阅读了几个开源MVI架构项目之后,自己也动手折腾了一个小DEMO,表示真香,但是也发现了一些问题。
注意:如果你对MVI架构没有任何认识,请在掘金阅读相关MVI架构文章或者阅读谷歌开发者文档之后,再继续阅读下文
遇到的小问题
1. 状态?事件!
MVI架构中,特别是谷歌推崇的开发模式,是将整个页面的状态存放于单一的类中,而且这个类必须是Kotlin的data class,因为kotlin的这个特殊的类自带了copy功能,非常方便去更新部分的属性,于是我们就有了下面的一个类:
data class NewsUiState(
val isSignedIn: Boolean = false,
val isPremium: Boolean = false,
val newsItems: List<NewsItemUiState> = listOf(),
val userMessages: List<Message> = listOf()
)
data class NewsItemUiState(
val title: String,
val body: String,
val bookmarked: Boolean = false,
...
)
复制代码
Ok,我们有了一个Ui的状态,其实如果你懂电影或游戏中的帧的概念的话,这个UiState实际上就是页面的一帧或很多帧,这样解释或许不恰当,但是足够你理解这个概念,也就是说,ViewModel只需要向Ui提供当前的状态就好了,至于UI拿到这个数据之后如何去展示显示UI,就和ViewModel没关系了。
目前为止,一切都很美好,数据流是单向流向viewModel,响应式...
但是,如果你注意到NewsUiState里面有个属性userMessages,在文档中,这个属性被用来充当ViewModel需要向Ui发送的通知,例如Toast之类的。
从这里开始一切都变得怪异起来了,你往一个表示状态的容器里面填充了一些事件,而且使用了列表,则说明事件需要被消费掉,否则越填充越多,更严重的是会产生数据倒灌的问题,当你切换到手机主页再切换回APP的时候,UI会尝试从ViewModel的状态流中取数据,然后将本应该消费掉的Toast事件又取出来消费一遍,于是出现了下面的场景:
当一个用户输错了密码之后,APP提示“密码错误,请重试”,他切换到其他APP又切回来的时候,发现APP又继续提示“密码错误,请重试”,即使他没有做任何操作 、
一切的问题根源都是来源于,UiState表示的是一种状态而非一种事件容器,因此如果你把事件填充进去,Ui就会尝试反复取出他,执行特定的逻辑,于是Toast被反复调用了。
此刻大多数人的第一反应是:Ui去更新viewModel中UiState的值。但是别忘了,MVI可是单项数据流动的呀,UI可不能去直接修改viewModel中的值!
正当笔者大呼谷歌RNM退钱的时候,发现谷歌在文档中写了解决方案,如下:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { uiState ->
uiState.userMessages.firstOrNull()?.let { userMessage ->
// TODO: Show Snackbar with userMessage.
// Once the message is displayed and
// dismissed, notify the ViewModel.
viewModel.userMessageShown(userMessage.id)
}
...
}
}
}
复制代码
哇哦,谷歌爸爸真的好聪明呀,既然Ui不能直接修改viewModel的值,那viewModel就提供一个方法给Ui调用不就行了,每次UI消费了这些一次性事件,就去调用一次viewModel提供的方法,然后viewModel去删除列表中被消费的事件对象,这就问题解决了,谷歌爸爸赛高!对此,笔者再次重申:
如果你是一个对代码坏味道敏感的人,可能已经隐隐约约闻到了一股屎味,没错请相信你的直觉。说好的响应式呢,结果还是要手动去维护事件的消费,万一我忘了呢,完蛋又出现bug了。
2. 模板,模板!
抛开事实事件倒灌的问题不谈, 再回头看看谷歌推荐的写法:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
// Bind the visibility of the progressBar to the state
// of isFetchingArticles.
viewModel.uiState
.map { it.isFetchingArticles }
.distinctUntilChanged()
.collect { progressBar.isVisible = it }
}
}
复制代码
map操作符的作用是过滤uiState中的其他参数,distinctUntilChanged操作符是消抖,collect操作符是收集,非常的直观,非常的易懂。
但是,如果你对kotlin的Flow不太了解的话,你也许会写出下面的代码
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState
.map { it.isFetchingArticles }
.distinctUntilChanged()
.collect { progressBar.isVisible = it }
viewModel.uiState
.map { it.xxx }
.distinctUntilChanged()
.collect { doSomeThing() }
viewModel.uiState
.map { it.yyy }
.distinctUntilChanged()
.collect { doSomeThing( }
}
}
复制代码
看起来一切都没问题,继续收集其他属性,然后执行不同的操作,然而实际上等你真的把代码运行起来的时候,会发现除了第一个属性的收集是有相应的以外,其他的属性均收不到最新的值。
出现这个问题的原因是因为collect是suspend方法,他会阻塞下面的代码的执行,因此你需要给每一个collect都套一层launch方法,即开启多个协程,防止协程挂起导致下面的代码无法运行:
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
launch{
viewModel.uiState
.map { it.isFetchingArticles }
.distinctUntilChanged()
.collect { progressBar.isVisible = it }
}
launch{
viewModel.uiState
.map { it.xxx }
.distinctUntilChanged()
.collect { doSomeThing() }
}
launch{
viewModel.uiState
.map { it.xxx }
.distinctUntilChanged()
.collect { doSomeThing() }
}
}
}
复制代码
恭喜你问题解决了,但是产生了一大堆模板代码,最核心的逻辑其实只包括2样:
- 要订阅的属性
- 获取到新值后的逻辑
谷歌的开发者文档对于入门MVI架构是非常合适的,但是谷歌只提供了非常基础的解决方案,并没有对这些逻辑做进一步的封装(这并不怪谷歌毕竟一个架构有非常多种实现方案,而且在一篇入门文章中阐述进阶的封装并不合适),因此我们需要封装来帮助我们解决掉这些难看的模板代码。
感谢你看到这里,本篇到此就已经结束了,那么在下一篇文章中,我将会用实际上的代码去解决上述讲到的2个问题,如果你有什么想说的请在评论区发表你的意见,给我点赞关注会加快下一篇文章出来的速度哟