J 神出品!让 Compose 从此摆脱 ViewModel
这两年在写 Compose 应用的时候,在 Compose 中实践了 MVVM 和 MVI 两个架构,发现 Compose 配合 MVI 写起来非常丝滑,甚至更进一步可以用 Compose 替代 ViewModel 写业务逻辑,甚至带来额外的优势,写个文章分享,我就来个抛砖引玉。
MVI 架构简介应用架构老生常谈了,但是还是先回顾一下现在比较流行,大家也比较熟悉的 MVVM。在 MVVM 的指导思想之下你会写出这样的代码:
class CounterViewModel: ViewModel() {private val _count = MutableLiveData<Int>()val count: LiveData<Int> get() = _count
}而在 MVI 的指导思想下你会写出这样的代码:
class CounterViewModel : ViewModel() {private val _count = MutableStateFlow(0)private val _input = MutableStateFlow("")val state = combime(_count,_input) { count, input ->CounterState(count = it.toString(),input = input,)}
}可以看到一个比较明显的不同:
MVVM 中整个页面的 State 来源较为分散,往往会暴露多个 LiveData/Flow 给 UI 层,UI 层也会订阅多个 LifeData/Flow。
MVI 中整个页面的 State 由一个或多个 Flow 组合而成,暴露给 UI 层的只有一个 LiveData/Flow,UI 层只需要订阅这一个 LiveData/Flow 即可。
MVI 这样做的好处就是:当页面开始复杂之后,你仍然可以很清晰的掌握整个页面的状态,特别是当 ViewModel 中多个 LiveData/Flow 之间会有依赖的时候。
至于为什么 MVVM 会给 UI 暴露这么多,简单的朔源一下:
MVVM 由微软架构师 Ken Cooper 和 Ted Peters 开发,通过利用 WPF(微软.NET 图形系统)和 Silverlight(WPF 的互联网应用衍生品)的特性来简化用户界面的事件驱动程式设计。微软的 WPF 和 Silverlight 架构师之一 John Gossman 于 2005 年在他的博客上发表了 MVVM。
而在 WPF 中,标准的 UI 数据绑定是这样的:
<StackPanel><TextBlock Text="{Binding Counter}"/><TextBox Text="{Binding Input, Mode=TwoWay}"/><Button Content="Increment" Command="{Binding IncrementCommand}"/></StackPanel>而 ViewModel 是这样定义的:
public class CounterViewModel : INotifyPropertyChanged{private int _counter;private string _input = "";
}如果用 CommunityToolkit.Mvvm 还能更简单些:
[ObservableObject]public partial class CounterViewModel{[ObservableProperty]private int _counter;[ObservableProperty]private string _input = "";
}因为 UI 和代码是两种语言,而且需要直接在 UI 中绑定不同的数据,甚至存在双向绑定,所以在 MVVM 中会暴露非常多的属性给 UI。
Android 的 DataBinding 也是从这里参考来的(但其实参考的不好)。
Compose 中实践在 Compose 中实践 MVI 也非常简单,比如:
@Composablefun Counter(viewModel: CounterViewModel = viewModel()) {val state by viewModel.state.collectAsState(initial = CounterState(0, ""))Counter(state = state,onIncrement = viewModel::increment,onInput = viewModel::input)}
@Composablefun Counter(state: CounterState,onIncrement: () -> Unit = {},onInput: (String) -> Unit = {},) {Column {Text(text = state.count)TextField(value = state.input,onValueChange = {onInput(it)})Button(onClick = {onIncrement()}) {Text(text = "Increment")}}}如果是 MVVM 的话,上面会写成这样:
@Composablefun Counter(viewModel: CounterViewModel = viewModel()) {val input by viewModel.input.observeAsState(initial = "")val count by viewModel.count.observeAsState(initial = 0)Counter(count = count,input = input,onIncrement = viewModel::increment,onInput = viewModel::input,)}
@Composablefun Counter(input: String,count: Int,onIncrement: () -> Unit = {},onInput: (String) -> Unit = {},) {Column {Text(text = count.toString())TextField(value = input,onValueChange = {onInput(it)})Button(onClick = {onIncrement()}) {Text(text = "Increment")}}}相比之下,在使用 MVI 时,整个 Compose 页面都是由一个状态驱动的,即使页面复杂度提高也仍然是一个状态,而 MVVM 就会有很多状态,这会提高 Compose 代码的复杂度,难以维护,想象一下你有很多行 val xxx by viewModel.xxx.observeAsState 。
emmm 好像实践这块没什么说的。
Compose 写业务逻辑更进一步,可以用 Compose 替代 ViewModel 写业务逻辑,来规避一些 ViewModel 的局限,还是上面的例子,当页面开始变得复杂,ViewModel 中状态开始变多的时候,输出 UI State 的代码可能会像这样:
class CounterViewModel : ViewModel() {//...val state = combime(_count,_input,_list,_data,_xxx,//...) { count, input, list, data, xxx /.../ ->}//...}当你组合的 Flow 越来越多的时候, combime 函数就会越来越长,看起来就很麻烦很累,更不要提你还要在 .collectAsState 的时候给个初始值了,我想这直接挡掉了大部分人实践 MVI 的想法。
那么有没有什么办法不用很麻烦很累就可以实践 MVI 呢?有请本文主角 Molecule
Molecule(https://github.com/cashapp/molecule)是由 jw 大神编写的使用 Compose 写业务逻辑的一个库(或者一个思路)。
一定有人会有疑问:Compose 不是 UI 框架吗?怎么还能写业务逻辑了?难道设计模式扔了直接在 UI 里面写业务逻辑?
Compose 和 Compose UI 首先需要区分两个概念,Compose 和 Compose UI。
Compose UI 就是我们非常熟悉的,用来画 UI 的那些。而抛开 Compose UI,仅保留 Compose Runtime 和 Compose Compiler,这就是不带任何 UI 的 Compose。举个例子:
@Composablefun CounterPresenter(): CounterState {var count by remember { mutableStateOf(0) }//...return CounterState(count)}这就是不带任何 UI 的 Compose,这里暂且称为 Compose Presenter。
对 Compose 稍有了解的应该都知道,当 count 被改变的时候,就会触发一次 recomposition, CounterPresenter 就会返回一个新的 CounterState ,而这一点特性恰巧和 Flow 非常相似,如果我们加以利用,上面的 ViewModel 就可以写成这样:
@Composablefun CounterPresenter(action: Flow<CounterAction>,): CounterState {var count by remember { mutableStateOf(0) }var input by remember { mutableStateOf("") }LaunchedEffect(action) {action.collect { action ->when (action) {is CounterAction.Increment -> count++is CounterAction.Input -> input = action.value}}}return CounterState(count = count.toString(),input = input,)}在 Compose UI 中就可以这样使用:
@Composablefun Counter() {val channel = remember { Channel<CounterAction>() }val flow = remember(channel) { channel.consumeAsFlow() }val state = CounterPresenter(action = flow)Counter(state = state,onIncrement = {channel.trySend(CounterAction.Increment)},onInput = {channel.trySend(CounterAction.Input(it))})}是不是看着比 combime 要舒服多了?如果需要组合的状态变多,写起来也完全没有问题,不会像 combime 那样令代码很快膨胀。
还有,我们经常会遇到这样的情况:有一些业务逻辑会在不同地方反复使用,或者当一个页面非常复杂的时候,此时一般可以抽象出 UseCase,或者抽象出基类 ViewModel。而如果使用 Compose 编写业务逻辑,就会发现,不仅 UI 是可组合的,业务逻辑也是可以组合的:
@Composablefun CounterPresenter(action: Flow<CounterAction>,): CounterState {//...
}@Composablefun OtherPresenter(action: Flow<OtherAction>,): OtherState {//..return OtherState(//...)}当一个页面非常复杂的时候,拆分 Compose Presenter 成为一个个小的 Compose Presenter,这样可维护性是大大高于一个非常大的 ViewModel 的。
简单说的话,在 MVI 架构下,Compose 替代 ViewModel 写业务逻辑有这几个优势:
不会产生 combime 那样很容易导致代码膨胀的问题
业务逻辑也是可组合的,意味着你可以给页面上的一个地方单独写一个 Compose Presenter,最后再在顶层组合成为这个页面的 State,这样不仅有助于理清业务逻辑,方便修改,还能够很简单的编写单元测试,大大降低维护成本,也会提高编写效率
由于不依赖 ViewModel,可以跨平台运行或测试,不再局限于 Android 平台。
Molecule 的作用上面写的似乎没有用到 Molecule,因为这些 Compose Presenter 和 Compose UI 都执行在一个 Composition 上,而 Molecule 的作用,就是将两者分开,分别执行在不同的 Composition 上。比如:
class CounterActivity : CompomentActivity() {private val scope = CoroutineScope(Main)
override fun onCreate(savedInstanceState: Bundle?) {//...val flow = //...val models = scope.launchMolecule(clock = RecompositionClock.ContextClock) {CounterPresenter(flow)}
}
override fun onDestroy() {super.onDestroy()scope.cancel()}}此时 Compose Presenter 和 Compose UI 执行在不同的 Composition 上。分开执行的好处除了在 Compose Presenter 的执行不会影响到 UI 之外,还有一个用处就是,Compose Presenter 可以给 XML View 使用:
class CounterActivity : CompomentActivity() {override fun onCreate(savedInstanceState: Bundle?) {//...
}}因为 scope.launchMolecule 返回的是一个 StateFlow<T> ,这是非常标准的 kotlinx coroutines 里面的组件,所以即使没有 Compose UI 也可以使用。
不过我更喜欢使用纯 Compose UI 来编写应用,现在让我再回去写 XML View 已经回不去了。这样依赖 Activity 还需要手动管理 CoroutineScope 的方式仍然还是有些繁琐,有没有再简单一点的?
接入 PreComposePreCompose 给 Compose 提供了跨平台的 Navigation 和 Lifecycle/ViewModel 支持,目前支持 Android/iOS/JVM/Web/macOS 平台,并且在最近的一次更新中还添加了 Molecule 的支持,用法非常的简单:
@Composablefun Counter() {val (state, channel) = rememberPresenter { CounterPresenter(it) }Counter(state = state,onIncrement = {channel.trySend(CounterAction.Increment)},onInput = {channel.trySend(CounterAction.Input(it))})}完整的例子:
这下编写业务逻辑只需要关心业务逻辑本身,再也不需要关心其余琐碎的事情,同时还能享受到 Compose Presenter 带来的各种优势,非常的解放心智。
总结 MVI 在前端已经实践了很长时间了,各种框架层出不穷,最经典的 redux 都很久了。和 React 一样同为声明式 UI 的 Compose,在编写方式上都有非常相似的地方,所以在 Compose 上实践 MVI 是再自然不过的事情。只不过这里另辟蹊径,利用 Compose 的特性,使用 Compose 自身替代 ViewModel,达到了一种更简单的 MVI 实现方式,在这里我就抛砖引玉,希望还有更加解放心智的做法。
评论