前言
MVI(Model-View-Intent)架构是近年来 Android 开发中逐渐流行的一种架构模式。它旨在解决 MVVM 在逻辑复杂时需要维护多个 LiveData(可变与不可变混合)的问题。MVI 通过 ViewState 对应用状态进行集中管理,页面只需订阅一个 LiveData 即可获取所有状态信息。
通过集中管理 ViewState,MVI 架构对外只暴露一个 LiveData,有效解决了 MVVM 模式下 LiveData 膨胀的问题,使得数据流向更加清晰单一。
然而,将所有页面状态都通过一个 LiveData 来管理也带来了一个显著的性能问题,即页面不支持局部刷新。当状态中的某个非关键属性发生变化时,整个 LiveData 都会触发更新,导致 View 层重新渲染所有内容。虽然对于 RecyclerView 可以通过 DiffUtil 来解决部分列表项的更新,但并非所有页面都是基于 RecyclerView 构建的,且引入 DiffUtil 会增加一定的开发成本。因此,直接使用基础 MVI 架构可能会带来不必要的性能损耗,这也是许多开发者犹豫是否采用 MVI 架构的原因之一。
本文主要介绍如何通过监听 LiveData 的属性变化,来实现 MVI 架构下的局部刷新,从而在保持架构优势的同时优化性能。
Mavericks 框架介绍
Mavericks 是 Airbnb 开源的一个 MVI 框架。该框架基于 Android Jetpack 与 Kotlin Coroutines,主要目标是使页面开发更高效、更容易、更有趣。目前,Mavericks 已经在 Airbnb 的数百个页面上投入使用。
下面我们来查看一下 Mavericks 的基本使用方式:
// 1. 包含页面所有状态的 data class
// 必须继承 MavericksState 接口
data class CounterState(val count: Int = 0) : MavericksState
// 2. 负责处理业务逻辑的 ViewModel
// 继承 MavericksViewModel,易于单元测试
class CounterViewModel(initialState: CounterState) : MavericksViewModel<CounterState>(initialState) {
// 通过 setState 更新页面状态
fun incrementCount() = setState { copy(count = count + 1) }
}
// 3. View 层,必须实现 MavericksView 接口
class CounterFragment : Fragment(R.layout.counter_fragment), MavericksView {
private val viewModel: CounterViewModel by fragmentViewModel()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
counterText.setOnClickListener {
viewModel.incrementCount()
}
}
// 4. 页面刷新回调,每当状态刷新时会回调这里
override fun invalidate() = withState(viewModel) { state ->
counterText.text = "Count: ${state.count}"
}
}
如上所示,Mavericks 的使用流程主要包括几个核心模块:
- Model 层:包括页面所有状态的
data class,其中的状态全都是不可变的,并且通常有默认值。 - ViewModel 层:负责处理业务逻辑,在其中通过
setState来更新页面状态。 - View 层:必须实现
MavericksView接口,每当状态刷新时都会回调invalidate函数,在这里统一渲染 UI。
可以看出,Mavericks 中 View 层与 Model 层的交互并没有强制包装成 Action,而是直接暴露的方法。这种设计减少了样板代码,具体是否使用 Action 层主要取决于个人开发习惯。
支持局部刷新
上面介绍了 Mavericks 的简单使用,下面我们来看下 Mavericks 是如何实现局部刷新的。
data class UserState(
val score: Int = 0,
val previousHighScore: Int = 150,
val livesLeft: Int = 99,
) : MavericksState {
// 计算属性,不需要存储在 State 中,但可被监听
val pointsUntilHighScore = (previousHighScore - score).coerceAtLeast(0)
val isHighScore = score >= previousHighScore
}
class GameFragment : Fragment(R.layout.game_fragment), MavericksView {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// 直接监听 State 的属性,并且支持设置监听模式
viewModel.onEach(UserState::pointsUntilHighScore, deliveryMode = uniqueOnly()) {
// 仅当 pointsUntilHighScore 变化时触发
updatePointsDisplay(it)
}
// 监听另一个属性
viewModel.onEach(UserState::score) {
updateScoreDisplay(it)
}
}
}
- 如上所示,Mavericks 可以只监听
State的其中一个属性来实现局部刷新,只有当这个属性发生变化时才触发回调,避免了全量刷新。 onEach也可以设置监听模式,主要是为了防止数据倒灌。例如Toast这些只需要弹一次,页面重建时不应该恢复的状态,就适合使用uniqueOnly的监听模式。
Mavericks 实现属性监听的原理也很简单,其核心源码逻辑如下:
fun <VM : MavericksViewModel<S>, S : MavericksState, A> VM._internal1(
owner: LifecycleOwner?,
prop1: KProperty1<S, A>,
deliveryMode: DeliveryMode = RedeliverOnStart,
action: suspend (A) -> Unit
) = stateFlow
// 通过对象取出属性的值
.map { MavericksTuple1(prop1.get(it)) }
// 值发生变化了才会触发回调,利用 distinctUntilChanged 去重
.distinctUntilChanged()
.resolveSubscription(owner, deliveryMode.appendPropertiesToId(prop1)) { (a) ->
action(a)
}
- 主要是通过
map将State转化为它的属性值。 - 通过
distinctUntilChanged方法开启防抖,相同的值不会回调,只有值修改了才会回调。 - 需要注意的是因为使用了
KProperty1反射机制,因此State的承载数据类必须避免混淆,否则编译后的属性名可能改变导致无法正确获取值。
LiveData 实现属性监听
上面介绍了 Mavericks 是怎么实现局部刷新的,但直接使用它主要有两个问题:
- 接入起来略微有点麻烦,例如
Fragment必须实现MavericksView,有一定接入成本。 - Mavericks 的局部刷新是通过
Flow实现的,但相信大多数人现有的项目还是用的LiveData,有一定学习成本和迁移成本。
下面我们就来看下如何在不引入额外框架的情况下,使用原生 LiveData 实现属性监听。
// 监听一个属性
fun <T, A> LiveData<T>.observeState(
lifecycleOwner: LifecycleOwner,
prop1: KProperty1<T, A>,
action: (A) -> Unit
) {
this.map {
StateTuple1(prop1.get(it))
}.distinctUntilChanged().observe(lifecycleOwner) { (a) ->
action.invoke(a)
}
}
// 监听两个属性
fun <T, A, B> LiveData<T>.observeState(
lifecycleOwner: LifecycleOwner,
prop1: KProperty1<T, A>,
prop2: KProperty1<T, B>,
action: (A, B) -> Unit
) {
this.map {
StateTuple2(prop1.get(it), prop2.get(it))
}.distinctUntilChanged().observe(lifecycleOwner) { (a, b) ->
action.invoke(a, b)
}
}
// 内部辅助类
internal data class StateTuple1<A>(val a: A)
internal data class StateTuple2<A, B>(val a: A, val b: B)
// 更新 State 的扩展方法
fun <T> MutableLiveData<T>.setState(reducer: .() -> ) {
.value = .value?.reducer()
}
- 如上所示,主要是添加一个扩展方法,也是通过
distinctUntilChanged来实现防抖。 - 如果需要监听多个属性,例如两个属性有其中一个变化了就触发刷新,也支持传入两个属性。
- 需要注意的是
LiveData默认是不防抖的,这样改造后就是防抖的了,所以传入相同的值是不会回调的。 - 同时需要注意下承载
State的数据类需要防混淆。
注意事项:ProGuard 配置
由于上述实现依赖于 Kotlin 的 KProperty1 反射机制来获取属性值,如果在发布版本中开启了代码混淆(R8/ProGuard),可能会导致属性名称被修改,从而无法正确获取值或抛出异常。因此,必须在混淆规则中保留相关的属性引用。
建议在 proguard-rules.pro 中添加以下规则:
# 保留 State 类及其属性
-keepclassmembers class com.yourpackage.model.** {
*; # 或者指定具体的属性
}
# 保留 KProperty1 相关反射调用
-keepattributes Signature
-dontwarn kotlin.reflect.KProperty1
确保你的 State 类不会被完全移除或属性名被混淆,以保证运行时反射能够正常工作。
简单使用
上面介绍了 LiveData 如何实现属性监听,下面看下简单的使用场景。
// 页面状态,需要避免混淆
data class MainViewState(
val fetchStatus: FetchStatus = FetchStatus.NotFetched,
val newsList: List<NewsItem> = emptyList()
)
// ViewModel
class MainViewModel : ViewModel() {
private val _viewStates: MutableLiveData<MainViewState> = MutableLiveData(MainViewState())
// 只需要暴露一个 LiveData,包括页面所有状态
val viewStates = _viewStates.asLiveData()
private fun fetchNews() {
// 更新页面状态
_viewStates.setState {
copy(fetchStatus = FetchStatus.Fetching)
}
viewModelScope.launch {
when (val result = repository.getMockApiResponse()) {
is PageState.Success -> {
_viewStates.setState {
copy(fetchStatus = FetchStatus.Fetched, newsList = result.data)
}
}
}
}
}
}
// View 层
class MainActivity : AppCompatActivity() {
private fun initViewModel() {
viewModel.viewStates.run {
// 监听 newsList,列表数据变化时只刷新 RecyclerView
observeState(this@MainActivity, MainViewState::newsList) {
newsRvAdapter.submitList(it)
}
// 监听网络状态,状态变化时只显示 Loading 或错误提示
observeState(this@MainActivity, MainViewState::fetchStatus) {
showLoading(it == FetchStatus.Fetching)
showError(it == FetchStatus.Error)
}
}
}
}
如上所示,其实使用起来也很简单方便:
ViewModel只需对外暴露一个ViewState,避免了定义多个可变不可变LiveData的问题。View层支持监听LiveData的一个属性或多个属性,支持局部刷新,提升了渲染效率。
总结
本文主要介绍了 MVI 架构下如何实现局部刷新,并重点介绍了 Mavericks 的基本使用与原理,并在其基础上使用 LiveData 实现了属性监听与局部刷新。
通过以上方式,解决了 MVI 架构的性能问题,实现了 MVI 架构的更佳实践。即使你的 ViewModel 中定义了多个可变与不可变的 LiveData,就算你不使用 MVI 架构,支持监听 LiveData 属性相信也可以帮助你精简一定的代码,提升应用的响应速度。
在实际项目中,建议根据项目的复杂度选择合适的方案。如果项目已经大量使用 Flow,可以直接参考 Mavericks 的实现;如果是传统的 LiveData 项目,则可以使用本文提供的扩展方法进行改造。无论哪种方式,核心目标都是减少不必要的 UI 重绘,提升用户体验。

