Airbnb迁移到Swift 3的实践

Airbnb公司自Swift语言诞生起就一直坚持加以使用。在这一过程中,我们通过亲身经历体会到这款现代化、安全且由社区驱动的新兴语言带来的各类助益。

直到最近,我们的代码库中有很大一部分由Swift 2编写而成。我们刚刚完成了面向Swift 3的迁移工作,刚好在新版本的Xcode放弃支持Swift 2之时。

我们希望与技术社区共享我们在迁移过程中积累的经验与心得、Swift 3为我们应用带来的提升以及期间值得一谈的技术性结论。

“无中断式开发”方法

我们有数十套模块及部分第三方库是由Swift编写而成,其中包括数以千计的文件与成千上万代码行。如果你认为如此规模的Swift代码库仍不足以构成挑战,那么这里要给大家提个醒——Swift 2与Swift 3的模块之间无法相互导入,这进一步提高了迁移过程的复杂性。即使是正确的Swift 3代码导入Swift 2库后亦无法编译通过。这种不兼容性导致我们很难以并行方式实现代码转换。

为了确保对代码进行逐步转换与验证,我们开始创建一套依赖性图表,其中以拓扑方式对我们的36套Swift模块进行了排序。我们的具体升级规划如下所示:

  1. 将CocoaPods升级至1.1.0(以支持必要的pod升级);
  2. 第三方pods升级至Swift 3版本;
  3. 按照拓扑顺序对我们的自有模块进行转换。

通过与其它已经完成此类迁移工作的企业进行沟通,我们意识到大多数项目需要在迁移时暂时冻结开发任务。我们希望尽一切可能避免对代码库进行冻结,即使这意味着会给迁移工作增添种种复杂性因素。由于转换工作本身很难以并行方式进行,因此所有已有的解决方案都存在效率低下的问题。另外,因为很难估计整个转换所需要的具体时间周期,所以我们希望能确保在迁移过程中继续发布App新版本。

整项迁移工作由三名工作人员负责。其中两位专注于代码转移,第三位则专注于协调工作内容、与团队沟通以及进行基准审查。

包括准备工作在内,我们设定的项目时间表如下所示:

  • 1周:调查并筹备(1人负责);
  • 2.5周:转换(2人负责),与主团队进行转换影响通报及交流(1人负责);
  • 2周:QA与bug修复(QA团队与对应的iOS功能负责人);

Swift 3的影响

尽管我们对于Swift 3带来的诸多全新语言特性相当兴奋,但我们亦希望确切了解此次更新会给我们的最终用户以及整体开发者体验带来怎样的影响。我们密切关注Swift 3对发布IPA大小及调试build时间的影响,因为这一切是我们在使用Swift过程中的两大痛点。遗憾的是,在通过多种不同的优化设置实验之后,我们发现Swift在这两方面的表现仍然差强人意。

发布IPA的大小

在迁移至Swift 3后,我们发现所发布的IPA出现了2.2 MB体积增量。通过初步发掘,我们发现这几乎完全是由于Swift库自身大小的增加而导致(我们自己的二进制文件大小几乎没有变化)。以下为几项未压缩二进制文件未经压缩的体积增加实例:

  • libswiftFoundation.dylib: 增长233.40% (3.8 MB)
  • libswiftCore.dylib: 增长11.76% (1.5 MB)
  • libswiftDispatch.dylib: 增长344.61% (0.8 MB)

考虑到Foundation等Swift 3库得到的显著增强,这种变化也完全能够理解。另外,当稳定版Swift ABI发布时,相信这些应用程序将不再需要因上述强化而遭遇体积增长的问题。

测试版Build时间

在迁移完成之后,我们的测试版build时间延长了4.6%,即在原本6分钟的基础上增加了16秒。

我们尝试比较了Swift 2与Swift 3之间的各函数编译时长,但却无法得出具体结论——因为二者间的格式存在很大差异。不过我们确实找到了一条函数,其编译时长在迁移之后激增至12秒。幸运的是,我们通过调整将其编译时间还原到了正常水平,但这也证明了检查转换代码的重要意义。Build Time Analyzer for Xcode等工具能够在这方面帮上大忙,当然大家也可以设置合适的Swift编译器标记并解析build结果日志以达到类似的效果。

运行时问题

遗憾的是,虽然代码已经在Swift 3中全部编译完成,但迁移工作仍未彻底结束。Xcode代码转换工具并不能保证实现完全相同的运行时行为。另外,正如我们在后面所提到,代码转换仍然需要人工介入并且里面有一些坑。这意味着我们恐怕还需要通过回归测试将其解决。由于我们的单元测试覆盖率不足以提供充足的信心,因此我们不得不花费额外的QA周期以审查新近迁移完成的应用。

