简单分享下我对 MVI 的理解 - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
KunMinX
V2EX    Android

简单分享下我对 MVI 的理解

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

    这几天讨论 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     Solana     3273 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 28ms UTC 00:06 PVG 08:06 LAX 17:06 JFK 20:06
    Do have faith in what you're doing.
    ubao snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86