MVI 范式在 Jetpack Compose 上的应用

本文于 2022-04-20 发表在老司机技术 - 微信公众号,为免费文章。现转载到本人博客,章节顺序有所编辑。


讲师简介: 林永坚(Jake Lin),超过十年移动开发经验,曾经是微软 Window Phone MVP,熟悉 iOS、Android 等平台。目前在 REA Group 担任 Mobile Tech Lead,负责公司 Customer 产品部所有移动产品的开发。并负责移动 App 的架构,以及项目自动化与工程化建设。开发 IBAnimatable 和 SwiftWeather 等开源项目,并编写了iOS开发进阶课程

编辑简介:nuomi1,Swift with iOS,果粉 / 米家粉

老司机技术周报与 T 沙龙联合举办了今年 2 月底的一次线下沙龙活动。林永坚 受邀为大家分享【MVI 范式在 Jetpack Compose 上的应用】,nuomi1 基于这次分享视频为大家整理此文,感谢二位!并整理成文字版分享给大家!(ps. 阅读原文,获取 PPT!)

MVI 范式在 Jetpack Compose 上的应用

正文

mvi-with-jetpack-compose-01.png

大家好,我是来自 REA Group 的 Jake。今天想和大家分享一下 MVI 范式在 Jetpack Compose 上的应用,希望能给大家一些新的启发。

范式

首先讲一下我们为什么要有架构和范式:

mvi-with-jetpack-compose-02.png

  1. 代码易于维护;
  2. 方便代码重用;
  3. 提高可扩展性;
  4. 方便团队沟通。

随着团队规模越来越大,每个成员在各方面的水平都不一样,没有一定的规范,代码质量将会变得难以控制。团队里如果应用了一定的代码架构和范式,大家就可以很方便地 review 代码,给代码库做贡献。

当然也不是说这些范式就没有缺点了:

  1. 代码层级变多,需要编写更多的代码;
  2. 学习成本提高,特别是引入响应式编程;
  3. 缺乏灵活性,只能按照预定的范式进行编写。

如果严格按照范式去编写一个简单的页面,需要写网络层、数据访问层、ViewModel 层等好几个层级,代码量一下子就上去了,这就会提高维护成本,特别是引入响应式编程之后,学习成本也会提高。但是响应式编程也是一个非常实用的范式,它可以减少很多 BUG。另外在新成员加入的时候,按照既定的范式可以很好地维护代码,但是也可能导致无法容易地引入新技术。凡事都具有两面性,需要仔细思考,准确把握。

从 UIKit 无缝移植到 SwiftUI

那么范式的好处是什么呢?我们可以把一个层级的内部逻辑完全替换,而不用改动其他层级。

mvi-with-jetpack-compose-03.png

举个例子,我在去年写了一本书,内容是一个仿照朋友圈的 app,使用 MVVM 范式进行编写。通过这种范式,我在 UI 层可以从 UIKit 无缝迁移到 SwiftUI,而不需要改动其他层级的任何代码。在框架设计比较合理的情况下,引入新技术还是比较方便的。

当然也不是有了框架就一成不变,永远用它。随着技术的更新迭代,我们还需要不断地重构,用更好的方式去实现功能。

MVVM 在 SwiftUI 上重新实现

在 Swift 5.5 出来以后,我使用新的框架和技术把朋友圈 app 重写了。通过对比,可以看到新的结构要比旧的结构好不少,但是分层其实没有太大的变化,还是 MVVM 的分层。

mvi-with-jetpack-compose-04.png

可以看到,当我们熟悉了一些范式之后,写这两版 app 其实没有太大区别,不用在整体架构上考虑很多,而是可以花更多的精力在新技术的尝试上,提升生产力。

Android 流行范式

回到本次分享的主题,我们先来了解一下 Android 上的流行范式:

mvi-with-jetpack-compose-05.png

  1. MVC - Model View Controller
  2. MVP - Model View Presenter
  3. VIPER - View Interactor Presenter Entity Routing
  4. MVVM - Model View ViewModel

MVC 是 iOS 比较常见的范式,也有人在 Android 中使用。Android 上比较流行的是 MVP 和 MVVM,而 MVVM 又是 Google 所提倡的。