经过第一轮QA测试,新近迁移完成的应用暴露出数十项显著问题。其中大部分问题在三人迁移团队的处理下很快得到了解决(数小时之内),具体涉及的质量应用将在后文中进行具体讨论。在最初的调整之后,剩下的是一些重要的回归测试工作,iOS团队最多留下15项潜在的问题——其中3项会引发崩溃,意味着我们需要在应用下个版本发布前进行调查。

代码转换流程

我们首先在master创建了一个新的swift-3分支。如前文所述,我们对各个模块中的代码进行逐一转换,首先是主干模块、而后逐步推进至依赖性树结构。只要有可能,我们就会尝试以并行方式进行不同模块的转换。如果不行,我们会一同讨论该如何处理以尽可能避免冲突状况。

对于各个模块,其转换流程基本如下:

  1. 在swift-3分支下创建一个新的分支。
  2. 在该模块上运行Xcode代码转换工具。
  3. 提交并推送变更。
  4. Build。
  5. 手动修复一部分Build错误。
  6. 提交并推送变更。
  7. Rebuild。
  8. 重复前三个步骤直到完成。

在手动进行代码更新时,我们一直秉持着“进行最直观的代码转换”这一理念。这意味着我们并不需要在转换过程中改进代码安全性。之所以选择这种思路,主要出于两个理由。其一,由于该团队以往一直利用Swift 2进行开发,因此这个过程实际上是在与时间赛跑,意味着并没有多余的精力进行质量调整。其二,我们希望尽可能减少新增的回归测试。

幸运的是,我们的项目在推进一段时间后即遇到了法定假期,这意味着我们能够腾出几天时间在master上对swift-3进行基础重建而不会导致进度延后。在进行基础重建时,我们利用git rebase -Xours master以保证尽可能不影响swift-3,同时解决master中的各类冲突。

当swift-3与master进度对接后,我们意识到需要大约一天时间整理现有问题,而后才能放心地对二者加以合并。不过考虑到iOS团队的庞大规模,master实际上一直在不断变化。因此为了完整Swift 3迁移,我们强烈建议整个iOS团队(除去参与迁移工作的成员)安心享受周末,而不要再对代码进行任何改动。

需要注意的问题

Objective-C中的Block参数

作为一大常见且无法在Xcode内得到自动修复的问题,我们发现Objective-C与Swift无法实现对block参数的顺利桥接。我们首先来看以下Objective-C标题头内的这条方法声明:

+ (void)fetchReviewWithID:(NSString *)reviewId
            completion:(void (^)(AIRReview *review))completionBlock

而在Swift 2.3中,生成的接口如下所示:

public class func fetchReviewWithID(
  reviewId: String!,
  completion completionBlock: ((AIRReview!) -> Void)!)

在Swift 3中,生成的接口则为:

open class func fetch(
  withID reviewId: String!,
  completion completionBlock: ((AIRReview?) -> Swift.Void)!)

很多内容都出现了变化,不过其中最重要的是completionBlock中的参数由隐式解析可选项变成了可选项。这可能破坏其在各blocks中的使用方式。

我们决定以最为直观的方式将其翻译为Swift 3(而不触及Objective-C代码),即在该block的开头声明一条变量,其拥有与该参数相同的名称但为隐式解析状态:

fetch(
  withID: reviewId,
  completion: { (review) in
    let review: AIRReview! = review
    // ...
  }
)

如此一来,相较于在使用时对该参数进行实际解析,我们现在至少能够确保其不会破坏block内其它位置的语义。在以上示例中,if let someReview = review { /* … */ }review ?? anotherReview等后续声明将继续按预期方式工作。

隐式解析Optional分配中的类型推断

另一大常见问题在于,我们需要设定Swift 3将变量类型推断为已分配的隐式解析Optional:

func doSomething() -> Int! {
  return 5
}
var result = doSomething()

在Swift 2.3中,result会被推断为类型Int!。在Swift 3中,其则会被推断为类型Int?

出于block参数概述的原因,最简单的解决办法就是将变量声明为隐式解析Optional类型:

var result: Int! = doSomething()

这一特定问题的出现频率要比预期更高,因为桥接后的Objective-C初始化工具会返回隐式解析Optional类型。

个别函数的编译时间激增

在我们的代码转换工作当中,编译器有时候会卡住几分钟。

我们的项目中存在一些 需要复杂类型推测的函数。在正常情况下,其编译时耗不会太长。但一旦其中存在编译错误,则可能令编译过程陷入混乱。

当我们的进度因为这类问题而受阻时,我们采用Build Time Analyzer for Xcode协助发现瓶颈所在。在此之后,我们开始专注于那些会给代码转换周期造成阻塞的函数,加以调整、进行rebuild再转换更多代码。

可选协议方法实现险些出现问题

在Swift 3转换过程中,可选协议方法往往很容易被大家所忽略。

下面来看UICollectionViewDataSource上的此方法:

func collectionView(
  _ collectionView: UICollectionView, 
  viewForSupplementaryElementOfKind kind: String, 
  at indexPath: IndexPath) -> UICollectionReusableView

假设您的类实现UICollectionViewDataSource并声明以下方法:

