重学架构-MVI

6,427 阅读8分钟

前言

刚把 MVC , MVP , MVVM 深入理解了一波,最近又开始各种推送 MVI 的文章,碰巧谷歌又发布了最新的 Android应用架构指南 ,大致看了下发现正式最近正火的 MVI !这可真是巧巧的妈妈生巧巧,于是上手实践了一下,只能说有规范就是好,真香

指南帮助我解决了以前开发过程中遇到的很多疑惑点,再也不怕和小伙伴争论了,直接指南怼脸,舒坦

架构概述

最新架构基于 界面和数据分离数据模型驱动界面 两个原则,将应用分为三层

其中最主要的是 界面层数据层

界面层

职责

  1. 将应用数据转换为界面数据,显示在UI上
  2. 提供状态容器(ViewModel)
  3. 处理界面事件

重点

业务逻辑和界面逻辑

  • 业务逻辑决定着如何处理状态变化。业务逻辑通常位于网域层或数据层中,但绝不能位于界面层中。
  • 界面行为逻辑(界面逻辑) 决定着如何在屏幕上显示状态变化。例如点击按钮跳转,消息提示等

界面逻辑(尤其是在涉及 Context 等界面类型时)应位于界面中,而非ViewModel中. 同一应用的业务逻辑在不同移动平台或设备类型上保持不变,但界面行为逻辑在实现方面可能有所不同。界面层页定义了这些类型的逻辑

界面事件处理

事件划分

  • 界面事件:应在界面层处理的操作
  • 用户事件:用户在与应用互动时生成的事件

ViewModel 通常负责处理特定用户事件的业务逻辑

事件决策树

如果操作是在界面树中比较靠下一层生成的,例如在 RecyclerView 项或自定义 View 中,ViewModel 应仍是处理用户事件的操作。

界面状态和状态容器

界面状态

  • 界面状态需要包含界面显示的全部信息
  • 界面状态定义不可变

只有数据源或数据所有者才应负责更新其公开的数据

状态容器

负责提供界面状态,并且包含执行相应任务所必需的逻辑。状态容器有多种大小,具体取决于所管理的界面元素的作用域

ViewModel 类型是推荐的实现,用于管理屏幕级界面状态,具有数据层访问权限。此外,它会在配置发生变化后自动继续存在。ViewModel 类用于定义要为应用中的事件应用的逻辑,并提供更新后的状态作为结果

数据转化

数据源接收的数据与应用其余部分所需的数据不符时,创建新模型

单向数据流管理状态

状态向下流动、事件向上流动的这种模式称为单向数据流 (UDF)

基本流程

  • ViewModel 会存储并公开界面要使用的状态。界面状态是经过 ViewModel 转换的应用数据。

  • 界面会向 ViewModel 发送用户事件通知。

  • ViewModel 会处理用户操作并更新状态。

  • 更新后的状态将反馈给界面以进行呈现。

  • 系统会对导致状态更改的所有事件重复上述操作。

单个数据流和多个数据流

单个数据流

最大优势是便捷性和数据一致性:状态的使用方随时都能立即获取最新信息

多个数据流

  • 不相关的数据类型:呈现界面所需的某些状态可能是完全相互独立的。在此类情况下,将这些不同的状态捆绑在一起的代价可能会超过其优势,尤其是当其中某个状态的更新频率高于其他状态的更新频率时。
  • UiState diffing:UiState 对象中的字段越多,数据流就越有可能因为其中一个字段被更新而发出。由于视图没有 diffing 机制来了解连续发出的数据流是否相同,因此每次发出都会导致视图更新。这意味着,可能必须要对 LiveData 使用 Flow API 或 distinctUntilChanged() 等方法来缓解这个问题

数据层

作用

管理应用数据和业务逻辑

层次结构中的其他层绝不能直接访问数据源;数据层的入口点始终是存储库类

重点

Repository的职责

  • 向应用的其余部分公开数据。

  • 集中处理数据变化。

  • 解决多个数据源之间的冲突。

  • 对应用其余部分的数据源进行抽象化处理。

  • 包含业务逻辑。

处理数据层错误

界面层应负责处理在调用数据层时出现的异常

公开API

  • 一次性操作:在 Kotlin 中,数据层应公开挂起函数;对于 Java 编程语言,数据层应公开用于提供回调来通知操作结果的函数,或公开 RxJava Single、Maybe 或 Completable 类型。
  • 接收关于数据随时间变化的通知:在 Kotlin 中,数据层应公开数据流;对于 Java 编程语言,数据层应公开用于发出新数据的回调,或公开 RxJava Observable 或 Flowable 类型

