作为创业公司,我们的资源有限,而我们的业务发展迅速。我们相信技术架构是为了业务服务的,架构要领先于业务发展。只有保证架构领先,才能提升团队开发效率和质量,进而满足业务不断发展的业务需求。

人人车 iOS 客户端从 2014 年年末启动到现在一年多的时间里,已经经历了 20 多次版本迭代,期间经过大大小小几个阶段的架构升级和改造,每一次版本迭代中都会包含技术驱动的项目,无论是小的代码重构,还是大的架构改造。连我们也很惊讶我们的代码量只有区区五万行。

现在,停下来回顾一下曾经走过的路、踩过的坑,将我们在 Storyboard、VIPER、JSONModel、Antenna、JSPatch、React Native 等多个方向的技术选型经历和经验拿出来与大家分享。

Section 1,Storyboard vs. 手写代码

当初准备做 iOS 客户端的时候,我们没有正式的 iOS 工程师,就我一个原本从事前端开发的工程师。就想着最大化利用 Apple 提供的基础设施,决定了基于 Storyboard 开发我们的第一个版本的应用。事实证明,基于 Storyboard 确实能直观、快速地开发出业务相对简单的应用。

但是,随着业务复杂度提升,当时 Storyboard 也显现出一些困扰我们的问题。一是 Xcode 性能问题,随着车辆详情页越来越复杂,每次打开 Storyboard 之后 Xcode 就出现卡顿问题,特别影响开发效率(PS,还真没见过 Xcode 这样脆弱的 IDE,每天恨不得 Crash 5,6 次)。二是 UI 元素复用问题,对于想复用的 UI 元素当时并没有特别好的复用办法,每次都要重新创建和设置。如果 PM 要求全局修改样式就比较痛苦了,只能一个个元素选中之后修改。三是约束冲突的时候不容易解决冲突,页面元素越复杂,只要出现约束冲突,一般情况下,Storyboard 建议你修复冲突的方式都是错的,你只能一个个元素排查,看是多了哪个约束还是少了哪个约束。四是常见的 Storyboard 文件冲突问题,不过当时只有一个人开发,此问题并不是特别严重。

促成转变的另一个因素是,业界对于 Storyboard 和手写代码之间孰优孰劣一直存在争议,唐巧写过一篇《iOS 开发中的争议》的文章来解构分析业界对于 Storyboard 的现实态度。这让我联想到 Web 前端对于 Dreamweaver 或者其他可视化工具的态度,相信没有一个资深的前端开发工程师是靠这样的工具来完成工作的。再者 Storyboard 能完成的任何工作,最终落实到代码层面,都是能通过手写代码来完成的。

最终,这些 Storyboard 存在的问题和不同领域的的借鉴促成了想手写代码的转变。

有了 Storyboard 使用经验,对 AutoLayout 就并不陌生了。手写代码最主要解决的问题就是约束和布局,Masonry 几乎是当时唯一的 AutoLayout 库,从 Storyboard 迁移到 Masonry,主要遇到的问题就是没有一个业界通用的创建对象、添加约束的最佳实践。因为 UIViewController 添加 subview 的地方可以在 loadViewviewDidLoad,添加 Constraints 的地方可以在 viewDidLoadupdateViewConstraints。既想保证正确性,也要求代码美观,发现对于这个问题,就找不到没有一个明确的最佳实践,大家讨论的基本都是生命周期问题,却对这个问题没有形成统一。最后,我们就定了一个方案,在 viewDidLoad 里添加 subviews,subview 均采用懒加载方式创建,在 updateViewConstaints 里结合 didSetupConstraints 这个 BOOL 变量来确保创建约束只执行一次,需要动态更新的约束也放在 updateViewContraints。现在看来可能并不是一个最理想的方案,但是,时至今日,对于这个问题仍然没有找到一个权威的意见。使用 AutoLayout 过程中,另一个遇到的问题是 frame 布局的混用。有时候不得不使用 frame 布局,例如,添加复杂的动画,还有 UITableView 的 headerView 和 footerView 设置。对于这样的问题,总结的一个经验是,对于一个 view 想使用 frame 布局,是可以对其 subviews 使用约束布局,但不要对 view 本身添加任何约束,在调用 addSubview: 方法将 view 添加到 superview 之后,要对其调用 setNeedsUpdateConstraintslayoutIfNeeded 方法,让 view 内部先完成布局,从而获取 view 的尺寸,再根据这个尺寸对 view 设置合适的 frame。

