V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
KunMinX
V2EX  ›  Android

简单分享下我对 MVI 的理解

  •  
  •   KunMinX · 2022-09-08 16:41:25 +08:00 · 9247 次点击
    这是一个创建于 838 天前的主题,其中的信息可能已经有所发展或是发生改变。

    这几天讨论 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 数据绑定的努力前功尽弃。

    目前想到的就是这些,有不同观点欢迎补充。

    10 条回复    2022-09-10 19:30:45 +08:00
    KunMinX
        1
    KunMinX  
    OP
       2022-09-08 17:03:48 +08:00
    这几天讨论 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 数据绑定的努力前功尽弃。

    目前想到的就是这些,有不同观点欢迎补充。
    reactna1ve
        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 ),但是状态是保持的,需要自己去区分

    所以这玩意并不是唯一的解决方案,更多是一种思路。具体的架构模式得参考自己的工程环境
    KunMinX
        3
    KunMinX  
    OP
       2022-09-09 12:00:48 +08:00
    没错,3 十分有体会,包括 editText 等自身能产生数据的控件情况。
    5 的话,uiEvent 由于会改变 mvi-view 以外的环境(比如新增一个 window ,新增一个页面到返回栈),所以对纯函数而言又是副作用,需要某种方式解决,
    2 没有理解,我的理解是,uiStates 和 actions 都聚合在 mvi-model 中,从发起 intent 到 action 为止,这期间可以记录本次操作路径,并且本次发起请求和回推结果,都可以记录
    KunMinX
        4
    KunMinX  
    OP
       2022-09-09 12:00:59 +08:00
    KunMinX
        5
    KunMinX  
    OP
       2022-09-10 00:44:02 +08:00
    @reactna1ve 理解你意思了,2 包含异步顺序等问题。
    Guaidaodl
        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 当前一个可变的对象直接修改。
    Guaidaodl
        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
    KunMinX
        8
    KunMinX  
    OP
       2022-09-10 12:54:47 +08:00
    @Guaidaodl

    受你的启发,想到一个简便方式了。类似于把 copy 环节后置,也即开发者写代码时,像往常一样直接改字段,然后回推时给到 UI 的是重新 copy 的 uiStates ,也即可以给观察者套上一层 wrapper 来内部实现 copy 。
    Guaidaodl
        9
    Guaidaodl  
       2022-09-10 17:48:02 +08:00 via iPhone
    @KunMinX
    通常实现是反过来吧。用户定义一个不可变 State ,Reducer 中操作的自动生成的可变对象。
    KunMinX
        10
    KunMinX  
    OP
       2022-09-10 19:30:45 +08:00
    @Guaidaodl
    也是,考虑线程安全。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   5274 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 23ms · UTC 08:17 · PVG 16:17 · LAX 00:17 · JFK 03:17
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.