前言
携程机票前台团队在使用 React Native 实现众多业务的过程中,经历了前期少量探索,中期大量应用,后期架构和性能优化的三个阶段。
在该技术栈积累了一定经验之后,结合不同业务的特点和复杂性,我们重新审视和思考一些前期实践项目的整体优化方向。在 App 国际机票查询列表页的相关业务模块,基于 Clean Architecture 整洁架构之道的思想,进行了一次技术大重构。
一、GUI 架构回顾
GUI 架构模式,一般分为两类:MV* 和 Unidirectional 。
最初的 MVC 将模块划分为展示界面的 View,数据模型 Model 和负责处理二者关系的 Controller 。从 MVC 到 MVP 的过程将 Model 和 View 完全隔离。随着 Databinding 技术的引入,MVP 进化到了 MVVM,使得 View 完全无状态化。
Unidirectional 系列相较于 MV*,则采用了消息队列式的数据流驱动的架构,其中具有代表性的 Redux 采用了统一的状态管理,带来了状态的有序性和可回溯性。
MV* 系列在 iOS、 Android 生态圈中已得到成熟广泛的应用,而在 React 技术栈的 Web 前端领域, Redux 是最主流的数据管理方案。
不同平台选择不同,这其中有框架 API 设计的原因,有编程语言的原因,以及面对的业务逻辑复杂度不同。React Native 是 React 和 Native 的混合体,原有的 Native 框架 API 被映射成 React Component 生命周期,编程语言也发生了变化,不变的是业务场景和逻辑复杂度。
Redux 曾是我们大型 RN 项目的标配,不过实践结果表明, Redux 的一些固有设计并不能很好的应对复杂的应用场景。因此,我们选择了相较于 MV*系列,又对 Presenter/Controller 做了进一步拆分的 Clean Architecture。
二、Clean Architecture
Clean Architecture (附录 1)是 Uncle Bob 在 2012 年提出的用于构建可扩展、可测试软件系统的概要原则。这些架构产生的系统特点是:
框架无关性 - 框架只是一个工具,系统不与框架绑定
可被测试 - 业务逻辑与 UI、数据库等隔离,方便单元测试
UI 无关性 - 不需要修改系统的其它部分,就可以变更 UI,如将 React 替换为 Vue
数据库无关性 - 业务逻辑与数据库之间需要进行解耦
外部机构(agency)无关性 - 系统的业务逻辑,不需要知道其它外部接口,诸如安全、调度、代理等
基于以上原则的系统架构如下图所示,又称洋葱图。
从外到内,分为四层:
Frameworks & Drivers - 由框架和工具组成,比如各种前端框架,数据库访问工具等。
Interface Adapters - 作用是转换数据,连接内层与外层
Application Business Rules - 将多个业务实体封装为高级具体的业务用例
Enterprise Business Rules - 单个业务实体,可以是具有方法的对象,也可以是一组数据结构和函数
不同层代表软件系统中不同领域,外层是机制(mechanisms),内层是策略(policies)。
层与层之间遵循一个依赖关系原则:外层指向内层,机制指向策略。内层中的任何东西都不能知道外层中的某些东西。特别是外层中声明的内容的名称不得被内层中的代码提及,包括功能、类、变量或任何其他命名的软件实体。出于同样的原因,外层中使用的数据格式不应该被内层使用,特别是当这些格式是由外层中的框架生成时。外圈中的任何东西不应该影响内圈。
2.1 业务场景
App 国际机票查询预订流程中,列表页负责展示符合用户搜索条件的航班列表,并将用户带入中间页(舱位选择),其业务场景有以下特点:
代码量庞大 - 逻辑层 70000 行以上
依赖服务多 - 依赖 11 个服务
交互复杂 - 筛选、排序、切换日期、低价订阅和查看浮层等
展示信息多 - 航班信息、通知公告,推荐航班等
页面结构变化 - 单程、往返、多程页面结构不同;不同 ABTesting 页面结构不同
2.2 应用结构
如下图,项目最外层分为公共库和业务两部分。
公共库 - 封装了全局可用的公共代码,如与 Native 通信,发起网络请求和其他通用工具类
业务部分 - 具体的业务逻辑,由多个同构的业务模块嵌套组合而成,分形结构
业务部分由多个 Clean Architecture 模块组成,最外层模块处理页面路由和页面初始化数据,低价日历、列表展示和筛选作为子模块嵌套其中。每个模块的内部结构相同,并且可以方便的成为另一个模块的子模块或父模块。
2.3 模块结构
模块内部遵循 Clean Architecture 原则,分为四层:
ViewModel & StatelessView - React 框架相关代码,只负责界面展示,样式,动画和传递交互事件
Presenter - 连接 ViewModel 和 Interactor,连接模块内部和外部,不存在业务逻辑
Interactor - 持有多个 Model,将它们封装成高级的业务逻辑,供 Presenter 调用
Model - 独立的业务逻辑实体,提供方法给 Interactor 调用
2.4 代码实现
2017 下半年,我们在 React Native 实践初期,就决定全面使用 TypeScript,因为我们期望该技术栈未来能够可靠地支撑大型复杂项目工程。实践证明,Typescript 不负众望,在 2019 年变成了前端技术栈必备技能。
Typescript 补齐了 JavaScript 在数据类型方面的短板,这对大型项目的持续维护和稳定交付非常重要。
TS 类型系统描述了数据结构、function 的入参和返回值的类型和 class 对外暴露的方法,面向接口编程变得可能,我们编码时不再通过阅读代码了解上下文,而是面向接口实现逻辑,消灭 TS error 就好。
TS 对 OOP 友好,对于部分场景,继承和多态是最优解,比如多态的单程、往返、多程列表页。
同时,IDE 的支持带来了方便的代码智能提示和跳转,提升了开发效率。
在 TS 加持下,一个标准的模块由以下类和接口组成:
ModuleBuilder.tsx
模块的入口,持有父模块传入的初始化参数,通过重写 buildInteractor、buildPresenter、buildViewModel 方法生成 Interactor、presenter、viewModel 实例。
IViewModel.ts (Interface)
viewModel 层契约,以接口的形式描述 viewModel 层对 presenter 层暴露的方法,这些方法通常为更新某个 state。
ViewModel.tsx
viewModel 层具体实现, 持有类型为 IPresenter 的 presenter 实例和多个无状态子组件。UI 交互的响应指向 presenter 暴露的方法,使用 state 持有界面数据,并以 props 的方式下发给无状态子组件。
StatelessView.tsx
没有业务逻辑,没有 state,无脑展示 viewModel 下发的 props。
IPresenter.ts (Interface)
presenter 层契约,描述暴露给 viewModel 层的方法,通常为响应 UI 交互逻辑。
Presenter
presenter 层具体实现,以接口的形式持有 viewModel 和 interactor 对象,关联业务逻辑和界面展示逻辑。持有 eventBus 和 apiBus 对象,用于模块间通信。拥有 onViewModelAttach 和 onViewModelDestroy 生命周期,对应 viewModel 的创建和销毁。
IInteractor.ts (Interface)
interactor 层契约,描述暴露给 presenter 层的方法,这些方法表示具体的业务逻辑。
Interactor.ts
interactor 层具体实现,持有多个 model 对象,将它们封装为高级的业务用例供 presenter 调用。当只有一个 model 时,interactor 可以不存在,而用唯一的 model 替代。
Model.ts
相对独立、内聚的业务实体,暴露方法供 interactor 调用。
2.5 数据流
模块内部数据流、模块与外部通信关系如下:
builder Init
持有父组件通过 props 传入的模块初始化参数,在生成各层实例时传入对应的构造函数。
viewModel -> statelessView
当 viewModel 的 state 被更新时,新的数据通过 props 传递到子组件。
viewModel -> presenter
当 viewModel 层监听到交互时,调用 presenter 方法。
presenter -> viewModel
当界面需要刷新时,viewModel 的方法被 presenter 调用。
presenter -> interactor
当触发某个业务场景时,interactor 的方法被 presenter 调用。
interactor -> model
当 presenter 调用 interactor 时,model 的方法被 interactor 调用。
presenter <-> api bus、event bus
当模块需要对外暴露 api 和发送事件时,api bus 和 event bus 被 presenter 方法调用;当外界需要调用 api 和广播事件时,presenter 的方法被调用。
2.6 具体案例
下面以筛选模块为案例,分析模块内部结构设计和数据流向。
筛选模块顶部为三个独立的筛选项;中部左侧为筛选大类栏,中部右侧为已选中大类对应的筛选项列表;底部可展开查看已选筛选项,以及符合当前筛选条件的航班数。
当用户选择中筛选项,如图中选中“中国国航”,会产生四处界面的改变:
筛选大类“航空公司” 左侧出现小红点;
筛选项“中国国航”被选中;
底部查看已选按钮从“无已选”变为“筛选项(1)”
底部发起筛选按钮文案从“查看 54 个结果”变为“查看 3 个结果”
这个案例很好地证明了:界面元素在布局关系上的亲密度,与界面状态逻辑的关联性并不成正比。
为了让界面逻辑和业务逻辑都能得到合理的表达,参照 Clean Architecture 原则,模块内部划分为四层。
ViewModel 层由多个 React Component 组合嵌套而成,这些勾选框,侧边栏,筛选项列表,按钮等界面元素按照如你所见的布局关系被 JSX 声明式表达为一棵组件树,所见即所得。
Model 层则按照业务逻辑相关性拆分封装为多个业务逻辑高内聚的类:AirlineModel 负责航司筛选逻辑,TimeModel 负责时间筛选逻辑…
Interactor 层是对 Model 层的高级封装,多个 Model 之间存在关联性逻辑包含在这层,例如“中转城市”与“仅看直飞”选项的互斥关系。
Presenter 层将界面层和逻辑层联系起来,同时也负责筛选模块内部与外界的交流,例如点击“查看 XX 个结果”按钮,就是在 P 层发出 Event,使得监听该事件的模块做出相应。
2.7 易用性
严格分层带来的副作用是要写不少模板代码。为了减少重复模板代码的编写和统一模块结构,我们提供了标准的模板代码。在开发过程中,只需要在模板代码基础上添加业务代码即可,无额外工作量。模板代码目录如下。
为了提高模块编码的易用性,我们提供了各层的基类实现。各层派生于以下基类:
JetModuleBuilder.tsx
JetViewModel.tsx
JetPresenter.ts
JetInteractor.ts
三、Why not use React Component
为什么不采用 React 的组件化设计,将状态逻辑放到 Component 内部?
回顾 Thinking in react (附录 2): 模块由多个 Component 组成,state 放置在负责展示他们的 Component 中。当业务场景变得复杂后,会出现这些问题:
在组件之间复用状态逻辑变得困难 - Component 的层次结构,对布局和界面展示友好,对业务逻辑不友好。业务上不相关的 state 组合在一个 Component 中,破坏业务逻辑的内聚,导致业务代码难以测试、复用和维护。
混乱的 componentWillReceiveProps - React 的数据流自上而下,当业务逻辑同时依赖 props 和 state 时,必须在 componentWillReceiveProps 中判断是否对应的 props 被改变。
针对以上问题,React 提供了解决方案:状态提升、高阶组件和 Render Props。
参照此思路,多个逻辑关联强的 Component 的 state,被提升到一个 Container 中统一管理,其余 Component 变成了 Stateless Component ,只负责界面展示。但是实践中遇到新问题:
复杂组件变得难以理解。随着组件复杂度提高,生命周期中被逻辑不相关的副作用充斥,这很容易产生缺陷。
四、Why not use React Hook
React Conf 2018 会议上,React 的开发者指出 Class Component 存在的 3 个问题:
Wrapper hell - 现有解决组件间状态逻辑复用的方案会破坏项目的组织结构,使项目变得难理解,抽象层组件会形成“嵌套地狱”。
Huge components - 充斥各种逻辑的复杂组件难以理解。
Confusing classes - JS 对 Class 的支持不好,冗余代码多。
并认为这些问题的根因是:
React doesn’t provide a stateful primitive simpler than a class component.
最终给出的解决方案:Hook。
为了复用组件间状态逻辑,可以将逻辑封装为一个 Hook,供其他组件使用。
为了 Class Component 的生命周期方法不被不相关的状态逻辑和副作用充斥,则换做在 Function Component 重复使用 Effect Hook ,将这些逻辑进行分类。
同时,相较于在 Class 要写类似 bind 的代码,Function Component 可以少写很多代码。
诚然,Hook 的出现,能帮助开发者更好的管理 Component 的 state 和 state logic,但是当面对复杂业务场景时,仍然需要考虑几个问题。
React 只是构建用户界面的框架。
组件树的结构利于描述布局逻辑,但对于业务逻辑不够友好。
在完成从 Native 迁移 React Native 技术栈之后,后续如果需要移植到小程序或 Flutter 如何成本最低?
Hook 并不能很好的解决这些问题,而 Clean Architecture 则是参考答案。如果说 Hook 的出现,是为了让开发者更方便地把 state 放入 Component ,那么 Clean Architecture 则是让开发者不要把 state 放入 Component 中。
五、Why not use Redux
同样能做到和业务逻辑和界面展示解耦,为什么不使用 Redux ?
作为 Unidirectional Architecture 类架构的经典,Redux 有其独特的优势:单向数据流和状态可预测。对于逻辑复杂度中等以下的 Web 网站和 App 工程,Redux 可以很好地提升开发体验。但是针对 App 国际机票列表页这样比较复杂(至少我们认为)的业务场景,它略显不足:
单一数据源(Store)变大后维护困难
单例 Store 在复杂业务场景下会变得庞大,所有全局状态包含其中,所有 Reducer 都拥有修改权限。当我们想修改或删除一个这样的 state 时,不得不把所有的 Reducer 和 mapStateToProps 代码阅读一遍,以确保改动不会影响到其他逻辑。
Action 和 Reducer 维度的职责划分方式容易导致低内聚。
Redux 项目中,通常会将所有 Action 放入一个文件,所有 Reducer 放入另一个文件。这样的职责划分无法将业务关联紧密的逻辑封装起来,导致每次修改都要小心翼翼。
Action 使用字符串区分,留下隐患。
新建 Action 时,需要人工确认避免用于区分 Type 的字符串冲突。
无法独立出子模块。
所有组件都依赖集中的单例 Store ,当需要将组件改造成为一个独立模块,复用于其他项目时,修改工作量较大。
六、总结
App 客户端技术栈从原生快速迁移到 React Native 之类的混合技术方案, 平台 API 变了,编程语言变了,但不变的是业务复杂性。
为了摆脱基于界面元素在布局关系上编写状态逻辑,我们放弃 Component 和 Hook 方案。为了前端模块化和整体分形的项目结构,我们放弃 Redux 方案。
Clean Architecture 不仅带来了逻辑与界面分离和统一的模块结构,还降低了单元测试的难度,减少了前端技术栈迁移的成本,同时加快了排查问题的速度,方便多团队间代码协作。
目前新架构洋葱版国际机票列表页已经全量上线运行一段时间,效果良好:
整个项目由 26 个标准模块组成
Bug 总数相比旧版减少 71.3%
UI 自动化测试用例覆盖率达到 86%
研发效率相比旧版提升 48.5%
附录
1、Clean Architecture
https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
2、Thinking in react
作者介绍:
任跃华,携程机票前台软件工程师,从事机票 android、react 和 react native 技术栈相关研发工作。
本文转载自公众号携程技术中心(ID:ctriptech)。
原文链接:
评论