到底什么是 MVI ?

您所在的位置:网站首页 本田xrv和途铠应该选购哪个 到底什么是 MVI ?

到底什么是 MVI ?

2023-11-12 19:17| 来源: 网络整理| 查看: 265

mvi 老早比较冷门,最近频繁看到这 3 个字母。这篇算是我使用 mvi 几年的一个总结,希望对大家有帮助。

前言

MVI,即 Model-View-Intent,最早由 andrestaltz 于 2015 年在他的 cycle.js 库中提出,相比 Vue、Rect、Redux,这是一个偏小众的框架,主要是对 MVC 的改进。 这是他在 JSConf Budapest in May 2015 中的关于 MVI 的演讲:www.youtube.com/watch?v=1zj…,感兴趣可以看一下,了解 MVI 的前世今生。 而 andrestaltz 也是 RxJs 早期的核心 Contributor。 而 Android 这边最早应该是 Hannes Dorfmann 提出, Hannes 也是受到 cycle.js 启发, 由此产生把 MVI 应用在 Android 的想法,于是就有了 mosby:一个应用了 MVI 模式的 Android App。

基本概念

在计算中,响应式编程或反应式编程(英语:Reactive programming)是一种面向数据串流和变化传播的声明式编程范式。这意味着可以在编程语言中很方便地表达静态或动态的数据流,而相关的计算模型会自动将变化的值通过数据流进行传播。

img.png MVI 是纯响应式、函数式编程的架构,Model 监听 Intent,View 监听 Model,Intent 又由 View 发出。数据单向流动,每个部分类似函数式编程,接收一个输入然后输出一个产物传递给下一个人。 所以呢 Android 上的实现都要借助 LiveData、RxJava、Flow 这些响应式组件。

这张图可以完美体现 MVI 的函数式编程思想:

img_1.png 用代码简单描述:

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 变量,它就不是一个纯函数。 纯函数是相对独立的,只有输入和输出,不会影响任何外部的数据。且输入和输出的数据都是不可变的。

副作用

img_2.png 按照响应式以及函数式编程的规则,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 中也有 Side Effect,这里就不做赘述了,原理是一样的。 可以说,所有基于函数式编程的 UI 的框架都会有这个概念。

状态

因为基于纯函数式编程的思想,所以需要遵守不可变的原则。大概理解了 MVI 的工作流程后,我们来看一下状态,什么是状态?谁的状态?对于前端(包括移动端),状态指 视图的状态,即对页面抽象出来的数据结构。对应 MVI 的架构图来说,状态可以指 Model 层输出的数据, View 层消费状态数据去渲染页面 ui。

比如,这是一个列表页面的状态:

list : 列表中所有的数据 page : 已经加载的页数 totalSize:总的数量 data class MainPageState( val list: List = 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 数据和当前状态压成一个新的状态),整体的流程看下图:

img_3.png 所以,一个 Reducer 可以这么定义:

fun reducer(action: Action, state: State): State { // 计算过程 return State() }

很多 MVI 博文中没有明确 reducer 以及状态管理者的角色,而是拿到 action 后解析完,直接去更新 State。

第一这跟 MVI 的提出者的思想并不符合,严格来说算不上 MVI, 这 2 者可以说是 MVI 的灵魂部分。 第二状态的输出应该明确是有序的,(排除副作用部分的异步部分,比如请求 Api 的网络加载过程)。所以使用队列来储存状态函数,按顺序执行。

保持 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 State>() fun add(actionData, reducer: (ActionData, State) -> State) { // push(Pair(actionData, reducer)) } fun poll(): Pair State> { } fun peek(): Pair 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 体系,可以试试 声明式 UI 的 recyclerView - airbnb/epoxy 搭配 airbnb/mavericks。

推荐的 MVI 相关资料

以上是我个人对 MVI 的学习总结,权当抛砖引玉。以下是我搜集到的一些 MVI 相关的优秀资源,配合食用。

Cycle.js - Model-View-Intent

从源头了解 MVI

JSConf Budapest in May 2015 MVI

cycle.js 的作者对于 MVI 的演讲

Reactive MVC and the Virtual DOM

cycle.js 的作者在这篇文章中描述了有关 MVI 的设计和优势。即使没有 Web 开发背景,我也建议阅读以更好地理解。

mosby3-mvi 系列

MVI 首次在 Android App 的应用,7 个小章节结合实例阐述 MVI

airbnb/mavericks

Airbnb Android App 使用的 mvi 架构 字节教育貌似也在使用

github.com/badoo/MVICo…

国外知名社交 App Badoo 开源的 MVICore


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3