范式有很多种,我们数不过来也学不过来,主要是解决这些问题:

  1. 保护原有职位并且创造新的就业;
  2. 视图与数据的分离;
  3. 不同功能模块之间的解耦。

视图是有生命周期的,如果数据也有了生命周期,就会难以进行单元测试,使得代码质量下降。随着我们把大量的逻辑写在视图里面,视图就会越来越膨胀,维护成本也越来越高。因此我们尽量把视图和数据分离,让数据能够进行独立的单元测试,保证软件质量。

除了视图与数据的解耦,我们还可以接续解耦网络层、数据层、ViewModel 层,通过引入依赖注入,可以让 app 更容易地进行测试。

Google 推荐的范式 - MVVM

来看一下 Google 推荐的范式 MVVM,它的结构和朋友圈 app 是非常类似的。

mvi-with-jetpack-compose-06.png

这个范式在 Android 上使用了 LiveData 和 ViewModel 这种响应式的数据流的方式来管理生命周期,这样子就解决了 Activity / Fragment 生命周期导致的问题。而 ViewModel 由 Repository 组成,分为本地数据和网络数据,本地数据通过 Room 来读写 SQLite,网络数据通过 Retrofit 来获取。一旦数据到来,视图就可以自动地更新。

Google 更新的推荐范式

如果你有关注 Google 的文档,就会发现 Google 推荐的范式更新了,不再是 MVVM 范式,而是提出了 UI Layer、Domain Layer (optional) 和 Data Layer 这些概念。

mvi-with-jetpack-compose-07.png

从层级来说其实和 MVVM 没有多大的区别,Data Layer 不区分本地数据还是网络数据,只不过使用了 Single source of truth 的概念,Domain Layer 是一个可选项,我写的 app 绝大部分用不到,UI Layer 则是主要更新的地方。

原先的 UI 层是 Activity / Fragment,它是一种旧的编程模式,比如说有一个视图对象,然后我们不断地命令它更新什么内容,这就是命令式 UI。

随着技术更新和设备性能提升,逐渐发展出声明式 UI,Web 端可谓是百花齐放,Google 也把这种声明式框架集成进 Android,这就是 Jetpack Compose。

Jetpack Compose

mvi-with-jetpack-compose-08.png

这是一个简单的 Jetpack Compose app,我们可以看到一个叫做 @composable 的注解。有了这个 @composable 之后,Android Studio 可以在后台做一些预编译的工作。

这里的 fun 是一个 component 而不是一个简单的 function,并且名字是大驼峰式命名,不像平时写函数时用的小驼峰式命名。

在这个 component 里面包裹了一些 component,第一个叫 Card,是 Jetpack Compose 里面的一个 material component,表现为一个卡片。它里面存了一个叫 expanded 的 state,这个 state 用了 remember 修饰,意思是 state 每次改变之后,component 都会触发重新绘制。

下面是一个 Column,对应到 SwiftUI 则是 VStackColumn 里面还有一个 Image 和一个 Text。当我们点击卡片的时候,就会改变 expanded 的状态,视图随之更新。

这个 app 使用的就是上面所说的声明式 UI,不管是 Jetpack Compose、SwiftUI、React 还是 flutter,这些声明式 UI 都遵循 UI = render(state) 公式,即唯一的状态通过一个渲染过程确定唯一的 UI。

MVI 范式

Google 引入 Jetpack Compose 之后对文档进行了大量的更新,虽然没有明说,实际上文档都引用了 MVI 这种新的范式。

所谓 MVI,即 Model View Intent,它强调了单向数据流,也有人说 MVI 是 MVVM + Redux。

mvi-with-jetpack-compose-09.png

从图可以看到,MVVM 和 MVI 的组成部分是一样的。在原先的 MVVM 中,我们有多个数据流,例如是不是 loading 状态,不是 loading 状态带不带 data,没有 data 会不会有 error,这些数据存在着依赖关系。当 View 要去更新 ViewModel 的时候,会调用不同的方法,例如 loadData 或者 bookMark 等等。但是在 MVI 中,View 和 ViewModel 的关系就变得非常明确了,ViewModel 只提供单一数据流给 View,这个单一数据流保证了如何渲染 UI,View 也只提供单一事件流到 ViewModel,这个事件流也就是 action,对应 MVI 中的 Intent。