手写代码的优势显而易见,使得我们的代码更加可控,定制性和复用性更好,另外,也促使我们直接放弃 Xcode,转向 AppCode,获得更好的开发体验。另一方面,手写代码势必伴随着代码量的增长,我们也采用了一些工具和方法来简化重复性的代码输入工作,例如,Xcode 的 DCLazyInstantiate 插件(AppCode 自带此功能),以及研究 Masonry 提供的创建约束的简化语法,如 make.edges.equalTo(self) 这样的写法。

业务发展的技术路径可能有很多种,应选用一种当下最适合你的路径,尤其是团队成员并不是特别有经验的情况下。最适合的路径可能并不是最优的路径,但却能最快的满足业务发展,在创业公司里容许你走弯路,同样要求不断审视曾经走过路,关注那些遗留下来的技术债务。

Section 2,MVC vs. VIPER

MVC 架构是 Apple 推荐的 iOS 应用架构,但相信所有 iOS 开发者都对 MVC 架构存在的问题了解一二。MVC 最大的问题就是容易导致 Controller 膨胀,演变成 Massive Controller。直接的解决方案就是拆分 Controller,将 DataSource 和 Delegate 等拆分出单独的类。

而我们在详情页模块刚开始膨胀的时候注意到了 VIPER 架构。经过一番调研之后发现,VIPER 在设计理念上比较符合功能复杂、并且不断迭代的应用。VIPER 遵循关注点分离的设计原则,它将数据请求、数据处理、页面跳转、业务逻辑和 UI 展现都一一分离。每一个职责都有对应的类实现,类的数量和相互间引用关系比起 Massive Controller 会相对复杂一些,好在有 vipergen 这样的工具来自动创建 VIPER 模块。

在引入 VIPER 架构的过程中,我们采用逐步按需升级的原则。没有一次性将所有的模块都按照 VIPER 模块重构,而是对重要的、业务复杂的模块按照 VIPER 架构重构。这样既限制了重构范围,方便评估试验结果,也降低了重构风险,减少对其他模块的影响。

我们在按照 VIPER 架构重构已有的模块时,遇到的最大的挑战是业务代码和 UI 代码分离,两者之间的差别不好把握,尤其是代码耦合较紧的情况下。我们的经验是,将 UI 代码细化到对称的方法在 UserInterface,例如 showXXXhideXXX,或者 reloadXXX:,而调用逻辑条件都抽象成方法在 Presenter,Presenter 负责一切 UI 交互逻辑,更像是粘合剂代码,但是职责更加清晰,不处理数据,不处理 UI,只负责数据请求,逻辑判断和 UI 方法调用。

另外,在 Interactor 数据处理层面,将 Model 处理成 ViewModel,ViewModel 的数据类型和内容都跟 UI 绑定,这样解耦了数据接口层和 UI 层的耦合关系。而 Interactor 负责处理网络数据、缓存数据、本地数据之间优先级关系,Presenter 业务层面不需要关心数据来源。另外,Interactor 和 DataManager 都可以作为数据接口供其他模块复用。

另外,除了 UserInterface(即 UIViewController) 每次 push / pop 过程中重复创建之外,整个 VIPER 模块可以保持单例形式存在,这样也有助于提升页面初始化的性能。

采用 VIPER 架构的好处是代码结构清晰,类职责明确,降低代码阅读难度。如果不熟悉此模块的同学接手,只要结合业务逻辑,读懂 Presenter 代码,这样就容易上手。

Section 4, JSONModel + JSONValueTransformer 最佳实践

我们一直使用 JSONModel 作为 JSON 解析库,JSONModel 足够可靠和简单。直到遇到了一次因为 JSON 解析失败导致的线上崩溃事故之后,我们才觉悟到 JSONModel 原来也没有我们想的这么简单。

