安卓MVI架构真的来了?动手试着封装吧(一)

关于安卓的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之类的。

test.png

  从这里开始一切都变得怪异起来了,你往一个表示状态的容器里面填充了一些事件,而且使用了列表,则说明事件需要被消费掉,否则越填充越多,更严重的是会产生数据倒灌的问题,当你切换到手机主页再切换回APP的时候,UI会尝试从ViewModel的状态流中取数据,然后将本应该消费掉的Toast事件又取出来消费一遍,于是出现了下面的场景:

当一个用户输错了密码之后,APP提示“密码错误,请重试”,他切换到其他APP又切回来的时候,发现APP又继续提示“密码错误,请重试”,即使他没有做任何操作 、

test2.png

  一切的问题根源都是来源于,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去删除列表中被消费的事件对象,这就问题解决了,谷歌爸爸赛高!对此,笔者再次重申:

test3.png

  如果你是一个对代码坏味道敏感的人,可能已经隐隐约约闻到了一股屎味,没错请相信你的直觉。说好的响应式呢,结果还是要手动去维护事件的消费,万一我忘了呢,完蛋又出现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样:

  1. 要订阅的属性
  2. 获取到新值后的逻辑

  谷歌的开发者文档对于入门MVI架构是非常合适的,但是谷歌只提供了非常基础的解决方案,并没有对这些逻辑做进一步的封装(这并不怪谷歌毕竟一个架构有非常多种实现方案,而且在一篇入门文章中阐述进阶的封装并不合适),因此我们需要封装来帮助我们解决掉这些难看的模板代码。

test4.png

感谢你看到这里,本篇到此就已经结束了,那么在下一篇文章中,我将会用实际上的代码去解决上述讲到的2个问题,如果你有什么想说的请在评论区发表你的意见,给我点赞关注会加快下一篇文章出来的速度哟

猜你喜欢

转载自juejin.im/post/7104565566568202276
今日推荐