## 饿了么商家版 iOS 端订单模块的重构之路
文/樊荣海
饿了么商户 SaaS 系统,又称 Napos,意为 Not only a Pos。该系统是国内最大的 O2O 本地生活平台商家 B 端,连接着亿万用户、百万餐厅和百万骑手。作为这个系统最直接面向商户的操作软件——饿了么商家版 App,成为了一道关键路径。根据用户习惯分析,商户在使用 App 时,停留在订单相关界面的时间比接近90%,所以保证订单模块的稳定性与用户体验是首要工作。
### 为什么要重构?
1)难以扩展
早期业务简单,系统采用的是传统 MVC 架构模式。随着业务不断扩张,原有架构难以扩展,Controller 层的代码呈现爆炸式的增长,甚至出现 Coding 阶段只有作者可以读懂,两个月之后只有上帝才能读懂的 God Code(上帝代码)。
2)代码耦合
早期界面较少,开发者可以将一些操作直接写在了相应的Controller 里,导致一些功能无法剥离复用。进而形成严重的代码耦合,开发效率低且容易造成逻辑错乱。
3)界面卡顿
订单数据结构复杂,需要展示用户、菜品、价格、配送等等。单张卡片内的控件达150多个,且视图层级较深,遇到了 AutoLayout 布局渲染的瓶颈,当界面快速滚动时,卡顿现象严重。
### 重构的目标
由于以上问题,项目中老版本的架构已经积重难返。我们不得不重新思考如何构建才能满足未来两至三年饿了么业务的高速发展;如何通过技术手段提高商户体验;如何解放并提高现有生产力;如何加速新伙伴的快速融入问题。于是,低耦合、高内聚成为首选,易扩展、高可用成为重中之重,页面流畅模块且高度复用成为当务之急。
### 重构之路
#### 业务架构优化
首先,需要将早期项目的订单业务全面梳理(见图1),可以看到当时只有为数不多的界面和操作,因此就出现了图2中的设计。
图1 早期的订单业务流程
图2 早期的订单设计
其中,Manager 负责网络请求,不同的功能模块对应不同的 Controller,并且 Controller 与相关的操作形成绑定关系。这样的设计看上去简单明了,但随着业务的不断扩展,Controller 越写越臃肿,模块间耦合加重,代码也难以复用。
重构的思路是尽可能减少 Controller 的工作,创建多个模块管理不同的事务;界面间使用同一个数据源,保证订单的即时性。借助 Android 流行的 DataBinding 技术,我们提出了一个全新的业务架构(如图3)。
图3 新的订单业务架构设计
首先介绍下其中最核心的模块:订单池(Order Pool,如图4)。由于同一张订单可能会出现在多个界面上,当某个操作引起一张订单的数据发生变化时,其他界面应该立即同步刷新。早期的实现方案是常见的发送全局 Noticification,所有界面监听某个 Noticification,用获得的新数据替换旧的,然后刷新界面。理论上讲,这样的方式是可以达到目的的,但其最大的弊病就在于替换数据的过程过于繁琐,因为某些界面的视图可能很复杂,以至于遍历所有数据的工作量超出了想象,曾经我们的小伙伴为了替换某一个旧数据,写了一百多行代码!
图4 订单池
而现在整个项目中,所有的订单数据都会保存在订单池,且相同 ID 的订单只会保存一份,当有新的订单插入时,订单池开辟一块新的空间来储存,如果是已经存在的 ID,则会把新数据的 Value 直接反射到旧的 Key 上(如图5)。界面不再自己创建新的实例,而是全部指向订单池中的对象。因为订单池在初始化时,储存的对象设定为弱引用类型,这样当某张订单没有被任何界面使用时,它的引用计数会变为零。订单池自动将此对象从其内部移除,从而减少了内存的压力,更不会出现无用数据累积的问题。
图5 订单池处理数据
那么界面之间是怎么实现数据同步的呢?在渲染 View
时,会给订单(Data)绑定一个 Observer(View 本身,见图6),一旦订单池内的某个订单属性发生了变化,与之绑定的 Observer 能立即通过 KVO 得到消息,然后抛出事件,Controller 执行回调代码刷新界面(如图7)。
图6 Data 与 View 绑定
图7 订单页的加载代码
为了解决代码耦合问题,我们在订单池的基础上采用了“多管理者模式”方案。比如数据管理者(ActionManager,见图8)负责发起订单的操作;数据管理者(DataManager)负责处理订单的数据,它们各自维护自己的业务,互不影响。Controller 不再参与数据的任何操作,只需要在收到数据变化的消息后执行刷新,管理者也不需要调用者去处理请求的结果,而是直接发送给订单池进行过滤,这样一来既保持了界面的数据同步,又达到了各业务模块分离的效果。重构之后,订单查询页上千行的代码简化到只有三百行,繁琐的视图所渲染的实现也不过寥寥数笔。
图8 ActionManager 负责所有操作的发起
#### 界面渲染优化
订单卡片最初是用 CoreGraphics 手动画上去的,但众所周知,CoreGraphics 框架的 API 非常不友好,加上其代码布局复杂,更是加深了开发和 Debug 的难度,哪怕是一条分割线的布局错误都要花费很多时间来解决。后来我们用 Autolayout 尝试缓存卡片的布局,经过实验测得,AuotLayout 渲染一张订单的时间是0.02秒,不过因为它需要在主线程上同步计算,所以在一次性拉取大量数据后,滚动界面会出现短暂性的卡死现象。对这两种布局的劣势进行分析后,我们决定试着引入 Facebook 的 AsyncDisplayKit 框架。
图9 AsyncDisplayKit 异步离屏加载机制
AsyncDisplayKit 是一款极为优秀的 UI 渲染框架,其中最值得令人称赞的就是它的异步预加载机制(见图9)。它会根据当前屏幕在列表的位置,异步加载上下两屏的内容,并且不会影响到主线程的事件,从而保证了界面的流畅性和高响应性。AsyncDisplayKit 的类 FlexBox 布局比 Autolayout 更为便捷、优雅,大大减少了开发界面的时间。关于这个框架的介绍网上有很多,这里就不再一一赘述了。最终在订单视图全部替换为 AsyncDisplayKit 后,界面流畅度基本稳定在50-60FPS,与 AutoLayout 布局的比较结果如图10所示。
图10 AsyncDisplayKit 与 AutoLayout 渲染的 FPS 比较
### 遇到的坑
重构过程中我们也遇到各种各样的问题。比如订单池的数据明明设了 Weak 却无法释放,最后发现在渲染的 Block 里发生了很隐秘的循环引用;以及 View 的 Dealloc 函数里没有及时移除监听,导致抛出异常闪退;还有 ASCellNode 的 ReloadRows,由于它的实现方式是先 DeleteRows 再 InsertRows,所以很容易出现多个数据变化后数据源与 IndexPaths 不一致的问题,因此我们使用了 BatchUpdate 并给数据源加上了同步锁。
### 结语
当旧架构无法满足快速迭代产生的新需求时,我们就应该停下脚步来思考怎样提高开发的效率,而不是在原有的问题上继续埋头挖坑 。对于新的技术,我们要敢于尝试探索,不能总停留着自己的舒适区。
为了追求完美的性能体验,重构工作中我们频繁使用了 Instrument 工具,特别是针对内存的使用、溢出,界面流畅度的监测,渲染的耗时,在进行了大量的观察比较后,选择了最优的方案。
脱离业务谈架构都是不负责的行为。在这次新的架构设计中,所有模块紧密围绕订单的视图、操作、数据进行分工,去除了之前冗余的代码,降低了模块间的耦合度,做到了高复用、易扩展,为未来业务的迭代做好了底层铺垫。再结合 AsyncDisplayKit 高性能渲染的优势,界面流畅度也得到了显著的提升,从而为我们的商户打造了极致的用户体验。
注:文中所有代码截图均为对外演示用的精简版本,并不代表当前项目中的实际代码。