这里只总结一部分自己觉得重要的,详情请查看应用开发指南

实践

架构图

样例分析

先来张效果图

很常规的一个首页,它包含以下功能

  • 一个支持上拉加载和上拉刷新的滚动列表

  • 缺省页(显示错误,网络异常,无数据)

  • 错误提示

基本流程

基本架构大致包含以下4个部分

定义UI State和Event

/**
 *  完整的界面状态
 */
data class HomePageViewState(
        val pageStatus: PageStatus = PageStatus.Empty,
        val refreshStatus: RefreshStatus = RefreshStatus.RefreshIdle,
        val loadMoreStatus: LoadStatus = LoadStatus.LoadMoreIdle,
        val data: List<Any> = emptyList()
) : UIState

/**
 *  其他事件,一次性
 */
sealed class HomePageViewEffect : UIEffect {
    data class ShowToast(val message: String) : HomePageViewEffect()
    object ShowLoadingDialog : HomePageViewEffect()
    object DismissLoadingDialog : HomePageViewEffect()
}

/**
 *  用户行为事件
 */
sealed class HomePageViewEvent : UIEvent {
    object LoadData : HomePageViewEvent()
    object LoadDataMore : HomePageViewEvent()
    object RefreshData : HomePageViewEvent()
}
  • 定义界面状态时,一定要用 data class,属性一定要val

data class 提供copy 方法方便更新状态,另外equal方法方便diff

val 确保状态不能修改

  • 状态属性一定需要有默认值

创建状态容器ViewModel

@HiltViewModel
class HomePageViewModel @Inject constructor (private val homeModel: HomeModel) : BaseViewModel<HomePageViewState, HomePageViewEvent, HomePageViewEffect>() {

 ...

    override fun providerInitialState(): HomePageViewState = HomePageViewState()

    override fun handleEvent(event: HomePageViewEvent) {
        when (event) {
            is HomePageViewEvent.LoadData -> loadData()
            is HomePageViewEvent.LoadDataMore -> loadDataMore()
            is HomePageViewEvent.RefreshData -> refreshData()
        }
    }

    init {
        loadData()
    }

    private fun loadData() {
          ...
    }
    ...
}

ViewModel职责很简单

  • 提供初始界面状态

  • 更新界面状态

  • 处理用户事件

更新UI State

    private fun loadData() {
        viewModelScope.launch {
            combine(homeModel.loadData(0)) { array ->
                val bannerData = array[0] as List<BannerData>
                val wxData = array[1] as List<WxData>
                val hotProjectData = array[2] as HotProjectData
                currentPage = 0
                setState {
                    copy(
                            pageStatus = PageStatus.Success,
                            //转化为VO
                            data = convertPoToVo(bannerData, wxData, hotProjectData),
                            loadMoreStatus = LoadStatus.LoadMoreSuccess(hotProjectData.curPage < hotProjectData.pageCount)
                    )
                }
            }.onStart {
                setState { copy(pageStatus = PageStatus.Loading) }
            }.catch {
                setEffect { HomePageViewEffect.ShowToast(it.errorMsg) }
                setState { copy(pageStatus = PageStatus.Error(it)) }
                LogUtils.e(it.errorMsg,it.errorCode)
            }.collect()
        }
    }
  • 从数据层获取的应用数据和界面需要展示的数据差异较大时,需要创建新模型

样例中首页是由一个RecyerView搭建,所以需要将数据状态为List

  • 通过copy方法可以不必每次创建新的界面状态
  • 数据层的出现错误需要界面层来处理

这里使用的flow,所以直接在catch更新界面状态,并给出友好提示

  • 提示,导航,loading弹窗这种单次事件处理,避免"数据倒灌"

如果事件流Flow开发,可使用SharedFlow 和 Channel

