关于安卓的MVI架构

作者: 丘卡皮 | 来源:发表于2022-06-12 20:21 被阅读0次

    作者:晴天小庭
    转载地址:https://juejin.cn/post/7104565566568202276

    近些年安卓的架构发展的真是非常迅速,笔者入行不久,就已经从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样:

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

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

    相关文章

      网友评论

        本文标题:关于安卓的MVI架构

        本文链接:https://www.haomeiwen.com/subject/kwcnmrtx.html