这几天讨论 MVI 的人有点多,我简单分享下自己的理解。
MVI 是响应式编程的产物。
响应式编程有个潜规则:一个控件只能在同一个粘性观察者( BehaviorSubject )中响应,
然而随着业务发展,开发者很容易误在多个粘性观察者中放置同一控件实例,即引发 “多个粘性观察者不符预期回推” 的隐患,
MVI 主要就是来解决这问题。通过将页面 uiStates 聚合在一处,控件只从这唯一观察者响应数据变化,从而确保环境重建时,回推的最后一次数据来源唯一,乃至符合预期。
由于控件集中响应,uiStates 字段不可变,加上每次根据 intent 执行业务,拿到的都是当前 action 当前 partialChange ,故唯有通过 copy 整合当前 partialChange 到上一次 uiStates ,来确保 uiStates 的延续,这个 copy 以延续的过程就叫 reduce ,
不过如果不是有专门的需求,我们无须特意写个 reducer 类来体现,而仅仅是 reduce 方法或直接 copy ,心意到了即可,
与此同时,UI 侧都是通过 diff 来判断本次某控件是否刷新。jetpack compose 无须开发者手动 diff ,其内部类似前端 DOM ,根据本次注入的声明树自行在内部差分合并渲染新的内容。当前我个人是用 DataBinding observableField 来做 diff 。
当然 MVI 也有其不便之处,由于它本来就是要通过聚合 uiStates 来规避上述不符预期问题,故 uiStates 很容易爆炸,特别是 UiStates 居多情况下,每次回推都要做数十个 diff ,在高实时变化的场景下,有一定的性能影响,
MVI 许多页面和业务都需手写定制,难通过自动生成代码等方式半自动开发,故我们我们不如退一步,反思下为什么要用响应式编程?
穷尽所有可能,我觉得最合理的解释是,响应式编程十分便于单元测试 —— 由于控件只在观察者中响应,那么有输入就必定有回响,
也是因为这原因,官方出于完备性考虑,以响应式编程作为架构示例。
现实中情况往往十分复杂。
android 当初为了站稳脚跟,选择复用已有的 java 生态和开发者,乃至使用 java 来作为官方语言,后来 java 越来越难支持现代化移动开发,故而转向 kotlin ,
用 kotlin 的开发者,更容易跟着官方文档走,一开始就是接受 flow 、reactive 的那一套,且 kotlin 的确抹平了语法复杂度,所以天然就是响应式编程的模式在开发,如此便有机会踩坑,并且有动力通过 MVI 的方式来改善响应式编程开发。
然而 10 个 android 7 个纯 java ,其中 6 个从不用 rxJava ,剩下一个还是偶尔用用 rxjava 的线程调度切换,
所以响应式编程在 java android 开发中的推行不太理想,领导甚至可能为了照顾多数同事,而要求撤回响应式编程代码,如此便很难有机会踩坑,更谈不上使用 MVI ,
也因此,实际开发中更多考虑的是,如何从根源上避免各种不可预期问题。对此从软件工程角度出发,我在设计模式原则中找到答案 —— 任何框架,只要遵循单一职责原则,就能有效避免各种不可预期问题,反之过度设计则易引发不可预期问题,
例如 BehaviorSubject ,实际上是一种过度设计,因为它的观察者是开放式,一旦开了这口子,后续便不可控,一个良好的设计是,不暴露不该暴露的口子,不给用户犯错的机会。一个正面的案例是 DataBinding observableField ,一个控件只能在 xml 中绑定一个,从根源上杜绝上述不符预期问题。
不过 DataBinding 也存在过度设计,例如开发者能拿到 binding 实例去直接调用控件实例,如此 observableField 数据绑定的努力前功尽弃。
目前想到的就是这些,有不同观点欢迎补充。
1
KunMinX OP 这几天讨论 MVI 的人有点多,我简单分享下自己的理解。
MVI 是响应式编程的产物。 响应式编程有个潜规则:一个控件只能在同一个粘性观察者( BehaviorSubject )中响应, 然而随着业务发展,开发者很容易误在多个粘性观察者中放置同一控件实例,即引发 “多个粘性观察者不符预期回推” 的隐患, MVI 主要就是来解决这问题。通过将页面 uiStates 聚合在一处,控件只从这唯一观察者响应数据变化,从而确保环境重建时,回推的最后一次数据来源唯一,乃至符合预期。 由于控件集中响应,uiStates 字段不可变,加上每次根据 intent 执行业务,拿到的都是当前 action 当前 partialChange ,故唯有通过 copy 整合当前 partialChange 到上一次 uiStates ,来确保 uiStates 的延续,这个 copy 以延续的过程就叫 reduce , 不过如果不是有专门的需求,我们无须特意写个 reducer 类来体现,而仅仅是 reduce 方法或直接 copy ,心意到了即可, 与此同时,UI 侧都是通过 diff 来判断本次某控件是否刷新。jetpack compose 无须开发者手动 diff ,其内部类似前端 DOM ,根据本次注入的声明树自行在内部差分合并渲染新的内容。当前我个人是用 DataBinding observableField 来做 diff 。 当然 MVI 也有其不便之处,由于它本来就是要通过聚合 uiStates 来规避上述不符预期问题,故 uiStates 很容易爆炸,特别是 UiStates 居多情况下,每次回推都要做数十个 diff ,在高实时变化的场景下,有一定的性能影响, MVI 许多页面和业务都需手写定制,难通过自动生成代码等方式半自动开发,故我们我们不如退一步,反思下为什么要用响应式编程? 穷尽所有可能,我觉得最合理的解释是,响应式编程十分便于单元测试 —— 由于控件只在观察者中响应,那么有输入就必定有回响, 也是因为这原因,官方出于完备性考虑,以响应式编程作为架构示例。 现实中情况往往十分复杂。 android 当初为了站稳脚跟,选择复用已有的 java 生态和开发者,乃至使用 java 来作为官方语言,后来 java 越来越难支持现代化移动开发,故而转向 kotlin , 用 kotlin 的开发者,更容易跟着官方文档走,一开始就是接受 flow 、reactive 的那一套,且 kotlin 的确抹平了语法复杂度,所以天然就是响应式编程的模式在开发,如此便有机会踩坑,并且有动力通过 MVI 的方式来改善响应式编程开发。 然而 10 个 android 7 个纯 java ,其中 6 个从不用 rxJava ,剩下一个还是偶尔用用 rxjava 的线程调度切换, 所以响应式编程在 java android 开发中的推行不太理想,领导甚至可能为了照顾多数同事,而要求撤回响应式编程代码,如此便很难有机会踩坑,更谈不上使用 MVI , 也因此,实际开发中更多考虑的是,如何从根源上避免各种不可预期问题。对此从软件工程角度出发,我在设计模式原则中找到答案 —— 任何框架,只要遵循单一职责原则,就能有效避免各种不可预期问题,反之过度设计则易引发不可预期问题, 例如 BehaviorSubject ,实际上是一种过度设计,因为它的观察者是开放式,一旦开了这口子,后续便不可控,一个良好的设计是,不暴露不该暴露的口子,不给用户犯错的机会。一个正面的案例是 DataBinding observableField ,一个控件只能在 xml 中绑定一个,从根源上杜绝上述不符预期问题。 不过 DataBinding 也存在过度设计,例如开发者能拿到 binding 实例去直接调用控件实例,如此 observableField 数据绑定的努力前功尽弃。 目前想到的就是这些,有不同观点欢迎补充。 |
2
reactna1ve 2022-09-09 11:28:21 +08:00
MVVM MVI 只是更多是协议式范式,我见过有把 MVVM 写成 MVP 的。我理解 MVI 更多是一种函数式编程思想的演进,即保证当前 ui 和定义的有限状态机是幂等的。但是实际工程中碰到的问题会有:
1 、uistate 粒度区分,粒度细了状态数量爆炸,粒度粗了没意义 2 、由于中间隔了一层 dispatcher ,导致问题追溯的路径不是连贯的 3 、ping-pong 问题。比如在某个场景下会触发多个 View 的修改,同时修改的状态依赖上一个 View 的状态以及当前逻辑,MVI 下这个通信逻辑会非常绕。Logic 和 View 之间来回横跳 4 、性能问题,这个上面你也说了,我们这没用 composer 很大一部分原因就是这个 5 、无论是 rx 还是 livedata ,intent 不会区分事件和状态。这俩的区别是事件会 consume ( like toast 或者 click ),但是状态是保持的,需要自己去区分 所以这玩意并不是唯一的解决方案,更多是一种思路。具体的架构模式得参考自己的工程环境 |
3
KunMinX OP 没错,3 十分有体会,包括 editText 等自身能产生数据的控件情况。
5 的话,uiEvent 由于会改变 mvi-view 以外的环境(比如新增一个 window ,新增一个页面到返回栈),所以对纯函数而言又是副作用,需要某种方式解决, 2 没有理解,我的理解是,uiStates 和 actions 都聚合在 mvi-model 中,从发起 intent 到 action 为止,这期间可以记录本次操作路径,并且本次发起请求和回推结果,都可以记录 |
4
KunMinX OP |
5
KunMinX OP @reactna1ve 理解你意思了,2 包含异步顺序等问题。
|
6
Guaidaodl 2022-09-10 01:39:19 +08:00
MVI 看起来就是模仿 React + Redux 吧。但是这种模式的一个基础就是 React 是有 VDOM 层,Android 原来可没有。到了 Compose 出来之后,用这种类似 Redux 的方案倒是比较合理了。毕竟 Compose 的 API 基本就是全盘抄 React 的(实现很不一样)。
Redux 这种模式之前也曾经模仿过,不过实际用下来发现几个不太方便的地方。 1. 定义很多 action(intent). 其实面向对象中,调用方法就是发消息。真的用 action ,实践中其实通常也是直接分发给 reducer 的不同的方法处理的,直接调用方法其实更方便。 2. 改变状态很繁琐,虽然 data class 自动生成 copy 方法,但是依然不太方便。 3. 就是你提到的手动 diff 还挺麻烦的。 其实你如果去看看现在的 Redux 写法,1 和 2 都被改善了。不用再手动定义 Action 的名字,而是自动生成跟 reducer 中方法名一样的 action 。同时再 Reducer 中,你处理也不再是一个不可变的数据,而是可以直接把 State 当前一个可变的对象直接修改。 |
7
Guaidaodl 2022-09-10 02:00:51 +08:00
@reactna1ve
3. 就你的描述来看,我觉得你的分层做得不够好。MVVM 分层的其中一个目标就是让逻辑更内聚。View 层不保存状态,只响应变化 ViewModel 的变化,你需要状态应该都在 VM 中。 一个比较难处理一点的就是有些操作中间可能需要在用户响应,比如弹窗需要用户点击确认或取消。但是其实这种有了协程后也比较容易处理了,调用一个 suspend 方法去等待,直到用户响应就行。 5 其实 RxJava 和 Flow 都出有区分状态和事件的啊。比如 RxJava 状态是 BehaviorSubject ,事件是 PublishSubject 。只有 LiveData 是单纯的状态,需要事件的时候我们会使用自定义的 PublishData ,不使用 LiveData |
8
KunMinX OP @Guaidaodl
受你的启发,想到一个简便方式了。类似于把 copy 环节后置,也即开发者写代码时,像往常一样直接改字段,然后回推时给到 UI 的是重新 copy 的 uiStates ,也即可以给观察者套上一层 wrapper 来内部实现 copy 。 |