MVVM 与 MVI 例子

来看一下代码,左侧使用 MVVM 范式,右侧使用 MVI 范式。

mvi-with-jetpack-compose-10.png

先看第一个不同点,在 MVVM 中,有三个数据流,第一个是 searchResults,第二个是 isLoading,第三个是 error。而在 MVI 中,只有一个数据流,那就是 uiState

再看第二个不同点,在 MVVM 中,首先检查是不是 loading 状态,如果是则提供一个 LoadingView,然后检查有没有数据,如果有则提供一个 SearchResultView,最后检查有没有 error,如果有则提供一个 ErrorView,但是这里面就有一个问题,到底是先检查 loading 还是先检查 error 还是先检查有数据,而且也有可能在 ViewModel 层已经做了很完善的测试,但使用闭源方式提供,导致在 View 层展示出问题,这种情况下只能凭经验决定到底要先检查哪一个状态。而在 MVI 中,只需要对 uiState 进行检查,它只有三种状态,要么是 loading,要么是有数据,要么是错误,并且编译器的 warning 会强制我们去处理这些状态。假如新增了一种 empty 状态,在 MVVM 中我们难以判断应该加到哪里,但是在 MVI 中就非常简单了,编译的时候就会提示 uiState 还有状态没有处理。

最后看第三个不同点,在 MVVM 中,处理事件时需要调用 ViewModel 的多个函数。而在 MVI 中,统一向 ViewModel 发送具体的 action 进行事件处理。

代码

mvi-with-jetpack-compose-11.png

下面我们来展示一个简单的例子,打开 Android Studio,选择 Empty Compose Activity。

0008.png

可以看到在 MainActivity 中不再是 Fragment,取而代之的是一个 setContent 函数,内部是一个 Greating@Composable 函数。

0009.png

我们把 Greating 外部传入的 name 参数去掉,改为 state,然后使用 Column 包裹住 TextFieldTextTextField 需要两个参数,第一个 value 传入 name,第二个 onValueChange 传入一个 lambda,修改 name 的值。

运行模拟器,点击输入框键入文字后,不仅 TextField 展示了所输入的文字,Text 也会同步更新,因为他们共享了同一个 name state,只要 name 发生改变,TextFieldText 就会同时更新。

0010.png

但是 Greating 这个 compose 难以进行单元测试,为此我们创建一个 GreatingViewModel。在 GreatingViewModel 中,我们创建了 UiStateUiAction,通过 handleAction 方法处理各个 action 并更新 state,然后驱动视图更新。

0011.png

有了 GreatingViewModel 之后,我们来改写 Greating。可能你会有疑问,TextFieldvalueonValueChange 都直接或间接引用了 uiState.name,在键入文字后会不会发生两次更新,答案是不会。

0012.png

0013.png

通过断点调试,我们可以看到,当键入第一个文字 j 后,TextField 并没有立刻更新,而是通过 handleAction 函数去处理 uiStateNameChanged action。在 handleAction 中处理完成后,uiState 才会更新并驱动视图更新。

0014.png

基于这个处理流程,我们还可以实现只支持输入阿拉伯数字的功能。对 NameChanged.name 进行过滤,当值只包含阿拉伯数字时才更新 uiState,通过这种简单的方式就能实现我们的需求。

视频中还有一个小节讲述了基于 MVI 范式编写的虚拟货币钱包 app,其 iOS 与 Android 两端代码结构非常相似,此处不再总结,感兴趣的可以观看视频了解更多。

mvi-with-jetpack-compose-12.png

总结

MVI 这种单一数据流、单一事件流的范式能够帮助我们更好地实现数据和视图的解耦和进行单元测试,同时通过编辑器警告或错误保证了各种可能的情况都能被处理。

关注我们

我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号。欢迎关注。

关注有礼,关注【老司机技术周报】,回复「2021」,领取 2017/2018/2019/2020 内参