复盘一下当时的事故。我们的车辆详情 Model 里有一个属性 sold,标识是否已售,当时属性定义的时候采用了 BOOL 类型,按照接口约定服务端回传 "true" 或者 "false" 字符串,我们只能傻乎乎地通过 isEqualToString: 方法给属性赋值,突然某一天服务端觉得这样接口设计不合理,将回传改成了 true 或者 false 布尔值,这样在方法调用的时候就出现了 unrecognized selector sent to instance 错误,造成点击进入已售车辆详情页的用户都会遭遇应用崩溃。更不巧的是这一天检索服务错误地把某量已售车辆保留在了检索列表的第二条,这样一下子我们的崩溃率就开始飙升。

当时 CTO 就说,你得做数据兼容啊,怎么的也不能让应用崩溃啊。

心想,这锅有一半还真得背。

此事故之后,我们开始深入地研究了 JSONModel 源代码,才理解 JSONModel 的设计理念,发现了很多我们使用上的误区和一些之前没有注意到隐藏特性。

// it’s not a JSON data type, and there’s no transformer for it
// if property type is not supported - that’s a programmer mistake -> exception

JSONModel.m 文件里有这么一段注释,作者的意思是如果 JSON 数据类型无法解析,那肯定是程序员的错,我就任性地抛出异常让你知道。

此意就是,Model 属性类型的定义就是数据接口约定的文档固化,如果数据接口的变动没有同步到 Model 的变化,那出现任何不兼容的解析问题,就通过抛出异常的方式提醒你数据接口出现了问题。这样的设计理念,我觉得是服务端问题显现化,因为接口变化出现的问题能通过服务端快速修复。所以,在客户端将问题暴露出来可能是一种合适的选择,避免因为过度的数据兼容导致业务逻辑上出现问题。

但是,对应到我们的事故上,可能并不是一种友好的方式。我们也不想通过简单的 try / catch 来解决问题,我们还是遵循 JSONModel 的设计思路,按照推荐的方式来进行数据兼容,总结了以下几条最佳实践:

  • 属性类型选用 NS 类型,避免选用原始数据类型(Primitive Types)。原始数据类型在解析时直接调用的是 setValue:forKey: 方法,如果传入类型不一致的值,会抛出异常
  • 属性协议合理选用
    • 有条件地设置 <Optional>,尽量避免设置全部属性均为 Optional
    • 对自定义属性设置 <Ignore>,避免解析覆盖
    • 对索引属性设置 <Index>,保证值唯一,便于比较
    • 对解析耗时的 NSArray 或者 NSDictionary 类型属性设置 <convertsOnDemands>,延时解析
  • 设置统一的 KeyMapper,并结合 mapper:withExceptions: 方法添加例外条件
  • -initWithDictionary:error: 用于设置 <Ignore> 类型属性的初始值,或者设置属性的默认值,或者对经过解析之后的属性值进行值变换(避免类型变换,类型变换通过自定义 setter 方法完成)
  • -validate 用于检验经过解析之后的 Model 是否在业务逻辑上正常
  • 对于需要类型转换的属性,如果 JSONValueTransformer.h 里没有定义兼容转型方法,可以在自定义 Model 的 m 文件里添加自定义 setter 方法,格式如 setXXXWithYYY:( XXX 是属性名称,YYY 是 JSON 类型名)。如果需要从 Model 转回 JSON 的话,需要同时添加自定义 getter 方法,格式如 JSONObjectFromXXX:
  • 全局性类型兼容转型,通过扩展 JSONValueTransformer 类来实现,添加 XXXFromYYY: 方法( XXX 是 Model 类型名,YYY 是 JSON 类型名)
  • 空置或者解析失败处理。所有 NSObject 类型的属性如果设置了 Optional,其属性值都可能为 nil,允许直接判断 value == nil。经过 JSONModel 成功解析之后的属性值不可能是 NSNull 类型。如果 JSON 值是 null,则一定为 nil。
  • 如果是 NSDictionary 类型,则遍历 keys 和 values,将 value 作为协议类型执行解析,如同 NSArray 方式解析