Android开发拾遗:MVVM与MVI
如果想要写一个可运行的应用,将所有的代码都放在同一个文件里并不会影响其编译运行,但在实践中,当一个软件的功能越来越复杂,代码量不断增多,为了项目的可维护性,一般需要遵循一些模式将代码拆分开。像微软的ASP.NET这类Web应用框架,就为开发者预设了一套模板,使用被称为「MVC」的架构模式,要求用户将代码分散到不同的目录,各自继承一些特定的类,致力于将UI表现层与业务逻辑解耦。
架构模式(architectural pattern)是软件架构中在给定环境下,針對常遇到的问题的、通用且可重用的解决方案。
近期在浏览Android开发相关内容时常看到「MVI」架构模式,但与ASP.NET不同,Android并没有一个很强的约束要求开发者必须以某种模式写代码,网上找到的一些对于MVI的介绍也没有把它讲得很清楚。所以这次就结合实践中的一些问题,讲讲我对MVI的理解。
为了说明MVI,需要回顾一下过去的Android开发范式。在Android使用XML
构建视图的时代,受推崇的架构模式是MVVM,这种模式在很多流行的GUI框架——如Vue.js——中被广泛使用。
MVVM
MVVM是model-view-viewmodel的缩写,ViewModel层作为View层和Model层之间的桥梁,避免视图和模型之间的直接交互。
以一个购物应用为例,ViewModel层可以继承官方库中的ViewModel类:
class CartViewModel : ViewModel() {
private val _itemQuantity = MutableLiveData(0)
val itemQuantity: LiveData<Int> = _itemQuantity
private val _itemPrice = MutableLiveData(10.0)
val itemPrice: LiveData<Double> = _itemPrice
val totalPrice: LiveData<Double> = MediatorLiveData<Double>().apply {
addSource(_itemQuantity) { quantity ->
value = quantity * (_itemPrice.value ?: 0.0)
}
}
fun addToCart() {
_itemQuantity.value = (_itemQuantity.value ?: 0) + 1
}
fun checkout() {
// ...
}
}
在XML布局中绑定ViewModel的数据:
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable
name="viewModel"
type="com.example.MainViewModel" />
</data>
<LinearLayout
android:id="@+id/main"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">
<EditText
android:inputType="number"
android:text="@{String.valueOf(viewModel.itemQuantity)}" />
<TextView
android:text="@{String.valueOf(viewModel.totalPrice)}" />
<Button
android:onClick="@{() -> viewModel.addToCart()}" />
</LinearLayout>
</layout>
ViewModel与XML View之间建立了数据的双向绑定,ViewModel中的LiveData变更会直接体现在UI上,不用手写数据变更监听的代码,实际应用中,ViewModel层可能会从Model层获取商品数据,不同商品有不同价格,ViewModel处理价格的计算逻辑,View只负责最终结果的呈现。
这样就实现了关注点分离,利于代码的维护,例如可以在不改变ViewModel的情况下使用Android新的UI库——Jetpack Compose,只需要在Compose中使用:
/**
* 需要安装几个依赖
* androidx.lifecycle:lifecycle-viewmodel-compose-android
* androidx.compose.runtime:runtime-livedata
*/
val totalPrice by viewModel.totalPrice.observeAsState()
...
Button(onClick = { viewModel.addToCart() })
就可以了。
那这么做有没有什么缺陷呢?MVI和这种模式的区别在哪?
MVI
虽然一般来说在MVVM中,数据绑定是双向的,但为了数据安全,通常只向View暴露一个只读的LiveData,避免意外的数据修改:
private val _stateA = MutableLiveData(0)
val stateA = _stateA
private val _stateB = MutableLiveData(0)
val stateB = _stateB
// 使用StateFlow也是一样
private val _stateA = MutableStateFlow(0)
val stateA = _stateA.asStateFlow()
private val _stateB = MutableStateFlow(0)
val stateB = _stateB.asStateFlow()
// 再暴露一些封装的方法用于更新state
fun changeXX() {
}
这些state可能会散落在UI的各处,给每个state都重复一遍私有和公开的声明也让代码有点繁瑣。如果要做单元测试,测试代码也会不简洁。MVI模式的核心就在于:单向数据流、单一不可变的状态对象及事件驱动的状态管理。
MVI是model-view-intent的编写:
在实际的代码中,通常会封装一个State数据类:
data class ShopingState(
val isLoading: Boolean = false,
val goods: Goods = emptyList(),
val unpaid: Double = 0.0,
val error: String? = null,
)
仍然可以使用ViewModel类,将业务逻辑放在ViewModel中,但这次只有一个state对象:
// 可能需要依赖注入封装了Data Layer的repository
class MyViewModel(private val repository: GoodsRepository) : ViewModel() {
private val _state = MutableStateFlow(ShopingState())
val state = _state.asStateFlow()
}
但这次ViewModel不直接暴露可以更新state的方法,而是使用一个自定义的Intent:
// 使用sealed便于安全地模式区配
sealed object ShopingIntent {
data object LoadGoods : ShopingIntent
data object Checkout : ShopingIntent
data class AddToCart(val id: String) : ShopingIntent
}
class MyViewModel(private val repository: GoodsRepository) : ViewModel() {
fun onIntent(intent: ShopingIntent) {
when (intent) {
is ShopingIntent.LoadGoods -> {
//
}
is ShopingIntent.Checkout -> {
}
}
}
}
将整个视图和ViewModel分开来:
val state by viewModel.state.collectAsState()
ShopingScreen(
state = state,
onIntent = viewModel::onIntent
)
@Composable
fun ShopingScreen(..) {
// UI层只是向外抛出一个Intent,不再关心ViewModel如何处理数据
Button(onClick = { onIntent(ShopingIntent.LoadGoods) })
}
在实际应用中,Intent不一定必须由ViewModel处理,例如我用的某个第三方SDK要求在Activity上调用某个方法:
MainScree(
state = state,
onIntent = { intent ->
when (intent) {
is MyIntent.Foo -> {
}
is MyIntent.Bar -> {
}
else -> viewModel.onIntent(intent)
}
}
)
所有的状态数据都集中在了一处,数据只能单向流入UI层,用户交互产生Intent,对Intent的处理决定是否需要更新状态。整个UI可以脱离ViewModel的业务逻辑代码,单独做测试、预览。
相比之下,MVI不像MVC或MVVM那样定义清晰,Android官方也没有强制开发者必须遵守这个模式。可以将其看作在MVVM架构上的助力View和ViewModel解耦的范式,解耦合通常是好的,但要注意没有银弹,单一状态对象也可能带来一些不简洁的代码,如每次状态变更都需要copy:
_state.update { it.copy(isLoading = true) }
还有由于Kotlin不是像Haskell一样天生不可变的纯函数式语言,頻繁的copy也可能会影响性能(得看JIT的优化)?