对中国开发者最具吸引力的科技企业有哪些?快来为你 pick 的企业投票! 了解详情
写点什么

携程机票 React Native 整洁架构实践

2019 年 7 月 24 日

携程机票 React Native 整洁架构实践

前言

携程机票前台团队在使用 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

  • https://react.docschina.org/docs/thinking-in-react.html

  • 作者介绍

  • 任跃华,携程机票前台软件工程师,从事机票 android、react 和 react native 技术栈相关研发工作。

  • 本文转载自公众号携程技术中心(ID:ctriptech)

  • 原文链接

  • https://mp.weixin.qq.com/s?__biz=MjM5MDI3MjA5MQ==&mid=2697268665&idx=1&sn=4693d3e4e2af0f3d24324b26fd889bf6&chksm=8376f28db4017b9bf4b1cba43239ebb999cfea2478fdd3100a22733faa24ab3b9baa529ff6ea&scene=27#wechat_redirect


2019 年 7 月 24 日 08:009382

评论

发布
暂无评论
发现更多内容

2期架构师训练营 - 第一周学习总结

Vicente

极客大学架构师训练营

5.3 分布式缓存架构:一致性 hash 算法

orchid9

架构师训练营第五周学习笔记

一马行千里

极客大学架构师训练营

极客大学 - 架构师训练营第一期 - 第五周作业

Black Eyed Peter

极客大学架构师训练营

一致性hash

袭望

week-5-part2 学习总结

陈龙

架構師訓練營第 1 期 - 第 05 周總結

Panda

架構師訓練營第 1 期

5.1 分布式缓存架构:架构原理与注意事项

orchid9

第5周作业

paul

架构第5周总结

Geek_Gu

极客大学架构师训练营

作业一:食堂就餐卡系统设计

伊灵

SpringBoot整合原生OpenFegin的坑(非SpringCloud)

冰河

微服务 高并发 远程调用 springboot OpenFegin

5.2 分布式缓存架构:常见的缓存实现形式

orchid9

2期架构师训练营 - 食堂就餐卡系统设计

Vicente

极客大学架构师训练营

架构师训练营Week01总结

Calvin

架构一期第五周作业

Airs

第五周作业(作业一)

Geek_83908e

极客大学架构师训练营

5.5 负载均衡架构

orchid9

week-5-part1 java实现一致性 hash 算法

陈龙

第五周学习心得

熊桂平

极客大学架构师训练营

5.4 消息队列:如何避免系统故障传递?

orchid9

「架构师训练营」第 1 周作业 - 食堂就餐卡系统设计

xiaomao

Week 5 學習總結

Christy LAW

阿里云盘线下交流会

兔2🐰🍃

阿里云网盘 Teambition 线下体验

作业-2020年10月25日

芝麻酱

第五周作业 (作业二)

Geek_83908e

极客大学架构师训练营

第五周作业

熊桂平

极客大学架构师训练营

间隔重排序链表Reorder List,iOS架构RxSwift, VIPER,MVVM,MVP, 机器学习,SageMaker,John 易筋 ARTS 打卡 Week 23

John(易筋)

学习 ARTS 打卡计划 重新排列链表算法 iOS 架构RxSwift SageMaker

架构师训练营第 1 期第 5 周作业

du tiezheng

极客大学架构师训练营

Week 5 作业02

Croesus

架构图

猴子胖胖

架构

混合云之争的开端与终途

混合云之争的开端与终途

携程机票 React Native 整洁架构实践-InfoQ