前言
刚把 MVC , MVP , MVVM 深入理解了一波,最近又开始各种推送 MVI 的文章,碰巧谷歌又发布了最新的 Android应用架构指南 ,大致看了下发现正式最近正火的 MVI !这可真是巧巧的妈妈生巧巧,于是上手实践了一下,只能说有规范就是好,真香
指南帮助我解决了以前开发过程中遇到的很多疑惑点,再也不怕和小伙伴争论了,直接指南怼脸,舒坦
架构概述
最新架构基于 界面和数据分离 和 数据模型驱动界面 两个原则,将应用分为三层
其中最主要的是 界面层 和 数据层
界面层
职责
- 将应用数据转换为界面数据,显示在UI上
- 提供状态容器(ViewModel)
- 处理界面事件
重点
业务逻辑和界面逻辑
- 业务逻辑决定着如何处理状态变化。业务逻辑通常位于网域层或数据层中,但绝不能位于界面层中。
- 界面行为逻辑(界面逻辑) 决定着如何在屏幕上显示状态变化。例如点击按钮跳转,消息提示等
界面逻辑(尤其是在涉及 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,任何操作导致的状态刷新都会导致整个界面刷新,需要借助工具去处理,很麻烦
- 模板代码代码还是很多
参考
彩蛋
自己造轮子很麻烦,那有没有现成可以商用的轮子呢?
当然有,来自Airbnb的Mavericks,别人很多年前就开始使用了,一致维护到现在,这才是真开源呀
Mavericks 是一个 Android MVI 框架,它既易于学习,又足够强大,可以处理 Airbnb、Tonal 和其他大型应用程序中最复杂的流程。
当我们开始创建 Mavericks 时,我们的目标是让构建产品更容易、更快、更有趣。我们相信,要让 Mavericks 取得成功,对于 Android 开发新手来说,开发他们的第一个应用程序必须很容易学习,而且功能强大到足以支持 Airbnb 最复杂的屏幕。
Mavericks 用于 Airbnb 的数百个屏幕,包括 100% 的新屏幕。它也被无数其他应用程序采用,从小型示例应用程序到下载量超过 10 亿的应用程序。
Mavericks 建立在 Android Jetpack 和 Kotlin Coroutines 之上,因此可以将其视为对 Google 标准库集的补充而不是背离