使用UI State

    //处理加载更多
        viewLifecycleOwner.lifecycleScope.launchWhenStarted {
            viewModel.state.collectState(HomePageViewState::loadMoreStatus) { state ->
                when (state) {
                    is LoadStatus.LoadMoreSuccess -> {
                        if (state.hasMore) {
                            refreshView.finishLoadMore()
                        } else {
                            refreshView.finishLoadMoreWithNoMoreData()
                        }
                    }
                    is LoadStatus.LoadMoreLoading -> {
                        if (!refreshView.isLoading) {
                            refreshView.autoLoadMoreAnimationOnly()
                        }
                    }
                    is LoadStatus.LoadMoreFail -> {
                        refreshView.finishLoadMore(false)
                    }
                }
            }
        }
        //处理下拉刷新
        viewLifecycleOwner.lifecycleScope.launchWhenStarted {
            viewModel.state.collectState(HomePageViewState::refreshStatus) { state ->
                when (state) {
                    is RefreshStatus.RefreshSuccess -> {
                        refreshView.finishRefresh()
                    }
                    is RefreshStatus.RefreshLoading -> {
                        if (!refreshView.isRefreshing) {
                            refreshView.autoRefreshAnimationOnly()
                        }
                    }
                    is RefreshStatus.RefreshFail -> {
                        refreshView.finishRefresh(false)
                    }
                }
            }
        }
        //处理缺省页
        viewLifecycleOwner.lifecycleScope.launchWhenStarted {
            viewModel.state.collectState(HomePageViewState::pageStatus) { state ->
                refreshView.closeHeaderOrFooter()
                refreshView.setEnableLoadMore(false)
                refreshView.setEnableRefresh(false)
                when (state) {
                    is PageStatus.Empty -> stateView.showEmpty()
                    is PageStatus.Success -> {
                        refreshView.setEnableLoadMore(true)
                        refreshView.setEnableRefresh(true)
                        stateView.showContent()
                    }
                    is PageStatus.Error -> stateView.showError()
                    is PageStatus.Loading -> stateView.showLoading()
                }
            }
        }
        //处理首页列表
        viewLifecycleOwner.lifecycleScope.launchWhenStarted {

            viewModel.state.collectState(HomePageViewState::data) {
                baseBinderAdapter.setDiffNewData(it.toMutableList())
            }
        }

        //处理其他例如提示,跳转等一次性事件
        viewLifecycleOwner.lifecycleScope.launchWhenStarted {
           
            viewModel.effect.collect {
                when (it) {
                    is HomePageViewEffect.ShowToast -> ToastUtils.showShort(it.message)
                    else -> {
                    }
                }
            }
        }
  • 每次状态更新都会导致界面更新,View没有Diff机制,所以我们需要使用LiveData 使用 Flow API 或 distinctUntilChanged() 等方法来缓解这个问题

这里的collectState是自定义的扩展方法

suspend fun <T, A> Flow.collectState(prop1: KProperty1<T, A>, action: (A) -> Unit) {
	this.map { StateTuple1(prop1.get(it))}//获取属性值
	.distinctUntilChanged() //属性值变化
	.collectLatest { (a) -> action.invoke(a)
    }
}

Recyclerview 可以通过DiffUtil优化

adapter.apply {
	addItemBinder(HomeBannerBinder())
	addItemBinder(HomeWxBinder())
	addItemBinder(HomeCategoryBinder(), HomeCategoryBinder.Differ())
	addItemBinder(HomeTitleBinder(), HomeTitleBinder.Differ())
	addItemBinder(HomeProjectBinder(), HomeProjectBinder.Differ())
}
  • 不要在一个 lifecycleScope 执行多个 collect ,只有第一个会生效,因为它是挂起的,后面都被阻塞了(当时这个卡了我很长时间,一度想要放弃)

到这里主要部分就介绍完了,坑还是很多的,需要上手试一试

源码地址

总结

优点

  • 状态,事件统一管理

  • 使用单向数据流,保证数据一致性,增强可测试性和可维护性

  • 不用定义两遍livedata来控制访问权限

缺点

  • 原生View由于没有Diff,任何操作导致的状态刷新都会导致整个界面刷新,需要借助工具去处理,很麻烦
  • 模板代码代码还是很多

参考

应用架构指南

用Kotlin Flow解决Android开发中的痛点问题

彩蛋

自己造轮子很麻烦,那有没有现成可以商用的轮子呢?

当然有,来自Airbnb的Mavericks,别人很多年前就开始使用了,一致维护到现在,这才是真开源呀

Mavericks 是一个 Android MVI 框架,它既易于学习,又足够强大,可以处理 Airbnb、Tonal 和其他大型应用程序中最复杂的流程。

当我们开始创建 Mavericks 时,我们的目标是让构建产品更容易、更快、更有趣。我们相信,要让 Mavericks 取得成功,对于 Android 开发新手来说,开发他们的第一个应用程序必须很容易学习,而且功能强大到足以支持 Airbnb 最复杂的屏幕。

Mavericks 用于 Airbnb 的数百个屏幕,包括 100% 的新屏幕。它也被无数其他应用程序采用,从小型示例应用程序到下载量超过 10 亿的应用程序。

Mavericks 建立在 Android Jetpack 和 Kotlin Coroutines 之上,因此可以将其视为对 Google 标准库集的补充而不是背离