J神出品! Molecule - 让 Compose 从此摆脱 ViewModel
作者:Tlaster
https://juejin.cn/post/7141269750277439496
这两年在写 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
private val _input = MutableLiveData<String>()
val input: LiveData<String> get() = _input
fun increment() {
_count.value = _count.value?.plus(1)
}
fun input(value: String) {
_input.value = value
}
}
而在 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,
)
}
fun increment() {
_count.value++
}
fun input(value: String) {
_input.value = value
}
}
可以看到一个比较明显的不同:
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 = "";
public int Counter
{
get => _counter;
set
{
_counter = value;
OnPropertyChanged();
}
}
public string Input
{
get => _input;
set
{
_input = value;
OnPropertyChanged();
}
}
public ICommand IncrementCommand { get; } = new RelayCommand(() => Counter++);
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
如果用 CommunityToolkit.Mvvm 还能更简单些:
[ObservableObject]
public partial class CounterViewModel
{
[ObservableProperty]
private int _counter;
[ObservableProperty]
private string _input = "";
[RelayCommand]
private void Increment()
{
Counter++;
}
}
因为 UI 和代码是两种语言,而且需要直接在 UI 中绑定不同的数据,甚至存在双向绑定,所以在 MVVM 中会暴露非常多的属性给 UI。
Android 的 DataBinding 也是从这里抄来的(但其实抄的不好)。
Compose 中实践
在 Compose 中实践 MVI 也非常简单,比如:
@Composable
fun Counter(
viewModel: CounterViewModel = viewModel()
) {
val state by viewModel.state.collectAsState(initial = CounterState(0, ""))
Counter(
state = state,
onIncrement = viewModel::increment,
onInput = viewModel::input
)
}
@Composable
fun 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 的话,上面会写成这样:
@Composable
fun 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,
)
}
@Composable
fun 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。举个例子:
@Composable
fun CounterPresenter(): CounterState {
var count by remember { mutableStateOf(0) }
//...
return CounterState(count)
}
这就是不带任何 UI 的 Compose,这里暂且称为 Compose Presenter。
对 Compose 稍有了解的应该都知道,当 count
被改变的时候,就会触发一次 recomposition,CounterPresenter
就会返回一个新的 CounterState
,而这一点特性恰巧和 Flow
非常相似,如果我们加以利用,上面的 ViewModel 就可以写成这样:
@Composable
fun 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 中就可以这样使用:
@Composable
fun 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 是可组合的,业务逻辑也是可以组合的:
@Composable
fun CounterPresenter(
action: Flow<CounterAction>,
): CounterState {
//...
val channel = remember { Channel<CounterAction>() }
val flow = remember(channel) { channel.consumeAsFlow() }
val otherState = OtherPresenter(flow)
LaunchedEffect(action) {
action.collect { action ->
when (action) {
//..
is CounterAction.OtherAction -> channel.trySend(action.action)
}
}
}
return CounterState(
//...
otherState = otherState,
)
}
@Composable
fun 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)
}
setContent {
val state by model.collectAsState()
//...
}
}
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?) {
//...
setContentView(R.layout.counter_activity)
val flow = //...
val models = scope.launchMolecule(clock = RecompositionClock.ContextClock) {
CounterPresenter(flow)
}
scope.launch(start = UNDISPATCHED) {
models.collect { model ->
counterText.text = model.count
}
}
}
}
因为scope.launchMolecule
返回的是一个 StateFlow<T>
,这是非常标准的 kotlinx coroutines 里面的组件,所以即使没有 Compose UI 也可以使用。
不过我更喜欢使用纯 Compose UI 来编写应用,现在让我再回去写 XML View 已经回不去了。这样依赖 Activity 还需要手动管理 CoroutineScope
的方式仍然还是有些繁琐,有没有再简单一点的?
接入 PreCompose
PreCompose 给 Compose 提供了跨平台的 Navigation 和 Lifecycle/ViewModel 支持,目前支持 Android/iOS/JVM/Web/macOS 平台,并且在最近的一次更新中还添加了 Molecule 的支持,用法非常的简单:
@Composable
fun Counter() {
val (state, channel) = rememberPresenter { CounterPresenter(it) }
Counter(
state = state,
onIncrement = {
channel.trySend(CounterAction.Increment)
},
onInput = {
channel.trySend(CounterAction.Input(it))
}
)
}
完整的例子:
https://github.com/Tlaster/PreCompose/blob/2bffc3486a05e942079fdd1b4861c5b159b4682a/sample/molecule/src/commonMain/kotlin/moe/tlaster/precompose/molecule/sample/App.kt
这下编写业务逻辑只需要关心业务逻辑本身,再也不需要关心其余琐碎的事情,同时还能享受到 Compose Presenter 带来的各种优势,非常的解放心智。
总结
MVI 在前端已经实践了很长时间了,各种框架层出不穷,最经典的 redux 都很久了。和 React 一样同为声明式 UI 的 Compose,在编写方式上都有非常相似的地方,所以在 Compose 上实践 MVI 是再自然不过的事情。只不过这里另辟蹊径,利用 Compose 的特性,使用 Compose 自身替代 ViewModel,达到了一种更简单的 MVI 实现方式,在这里我就抛砖引玉,希望还有更加解放心智的做法。
Molecule:https://github.com/cashapp/molecule
PreCompose: https://github.com/Tlaster/PreCompose
-- END --
推荐阅读