Compose 下的 MVI 架构实践,用 Compose 写业务逻辑,取代 ViewModel

6,258

这两年在写 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 是由 jw 大神(没错就是那个 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))
        }
    )
}

完整的例子在这里
这下编写业务逻辑只需要关心业务逻辑本身,再也不需要关心其余琐碎的事情,同时还能享受到 Compose Presenter 带来的各种优势,非常的解放心智。

总结

MVI 在前端已经实践了很长时间了,各种框架层出不穷,最经典的 redux 都很久了。和 React 一样同为声明式 UI 的 Compose,在编写方式上都有非常相似的地方,所以在 Compose 上实践 MVI 是再自然不过的事情。只不过这里另辟蹊径,利用 Compose 的特性,使用 Compose 自身替代 ViewModel,达到了一种更简单的 MVI 实现方式,在这里我就抛砖引玉,希望还有更加解放心智的做法。