func collectionView(
  collectionView: UICollectionView, 
  viewForSupplementaryElementOfKind kind: String, 
  atIndexPath indexPath: IndexPath) -> UICollectionReusableView

您能发现其中的差异吗?这并不轻松,但差异却的确存在。 这时您的类将只进行编译而不会对定义的签名进行更新,因为其属于一项可选协议方法。

幸运的是,编译器会给出警告信息以帮助大家发现此类问题——但并非全部问题。因此我们必须认真审查一切包含可选方法的类型实现协议——例如大部分UIKist委托以及数据源协议——并验证其正确性。搜索“func collectionView(collectionView:”等文本(请注意第一项参数标签,其明显属于Swift 2产物)能帮助大家在自己的代码库中发现此类问题。

涉及默认方法实现的协议

某些协议可能会通过协议扩展采用默认方法实现。如果某项协议的方法签名在Swift 2到Swift 3的迁移过程中发生了变更,则必须确保这一变更体现在全部相关位置。编译器本身不会理会协议扩展是否实现或者您的类型实现是否正确,但若二者出现问题则编译过程显然无法成功。

包含字符串原始值的枚举类型

在Swift 3中,枚举类型由lowerCamelCase的命名惯例进行指定。Xcode代码转换工具会自动对现有枚举进行适当变更。然而如果其中的原始值类型为String,则其会自动跳过对应枚举。这样的处理方式可以理解,因为开发者有可能会利用某个与枚举类型名称相匹配的String对其中一条枚举进行初始化。如果大家变更该枚举类型名称,则可能破坏其余位置的初始化过程。您可能认为能够通过手动小写某些枚举类型的方式“完成任务”,但这种作法只适用于不会破坏其它基于客串的初始化过程的前提之下。

第三方库API变更

与大多数应用类似,我们的应用也存在一定的第三方库依赖性。在迁移当中,我们需要更新这些利用Swift编写的库。这项工作看似简单,但却仍然需要高度关注:包括仔细阅读发行说明,特别是您所依赖的库已经经历了大版本升级(特别是其对应的语言进行了大版本升级)。通过这种方式,我们得以发现了不少易被编译器所忽略的API变更。

下一步工作

现在我们的master分支已经彻底转换为Swift 3,而且不再有任何利用Swift 2进行的开发工作。那么,这是否意味着迁移工作已经全面结束?

还不一定。正如之前所提到,在代码转换过程中,我们仅仅对Swift 2代码进行了直观的Swift 3转换,这意味着我们还未能充分发挥Swift 3在安全性及其它便利层面的增强优势。

作为一项需要持续推进的工作,我们将始终关注各类潜在的改进空间。

更高水平的细粒度控制

在默认情况下,Xcode代码转换工具会将访问控制修饰符private转换为fileprivate,而public则被转换为open。这代表着代码进行实现“字面”形式转换,保证代码的工作效果与原本相统一。

然而,这同时意味着开发者亦错过了考量新的private与public是否真的最适合用于实现预期效果的机会。下一步,我们将审查访问控制机制的字面转换结果,并思考如何利用Swift 3更为强大的表达能力提供更高水平的细粒度控制方案。

Swift 3方法命名

在对代码进行手动转换时(即当Xcode转换工具无法实现或者进行基础重建时),我们通常采取“字段”方式变更方法名称,确保调用机制能够继续正常起效。以下列Swift 2.3方法签名为例:

func incrementCounter(counter: Counter, atIndex index: Int)

为了更快更简便地完成面向Swift 3的代码编译转换,我们将其变更为:

func incrementCounter(_ counter: Counter, atIndex index: Int)

不过更具Swift 3风格的调整方式显然应该是:

func increment(_ counter: Counter, at index: Int)

接下来的工作是找到那些进行快速命名的实例,有针对性地更新方法签名以遵循Swift 3的命名约定。

更安全地运用隐式解析可选项

如前文所述,我们处理新型可选项(Swift 3中)Objective-C block参数的方式是直接为其分配隐式解析可选项变量,这意味着我们不需要在block之内进行大量代码更新。然而,更理想的处理方法应该是考虑该参数为nil的可能性。

解决warning

为了快速完成转换转换流程,我们直接忽略了大量编译器提出的警告信息——只要其内容不是非常关键。着眼于未来,我们必须回头审查这些问题以将警告数量控制在正常范围内。

总结

考虑到Airbnb公司很早就开始采用Swift语言,我们积累了大量遗留Swift代码。将其迁移至Swift 3的头号问题在于,我们很难弄清这项工作该如何进行或者会对应用程序产生怎样的影响。如果大家还没有着手进行这项面向Swift 3的迁移工作,希望我们通过实践积累到的上述经验能够帮助大家对未来的挑战拥有更为明确的认知。

原文: https://medium.com/airbnb-engineering/getting-to-swift-3-at-airbnb-79a257d2b656#.xzuxiseb0

相关推荐