从源头了解 MVI 的核心思想
作者:未来猫咪花
https://juejin.cn/post/7120517885130686472
前言
MVI 老早比较冷门,最近频繁看到这 3 个字母。这篇算是我使用 MVI 几年的一个总结,希望对大家有帮助。
MVI,即 Model-View-Intent,最早由 andrestaltz 于 2015 年在他的 cycle.js 库中提出,相比 Vue、Rect、Redux,这是一个偏小众的框架,主要是对 MVC 的改进。而 andrestaltz 也是 RxJs 早期的核心 Contributor。
而 Android 这边最早应该是 Hannes Dorfmann 提出, Hannes 也是受到 cycle.js 启发, 由此产生把 MVI 应用在 Android 的想法,于是就有了 https://github.com/sockeqwe/mosby 这个库。
基本概念
在计算中,响应式编程或反应式编程(英语:Reactive programming)是一种面向数据串流和变化传播的声明式编程范式。这意味着可以在编程语言中很方便地表达静态或动态的数据流,而相关的计算模型会自动将变化的值通过数据流进行传播。
MVI 是纯响应式、函数式编程的架构,Model 监听 Intent,View 监听 Model,Intent 又由 View 发出。数据单向流动,每个部分类似函数式编程,接收一个输入然后输出一个产物传递给下一个人。所以呢 Android 上的实现都要借助 LiveData、RxJava、Flow 这些响应式组件。
这张图可以完美体现 MVI 的函数式编程思想:
fun view(model: (action: Action) -> Data) = {
UserInput(model(action))
}
fun model(intent: (input: UserInput) -> Action) = {
Action(intent(input))
}
fun intent(view: (data: Data) -> UserInput) = {
UserInput(view(data))
}
fun main() {
view(model(intent()))
}
Intent: 由用户交互产生
输入:用户在屏幕上交互 输出:描述用户行为的数据模型,比如用户想刷新,此时应该输出一个 RefreshAction(params) Model: 数据层
输入:描述用户行为的数据(来自 Intent) 输出:给 View 层渲染的数据 View
输入:来自 Model 的数据 输出:将用户的点击,各种手势滑动等交互输出。
MVI 强调数据单向闭环流动,纯函数式的驱动这个循环,每个组件按规则输入和输出。
这个环的起点一般都是 View,因为都是从用户交互发起的。
函数式编程和副作用 (Side Effect)
Cycle.js 提出的 MVI 中强调函数式编程,MVI 三个部分只关心输入的参数和输出的结果。
函数式编程,或称函数程序设计、泛函编程(英语:Functional programming),是一种编程范式,它将电脑运算视为函数运算,并且避免使用程序状态(英语:State (computer science) )以及易变对象。其中,λ演算为该语言最重要的基础。而且,λ演算的函数可以接受函数作为输入参数和输出返回值。
-- 维基百科
常见的函数式编程语言有 Haskell、Scala、F#。 而各种语言的响应式框架(RxJava、RxSwift、Rxjs)也是函数式编程。 函数式编程分为:
纯函数式编程 非纯函数
纯函数强调
不可访问程序外部的状态以及可变的数据 不作出对函数外部影响的操作 同样的参数只会输出一种结果,如果 f(a) = b,那么 f(a) 永远只能等于 b。 不引用任何非纯函数,这会破坏上面的规则
var b = 2;
fun list(a: Int) {
b = a + 1
return b
}
上面这个 list 方法,内部访问了外部的 b 变量,它就不是一个纯函数。纯函数是相对独立的,只有输入和输出,不会影响任何外部的数据。且输入和输出的数据都是不可变的。
副作用
按照响应式以及函数式编程的规则,MVI 这个闭环是不会对外部环境有任何影响的,因为 MVI 这 3 个部件是一个整体,一个环,按顺序生产和消费。举个例子,用户下拉刷新之后产生刷新的意图 (Intent),然后传递给 Model,Model 层去调用 Api 请求数据,然后将数据输出给 View 绘制新的 UI。绘制 UI 就是一个对外部的影响,因为它不属于 MVI 任何组件的输入。Intent 只会接收 View 的用户交互。
绘制 UI 属于 view 函数产生的副作用,因为它不属于 view 函数的输出,只是一个额外产出,它只监听 View 层接收到的 UI 数据然后渲染,所以它属于一个 Side Effect,是一个组件输入/输出的过程中产生的额外的影响。
因为我们有绘制 UI 这种额外产出的需求,在一个函数执行的时候顺便要干点别的事儿。所以引入了 Side Effect 来解决这种场景。熟悉 Android Compose 的会发现 Compose 中也有 SideEffect,这里就不做赘述了,原理是一样的。
可以说,所有基于函数式编程的 UI 的框架都会有这个概念。
状态
因为基于纯函数式编程的思想,所以需要遵守不可变的原则。大概理解了 MVI 的工作流程后,我们来看一下状态,什么是状态?谁的状态?对于前端(包括移动端),状态指 视图的状态,即对页面抽象出来的数据结构。对应 MVI 的架构图来说,状态可以指 Model 层输出的数据,View 层消费状态数据去渲染页面 ui。
比如,这是一个列表页面的状态:
list : 列表中所有的数据 page : 已经加载的页数 totalSize:总的数量
data class MainPageState(
val list: List<Item> = emptyList(),
val page: Int = 0,
val totalSize: Int = 0,
)
View 层的渲染通过监听状态完成:
class View {
fun init() {
// 和 viewModel 层建立观察者模式, 监听 state 的变化
viewModel.observeStateChanged(this) { state ->
drawUi(state)
}
}
fun drawUi(state: MainPageState) {
}
}
View 依赖 State 刷新,所以作为 View 的所谓唯一可信源,State 一定是不可变的。想要更新 View 层,必须生产新的 State。不光是 State,MVI 中所有的输入输出参数都必须不可变,函数式编程中如果参数的值能随意更改, 代码的其他部分并不知情,就会产生意料之外的 bug,如果可变那么就意味着不可信了。
Reducer 与状态管理
MVI 的 V 层自身是不允许管理状态的,只能把状态作为输入参数监听, 不断的产生新的状态,这些前后状态一定是互相关联的,此时一定要有一个队列来管理状态了,因为状态是有序的,View 层不应该丢状态,会挨个处理。
那么谁来管理状态呢?
按照 MVI 的架构图,肯定是生产 state 的 Model 层来管理了。
拿列表页举例,加载更多需要把当前页面的 page + 1 作为参数请求下一页数据,新状态的 list 需要把当前状态的列表数据和下一页数据合并。
class MainPageModel {
val stateStore = StateStore()
// 不严格的伪代码
fun loadMore() {
val state: MainPageState = stateStore.getCurrentState()
val page = state.page + 1
val newList = RemoteApi.getList(page)
val nextState = state.copy(list = state.list + newList, page = page)
stateStore.add(nextState)
}
}
redux 中所有的 state 都集中存储在一个全局的 store 中,不同于 redux 这种中心化的事件管理,MVI 每个模块都是独立的一个环,所以每个页面的 Model 层拥有独立的状态管理。并且也可以有多个 Model 层,一个如果过于复杂页面可以拆分为多个状态。
从这个示例可以看出来,新状态的产出需要 intent 和当前的状态共同参与。而这个过程可以称之为 reducer(大概翻译成压合,把 action 数据和当前状态压成一个新的状态),整体的流程看下图:
所以,一个 Reducer 可以这么定义:
fun reducer(action: Action, state: State): State {
// 计算过程
return State()
}
很多 MVI 博文中没有明确 reducer 以及状态管理者的角色,而是拿到 action 后解析完,直接去更新 State。
第一这跟 MVI 的提出者的思想并不符合,严格来说算不上 MVI, 这 2 者可以说是 MVI 的灵魂部分。 第二状态的输出应该明确是有序的,(排除副作用部分的异步部分,请求 Api 等)。比如先后触发 2 次 Intent,我将这 2 次状态计算分别作为 2 个任务丢进线程池,第二个状态的计算完全有可能比第一个快,导致第二次渲染的 ui 是第 1 次的 State。
保持 reducer 的干净很重要!不要在 reducer 函数中做以下操作:
修改传入参数; 执行有副作用的操作,如 API 请求和路由跳转这些影响函数外部的操作; 调用其他非纯函数,比如 Date.now() 或 Math.random() 都会产生变化的结果。
作为一个纯血正宗的纯函数,reducer 只要传入的参数相同,计算后输出的 state 一定相同。
还是拿刷新列表来举例,用户触发刷新的 Action,然后 Model 去调用 Api 请求数据,请求时通过 reducer 生成一个 loading 的 State 表示正在加载。Api 请求结束后拿到数据再通过 reducer 生成新的状态渲染 ui。
这个过程 reducer 全程只作为一个纯纯的计算状态的工具人存在。怎么请求数据怎么渲染数据 reducer 都不关心,数据层只管把请求完的数据作为参数输入到 reducer 就行,因为按照我们的函数约定一定会生成新状态,后续就交给下个组件消费 State 了,角色分工非常明确。
为了保证状态产出的顺序,StateStore 可以保存 Reducer 函数以及其所需的 action 参数。同时也可以将产出的 state 以及 其 Action 进行保存,便于调试追溯之前的状态(以下示例没有保存,可以自行实现)。
MVI 雏形
// 状态管理
class StateStore : Deque() {
val pair = Pair<ActionData, (ActionData, State) -> State>()
fun add(actionData, reducer: (ActionData, State) -> State) {
//
push(Pair(actionData, reducer))
}
fun poll(): Pair<ActionData, (ActionData, State) -> State> {
}
fun peek(): Pair<ActionData, (ActionData, State) -> State> {
}
}
// ViewModel 层,可以直接继承 Jetpack 的 ViewModel 实现
class ViewModel {
val stateStore = StateStore()
// 当前的 state
var curState = State()
private set
fun init() {
// 子线程/线程池中有序消费 Reducer
viewModelScope.lauch(Dispather.IO) {
while (true) {
val (action, reducer) = stateStore.poll()
val newState = reducer.invoke(action, curState)
// 过滤无意义的重复状态
if (curState != newState) {
curState = newState
// 通知 view 有新的 state
notifyUi(newState)
}
}
}
}
fun loadList(refresh:Boolean) {
if (curState.isLoading) return
val page = if(refresh) 0 else curState.page + 1
// 加载中
stateStore.add(ActionData(page, emptyList())) { actionData, state ->
state.copy(isLoading = true, page = actionData.page)
}
// 此处等待异步的数据回来,getList 可作为协程理解
val moreList = Repo.getList(page)
val actionData = ActionData(page, moreList)
stateStore.add(actionData) { actionData, state ->
state.copy(isloading = false, list = list + actionData.moreList, page = actionData.page)
}
}
}
// Activity/Fragment/其他自定义的 View 层
class View {
// 初始化时订阅 ViewModel 的状态分发
fun init() {
// 和 viewModel 层建立观察者模式, 监听 state 的变化
viewModel.observeStateChanged(this) { state ->
drawUi(state)
}
}
// 示例:关闭当前页面之前,根据当前状态做出一些 Action
fun close() {
// 获取当前的 state
val state = viewModel.curState
// 当前页面是编辑模式时,取消编辑
if (state.isEditMode) {
viewModel.cancelEdit()
return
}
finish();
}
// 根据状态渲染 UI
fun drawUi(newState: MainPageState) {
// 展示 loading
if(newState.isLoading){
val isRefresh = newState.page == 0
showLoading(isRefresh)
return;
}
// 展示列表数据
updateList(newState.list)
}
}
实际为了便利性,可以弱化 action 参数,而是从 reducer 外部的方法中获取静态的不可变值,这样也算是纯函数。
于是 reduer 可以简写定义成这样:
fun reducer(state: State): State {
// 计算过程
return State()
}
用 kotlin 的 function 可以简写成下面👇的样子。State.() 的写法是利用了 kotlin 的语法糖,block 内可以以 this 直接调用当前 state。
val reducer: State.() -> State = {
}
class StateStore : Deque() {
fun add(reducer: State.() -> State) {
//
push(reducer)
}
fun poll(): State.() -> State {
}
fun peek(): State.() -> State {
}
}
class ViewModel {
val stateStore = StateStore()
// 当前的 state
var curState = State()
private set
fun init() {
// 子线程/线程池中有序消费 Reducer
viewModelScope.lauch(Dispather.IO) {
while (true) {
val (action, reducer) = stateStore.poll()
val newState = reducer.invoke(action, curState)
// 过滤无意义的重复状态
if (curState != newState) {
curState = newState
// 通知 view 有新的 state
notifyUi(newState)
}
}
}
}
fun loadList(refresh:Boolean) {
if (curState.isLoading) return
val page = if(refresh) 0 else curState.page + 1
// 加载中
stateStore.add {
copy(isLoading = true, page = page)
}
// 此处等待异步的数据回来,getList 可作为协程理解
val moreList = Repo.getList(page)
stateStore.add {
// 此处可直接使用外部的 page 和 moreList,保证 page 和 moreList 不可变即可
copy(list = list + moreList, page = page)
}
}
}
class View {
fun init() {
// 和 viewModel 层建立观察者模式, 监听 state 的变化
viewModel.observeStateChanged(this) { state ->
drawUi(state)
}
}
fun close() {
// 获取当前的 state
val state = viewModel.curState
// 当前页面是编辑模式时,取消编辑
if (state.isEditMode) {
viewModel.cancelEdit()
return
}
finish();
}
fun drawUi(newState: MainPageState) {
if(newState.isLoading){
val isRefresh = newState.page == 0
showLoading(isRefresh)
return;
}
updateList(newState.list)
}
}
至此,MVI 的小雏形基本形成。
总结
看到这里,MVI 可以抽象出几个关键点
响应式,函数式 状态,reducer 单向数据流 View 没有内部的状态,flutter/Compose/Vue 这些 UI 组件自身是可以有状态的。MVI 的状态只能存在 Model 中
优点
架构思想很简单且解耦低,能将复杂业务简单化,便于维护。 可测试性很强 某个组件出现问题,可以将当前的 State 和相关 Action 还原,还可以追溯之前的 state,倒推用户的一系列操作路径定位问题。
缺点:
不断生成不可变的对象,对性能有一点点影响,但是忽略不计 ViewModel 以及 State 复用性不太强,每个页面一般都会有自己独一无二的 state。 这么多的状态,需要一个高效的 diff ui 组件,Android 原生的 RecyclerView 以及 Comopose 都有 diff 机制,但是其他 View 体系的控制就没办法了
个人认为 MVI 非常顺应现代的声明式框架,是未来趋势,Flutter 和 Compose 都和 MVI 特别匹配。flutter 可以使用 Riverpod (StateProvider) 管理状态,Compose 的话可以使用 airbnb/mavericks。如果你不得不维护原生 View 体系,也可以试试 airbnb/epoxy
MVI 学习资料
https://cycle.js.org/model-view-intent.html#model-view-intent MVI 起源 https://www.youtube.com/watch?v=1zj7M1LnJV4 cycle.js 的作者对于 MVI 的演讲 https://futurice.com/blog/reactive-mvc-and-the-virtual-dom cycle.js 的作者在这篇文章中描述了有关 MVI 的设计和优势 https://hannesdorfmann.com/android/mosby3-mvi-1/ MVI 首次在 Android App 的应用,7 个小章节结合实例阐述 MVI https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fairbnb%2Fmavericks Airbnb Android App 使用的 mvi 架构
-- END --
推荐阅读