查看原文
其他

从源头了解 MVI 的核心思想

AndroidPub 2022-09-23

作者:未来猫咪花
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 --

推荐阅读


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存