Android开发拾遗:MVVM与MVI

创建于:发布于:文集:Android开发拾遗

如果想要写一个可运行的应用,将所有的代码都放在同一个文件里并不会影响其编译运行,但在实践中,当一个软件的功能越来越复杂,代码量不断增多,为了项目的可维护性,一般需要遵循一些模式将代码拆分开。像微软的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层之间的桥梁,避免视图和模型之间的直接交互。

MVVM

以一个购物应用为例,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的编写:

MVI

在实际的代码中,通常会封装一个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的优化)?

EOF
Githubmastodonrss-box
Copyright © 2020-2024 Elliot