Jake Lin 当时在 REA Group 从事移动产品与研发效能相关工作。他关注架构,不只是为了给代码分类,也为了让不同背景的开发者更容易参与同一个项目,并在引入新 UI 技术时继续理解原有逻辑。
这段分享来自上海“移动端技术实践”录播,稿件发布于 2022 年 3 月 20 日,实际活动日期未确认。演示采用当时的 Compose、Kotlin 和 Swift 技术环境;文中的公司经历与团队安排也仅指分享时的情况。
统一架构有收益,也会增加成本
Jake 首先谈的是可维护性、复用、扩展和团队沟通。团队变大以后,大家对同一份代码形成稳定预期,能够减少协作和 review 时反复解释“这段逻辑应该放在哪里”的成本。
他也直接列出代价:分层意味着更多代码和间接调用;响应式编程增加学习成本;带有强约定的框架会限制写法,新成员需要适应,某些新技术也未必容易接入。更一致的代码和更少的自由度,往往来自同一项设计选择。
他用个人 iOS 示例解释这种取舍。在一个采用 MVVM 的应用中,曾把 UI 换成 SwiftUI 而保留其他层;后来又采用新的并发等机制重写部分实现。技术细节发生变化,但分层思路相近,让精力更多用于理解新能力。这是他的项目经验,并不意味着采用一个架构后就永远无需重构。
第一次改造时,他强调替换的是 UIKit 视图层:ViewModel、Repository 和网络层没有跟着重写。第二次则不是只换一个视图组件,而是在同一个朋友圈示例中试用新版 SwiftUI、Swift 的 async/await 与 Actor 等能力。前后两次改动的范围不同,恰好解释了他的主张:架构可以降低引入新技术时重新划分职责的负担,却不能替代针对新需求的重构。
名称很多,首先要解决职责和生命周期的问题
面对 MVC、MVP、MVVM 等名称,Jake 把重点放回视图与数据的分离。移动端界面组件受生命周期影响,把大量业务逻辑写在视图里,会让测试、维护和复用更困难。进一步拆分数据访问、Repository 和状态处理,并通过依赖注入连接,有助于单独验证各部分行为。
分享还比较了当时 Android 文档中从 LiveData、ViewModel 与 Repository 的图示,到 UI、Domain、Data 分层的表达变化。Jake 并不主张为了齐全而强行添加每一层;他在不少应用中并未单设 Domain 层。
他逐层对照了两个版本的图:旧图中,ViewModel 持有界面需要的数据,Repository 协调本地数据与远端接口;新图把数据来源归入 Data 层,强调单一事实来源,把用例组成的 Domain 层标为可选,再让 UI 层围绕状态持有者渲染。术语和图形变化很大,他关心的仍是数据从哪里来、由谁维护,以及界面如何观察它。
读者也不必把 MVI 名称当作唯一目标。Android 官方架构指南强调职责分离、状态来源与单向数据流,并将 Domain 层作为按复杂度和复用需要选择的部分。Android 架构指南
Compose:UI 由当前状态决定
Jake 先用可展开卡片说明声明式 UI:点击改变展开状态,组件根据状态决定显示什么。@Composable 函数描述界面,状态改变后,相应组合内容得到更新。remember 则帮助在重组过程中保留对象,而不是每次都从头创建。
示例中的卡片有图片和文字,点击容器改变 expanded 值。Jake 借此把命令式的“找到某个视图并修改它”换成“给定状态,描述应该出现的界面”。他还用 SwiftUI、React 和 Flutter 作类比:平台 API 各异,但开发者都需要理解状态变化如何驱动声明式界面的重新计算。
这里要注意保存范围:remember 保留的是当前 Composition 中的值,不等于自动应对配置变化或长期保存数据。状态保存和生命周期仍需按需求设计。Compose 状态文档
随后他介绍 MVI 的三个词:Model、View、Intent。这里的 Intent 指用户意图或动作,不是用于启动 Activity 等组件的 Android Intent 类。他采用的具体实现,是让 ViewModel 向界面提供统一的 UiState,界面通过统一的 Action 入口表达操作。
为什么把多个状态流收成一个 UI 状态
搜索页面的例子最能体现问题:结果、加载标记和错误如果独立到达,界面就必须判断先看哪一个,以及它们冲突时怎样显示。逻辑层测得再充分,视图中仍可能出现判断顺序不一致的问题。
Jake 将页面状态表达成加载、结果、错误等明确情形,界面按状态分支渲染。以后新增空结果情形时,也能借助类型和分支检查发现遗漏,而不是在多个布尔判断中寻找合适的位置。
演示中的左右两段代码有相同的搜索需求。左侧分别暴露搜索结果、isLoading 和错误,视图先判断加载还是先判断错误,都可能影响显示;右侧只读取 UiState,在加载、结果和错误三种情况中选择一个分支。搜索无结果需要单独呈现时,可增加 Empty 情形,让处理状态的分支显式面对这个新条件。类似地,视图向逻辑层提出“开始搜索”之类的操作,可以集中从一个 Action 入口进入,而不是在页面不同位置调用多个更新方法。
这样的检查依赖具体的类型建模与分支写法,不是只把架构命名为 MVI 就会得到。MVVM 也可以使用统一状态;MVI 本身也不能保证每个业务条件都已正确覆盖。分享的价值在于展示一种减少无效状态组合的建模方式。
第一步:让输入框和文字共享局部状态
现场从一个新的 Compose 项目开始。Jake 修改生成的问候组件,把原本由参数传入的名字变成组件内的状态,再加入输入框和文本:输入框读取当前值,通过值变化回调更新状态;文本读取同一份状态显示结果。
这个最小例子已经能正常交互,却把状态和更新逻辑放在 UI 内部。下一步的问题是,如果更新规则变复杂,怎样在不运行整个界面的情况下测试它?这成为抽出状态持有者的直接理由,而不是为了多写一个类。
演示顺序很直观:先把生成项目中由参数传入的 name 改成局部可变状态,再用 Column 放入 TextField 和显示问候语的 Text。TextField 的 value 读取名字,onValueChange 把新输入写回;文字组件只读这份名字。两个组件因此同步,但输入规则和界面代码也暂时绑在一起。
第二步:把状态和 Action 移到 ViewModel
Jake 建立 ViewModel,定义包含名字的 UiState,并在内部维护可更新的状态流。对外只开放读取状态的接口,更新统一经过 Action 处理。示例先定义名字变化的 Action,再演示新增另一个 Action 时,如何检查对应处理分支。
他把内部状态流初始化为空名字,随后定义表示 NameChanged 的 Action,并在 handleAction 的分支中取出事件携带的新值。现场临时加入一个邮箱变化事件,只为显示新增 Action 时开发工具会提示处理分支未覆盖;然后又删掉这个无关分支。这里的类型检查帮助发现遗漏,具体提示是警告还是编译错误,仍取决于语言类型和 when 表达式的写法。
处理名字变化时,他基于原有状态创建更新后的副本,保留其他字段,再写回状态流。这使“旧状态经过这个事件变成什么”成为一个明确的测试对象。
视频把 copy 与通知更新紧密联系,实际机制要更精确:StateFlow 根据相等性合并值,相等的新值不会再次发出。copy 是不可变数据建模常用的更新方式,不是通知的特殊开关。对外只读也应由接口类型和封装保证,不能仅凭属性声明为 val。Kotlin StateFlow 文档
第三步:界面读取状态,输入只发送事件
回到 Compose,Jake 让界面取得状态持有者,收集其状态流,再把状态字段绑定到组件。输入回调不直接改原来的局部变量,而是发出名字变化的 Action。最终外观与最初示例几乎一样,职责却已经不同。
为了展示单向数据流,他在输入回调和 Action 处理处打断点:按下一个字符后,界面并不会绕过状态直接决定最终显示;事件先进入处理逻辑,生成新状态,组件再根据状态更新。可以沿这一条路径逐步检查值在哪里变化。
断点停住时,他在模拟器输入小写 j,界面仍显示旧的空值;继续走到 handleAction,能看到旧 UiState.name 为空、Action 中的名字是 j。更新状态流并让界面继续执行后,j 才出现在输入框和文字中。这个逐步调试比只看最终画面更能说明“事件上行、状态下行”的次序。
接着他加入只接受数字的规则:处理事件时过滤输入,如果有效值没有变化就保持原状;输入非数字字符时,最终状态不会包含它。校验逻辑集中后,可以直接测试规则,不必把所有情况都放到 UI 自动化里。这个教学例子说明的是控制路径,实际文本编辑仍需考虑输入法等完整交互。
他重新运行应用,先输入 J,画面不出现这个字符;再输入 01,数字显示出来;继续输入 A,状态仍保持 01。这样一个很小的规则,就能让人看到状态持有者改变的是最终呈现的值,而不是只在输入完成后附加一个报错提示。
Jake 还提到,当时尝试在 SwiftUI 中组织相同输入控制流程时遇到差异。他把它作为继续探索的问题,没有展示一个适用于所有平台和输入组件的通用解决方案。
钱包示例:把异步请求也纳入状态转换
后半场用一个闪电网络钱包演示 iOS 与 Android 两套原生实现。UI 分别采用 SwiftUI 和 Compose,状态与异步设施也各用平台语言生态中的实现;相近的是页面流程和状态组织方式,并不是同一份代码无需转换就能运行于两端。
Jake 把两端的技术栈逐项对齐:iOS 侧用 ObservableObject、Combine、Swift 并发和 Codable,Android 侧用 StateFlow、SharedFlow、Kotlin 协程与序列化工具。视频里他先说 Android 的一处响应式设施名称写错了,随后口头更正。两端跑起后,他分别打开创建钱包界面,输入名称并提交,让观众看到相同的交互步骤;这种相似性来自设计和分别实现,而非跨平台共享二进制代码。
创建钱包页面分为表单和备份等状态。表单包含输入内容和加载信息,事件包含字段变化与提交。提交后先进入创建中的状态,发出后台请求;成功后保存相应资料,并切到备份界面。这里展示的是应用流程,不能仅凭 UI 演示判断钱包整体安全性。
更具体地说,表单要记录钱包名和服务端配置名,Action 分别对应两个字段的修改及点击创建按钮。提交后加载标记让按钮附近出现等待反馈;请求成功后,状态从表单切到备份,并展示需要保存的秘密资料。Jake 表示会把资料写入本地安全存储,但现场没有展开密钥管理、恢复流程或安全审计。
测试因此可以围绕状态转换展开:给定初始状态与事件,检查中间加载状态和完成后的状态;请求失败时,也应验证对应行为。Jake 把两端 ViewModel 并排展示,说明状态、Action 和处理入口的相似性,让开发者切换平台时仍可沿用理解问题的方式。
他同时指出演示尚有差异:Swift 版本已处理失败显示,Android 版本还没有补上。这个缺口也说明,架构相似不等于行为已经对齐;异常路径与用户体验需要逐项完成。
并排展示两端 ViewModel 时,他让观众先看 UiState 的表单与备份两种子状态,再看三个 Action,最后看处理入口。字段、分支和创建时的加载过程可以逐项对照,开发者换平台时有熟悉的阅读路径;但 iOS 创建失败时会显示错误,Android 的对应分支尚缺。这是同一设计目标下仍需完成的具体工作。
当时的团队背景与这场分享的落点
结束时,Jake 介绍了团队同时进行 iOS、Android 和部分 BFF 开发的工作方式,以及当时远程协作、学习探索和招聘的安排。这些属于 2022 年分享中的团队情况,不代表现在的岗位或政策。
这场演示没有把 MVI 作为消除复杂性的万能答案。它具体展示了如何让状态归属、事件入口和异步结果更容易追踪,并让测试围绕业务转换展开。与此同时,额外分层的成本、平台差异和错误处理,仍然需要开发团队自行承担。
