查看原文
其他

淘宝工程渐进式拥抱 Swift

倾寒&皮拉夫大王 老司机技术 2022-09-06

前言

老司机技术周报与淘系技术联合主办了今年的淘系技术.T 沙龙杭州专场。本次沙龙邀请了 4 位国内嘉宾,特邀了 2 位国外嘉宾。倾寒受邀为大家分享【淘宝工程渐进式拥抱 Swift】,皮拉夫大王基于这次分享视频为大家整理此文,辛苦二位!阅读原文,获取 PPT!

讲师简介:倾寒(淘系技术-终端平台技术-无线开发专家),主要负责 iOS Native 架构,与 Swift 生态布道,在淘宝工程完成多项重构工作,推动淘宝 Swift 工程实践。

编辑简介:皮拉夫大王,摸鱼周报联合编辑,现就职于 58 同城主 App 团队。主要专研方向为二进制静态扫描及 App 性能优化。近期在做 Swift&OC 混编项目的无用代码静态扫描,感兴趣的同学可以在 GitHub 搜索 WBBlades 交流下。

正文

大家好,我是来自终端平台技术的倾寒。今天跟大家分享一下淘宝工程渐进式拥抱 Swift 的演进历程。作为一名 Swift 开发爱好者,我使用的第一门编程语言其实不是 Objective-C,而是 Swift。与国外的 Swift 流行度不同,国内的 Swift 的流行度处于非常缓慢的阶段。我们淘系技术部(主要是淘宝 iOS 工程团队)在 19 年年终完成了 Swift 的工程演进,达到上线的阶段。在这里我将跟大家分享下我们从 19 年到现在,都做了哪些事情。

本次分享主要包含 6 个部分:

  • 我们为什么要引入 Swift?
  • 如何打造我们的 Swift 基建?
  • 如何升级我们的 DevOps。
  • Swift 0~1 我们有了哪些进展。
  • 我们在 iOS 开发之外的领域做的 Swift 尝试。
  • 今后我们将在 Swift 的哪个方向做更加深入的探索。

淘宝其实关注 Swift 很多年,在这些年里我们对 Swift 到底有怎样的看法和定位,支撑着我们一直推进把 Swift 引入到工程中。淘宝是一个非常庞大的超级 App,我们也会把淘宝称为商业操作系统——“淘宝 OS”。我们将“淘宝 OS”架构抽象成下面这样的结构:

  • 我们的下层结构是厂商。厂商提供了操作系统,包括:iPad OS、iOS,还有 Android 。

  • Native 技术由厂商提供。这些技术有些是单平台的,有些是例如 C/C++ 这样跨平台的技术。

  • 再上层,是我们搭建近 10 年发展的横向架构。例如我们特有的路由、特有的 UIKit、启动器等等。

  • 再上层是我们的跨平台架构。包括之前很出名的 Weex、小程序架构以及我们内部的其他的跨端引擎。

  • 在横向架构和跨端架构之上,是我们的垂直业务。垂直业务也是有各自的垂直业务架构的。但是垂直业务架构领域相对比较特定,例如消息业务就有其特有的消息架构。

Swift 位于 Native 原生技术以及厂商绑定业务。上图右边是我们整理的 Swift 的优势,这些优势中包括先进性、包大小等。上述优势中有几个比较特殊,比如在 19 年之后苹果推出了自己的标准研发流程 SPM(Swift Package Manager),还有 iOS13 之后推出的一系列 Swift 特有的编程框架(SwiftUI、Combine、RealityKit、CoreML 等相关框架),这些编程框架会对我们的技术栈尤其是厂商原生技术栈有比较大的影响。

早在 2019 年我们就得到这样的一个结论:Swift 是苹果平台唯一官方支持语言, 引入 Swift 可以保持技术前瞻性、防止技术踏空防止技术断层等现象

那我们是如何得到上面的结论的呢?为此我们定义了2个维度:趋势生产力

生产力是比较主观的数据,比较难量化。为了验证生产力数据,我们重写了某些基础库的功能模块和业务模块。从数据中我们可以看到 Swift 实现的库代码量是大幅下降。业务库的下降幅度可能并没有那么多,原因是业务库在没有任何 Swift 支撑代码的情况下,带来了较多的胶水代码。

从 19 年的 TIOBE 语言流行度排行榜可以看到 Swift 的排名已经超过了 OC, Swift 的整个趋势也逐步趋向于 OC。OC 的语言流行度相反在逐渐下降。

我们对 19 年整个中/美地区 Top1000 的 App 使用相应的脚本进行了分析,可以看到美区使用 Swift 的 App 占比非常庞大,只有 20% 的 APP 不支持 Swift。而在国内,情况则恰恰相反,在 Top 1000 的 App 中,我们只有 20% App 使用了 Swift。如果进一步通过 CodeSize 分析,我们可以看出在使用 Swift 的 App 中,Swift 代码量占比也很少。

为了说明 Swift 混编应用的变化趋势,我特地截取了我的老朋友 思琦 的文章。从文章中可以看出,2020 年到 2021 年,国内的 Swift 的占比达到了 60% 之多。不得不说正是因为苹果的决策,推出了很多 Swift 特有的框架,才大大推进了 Swift 语言流行。

那我们真正拥抱 Swift 的原因是什么?我们可以从社区苹果2个维度寻找答案。

众所周知,Swift 在 19 年的时候达到了 ABI 稳定,在 5.1 的时候达到了模块稳定。从苹果文档可以看出,苹果在 WWDC 17 之后不再对 OC 进行 Sample Code 的展示。在 iOS13 之后,苹果推出了很多 Swift 特有的纯 Swift 框架,这些框架(例如 SwiftUI 和 Combine)可能没办法被简单地实现混编,即使实现混编也会因为破坏了原有的响应式编程模型而失去了原有的意义。另外从社区的维度来看,我们在 Stack overflow 提出的 OC 相关的疑难杂症基本上没有人解决。另外从项目维护的角度来看,Github 上的 OC 多年的开源库基本不再更新,例如我们常用的 AFN、SD 等基本已经不再演进。取而代之的是很多纯 Swift 框架,这些框架却保持非常高频的更新。

前面我们提到了 Swift 很多优势,我们需要将这些优势用起来才能体会到 Swift 的好处。而应用也是一个比较复杂的过程。

我们当时选择了一些业务试点,我们将项目抽象为2层,业务层与基础层。最终我们选择我们的灰度 SDK ——TestFlight 进行试点。

那为什么选择业务而不是选择基础库进行试点呢?主要有以下几点原因:

  • 业务不会有外部输出,不会对其他的模块造成影响。
  • 业务作为非常上层的模块,可以非常清楚地验证下层模块存在的问题。
  • 业务可以实现低风险上线。我们在项目中存在 2 套业务代码,一套新的一套旧的,可以通过 A/B 保证不出线上问题影响真正的用户。

相信No such module 'ObjcLib'这个报错大家应该非常熟悉了。尤其是使用很多年 Objecive-C 的开发者。接下来将给大家介绍下如何去解决No such module 'ObjcLib'问题,尤其是在一个很大的工程中如何去解决这个报错。

在实践过程中,我们碰到的问题并不多,抽象下来就 4 点:

  • 一个是我们的工程模板老旧;
  • 一个是我们内部的 DevOps 等工具链比较老旧;
  • 还有就是我们的二进制库不支持 Module;
  • 更进一步的问题就是我们没有支持语义化混编,支持语义化混编的话可以让我们更优雅地使用 Swift 的 API;

这些问题的解法很简单。解决方法分别是:

  • 升级模板;
  • 升级我们的工具链;
  • 支持 Module;
  • 支持语义化混编;

在此之前有必要介绍下开发产物的演进。之前我们的 Xcode 工程中源文件包括 .h、.m、.c、.cpp。一些大型工程,可能会有静态库工程、Cocoa touch static/dynamic framwork 工程,以及 19 年之后苹果推出的 xcframework 工程。这些工程对 Swift 来说都有一定的要求,那就是必须要支持 Clang Module 才能实现 Swift 混编。

那支持 Clang Module 需要做哪些事情呢?总结下来要做的事情主要包括:Define Module;格式化公开头文件;语义化适配(可选类型适配、指定构造器、逃逸闭包等)。解决方案分为2部分:

1、自动化脚本,格式化脚本可以帮我们快速处理成百上千个文件,保证我们的文件是符合苹果规范的。

2、另外一部分是需要人工确认的语义化适配,包括可选类型适配、指定构造器适配等。这部分内容看上去只是提升 API 实用性,但是如果不做这一步可能会对线上混编造成大量的问题。比如我们如果把可选类型写错,那么很容易在线上造成比较严重的 crash。所以语义化适配这部分工作是一定要做的。

接下来我们回顾下 19 年淘宝的状态。淘系是一个非常庞大的团队,包括数百名开发人员,有超过 500 个的 CocoaPods 库,有将近 1000 个的 .a 或 framework 的二进制结构。那这就引出了对我们来说最大的困局:业务不能停,人力也是不充裕的。而且我们存在很多开发者团队,涉及几十个 BU,团队结构极其复杂,推进 Clang Module 适配非常难。在当时看来,投入产出比是非常差的。那我们该如何解决这些困局呢?

我们的破局之道源自我们的初心。回顾下我们拥抱 Swift 主要是为了解决以下几个问题:

  • 避免技术踏空;
  • 提升 Native 的业务开发效率和稳定性;
  • 打造我们的生态,沉淀可复用技术。因为我们不希望这些重复的工作大家都经历一遍;
  • 探索新技术,保持我们的技术前瞻性;

对于我们淘系甚至阿里巴巴来说,以上内容最重要的是“营造 淘宝/集团 生态,沉淀可复用技术”,我们要让这部分工作量变的不再庞大。

所以我们的做法就是 ——大家一起玩,将 Swift 推进起来。

因此我们内部成立了一个虚拟的横向组织,这个横向组织包括很多对业务或基建的探索,共同打造 Swift 的混编环境。这其实就是将 Swift 的成本摊平,大家共享收益,走的是“农村包围城市”的路线。虽然当时看起来这是一个缓慢的过程,但正是因为这个虚拟团队的存在,我们才在19年顺利完成了 Swift 的演进。

适配 Clang Module 是一个比较细节的工作,具体内容大家可以参考 WWDC 15 年的一个 Session(Swift and Objective-C Interoperability session 401),这些 session 也会包含相应的示例代码。我们内部也有我们自己的输出文档,我们沉淀了将近上百篇文档。另外我们有很多同学参与适配工作。这些工作顺利完成之后才能完成我们整个基建的大升级。

接下来的第二部分主要分享如何进行 DevOps 升级。可能有些 APP 的组件化并没有那么庞大而复杂,甚至 DevOps 可能在很多公司中并不存在。但对我们来说 DevOps 升级却是一个比较严重的问题,我们必须解决这个问题才能保证 Swift 顺利的上线和落地。

这里需要提一下,要支持稳定的 Swift 开发工具有 2 件事情要做:

1、将 CocoaPods 升级到 1.5 之后。因为 CocoaPods1.5 以后才支持 Swift 的二进制静态库、支持 Swift5.0 版本、支持 modular_header 的引入;在 1.9 版本以后 CocoaPods 有了更大的进步,支持源码级别的静态库版本 framework 工程。

2、Xcode 升级到 11.2.1 主要是我们需要的 Swift 版本非常新,因为 Swift5.1 版本之后才支持模块稳定。这里有两个比较重要的内容需要支持,一个是 Module Stability(模块稳定),这涉及 Library Evolution 选项的开启,这个选项对我们的最终产物有着非常大的影响。

接下来介绍下这些产物的细节。在此之前先要抛出 2 个概念:API 是什么?ABI 是什么?API 很好理解,就是大家在开发时用到的别人提供的编程接口。那 ABI 是什么?我截了维基百科中 Linux 内核的 API 和 ABI 的图。图的上行介绍的是 API 的兼容性,图的下行介绍的是 ABI 的兼容性。

API 兼容是指我们的源码是可以适配的,源码适配指的是我们的源码拷贝到同样的环境下是能正常编译通过的。如果 API 不兼容,那么很显然我们的源码都编译不过。

ABI 兼容是指我们的二进制分发到不同的环境下是不通用的。比如说不熟悉电脑的同学,买了一台 Mac 电脑,发现 exe 程序不能执行,其实这就是 ABI 不兼容的原因。

当然 API 或者 ABI 不需要完全都兼容或者完全都不兼容。例如我们的 C++ 是可以做到源码层面的兼容和 ABI 的不兼容,因此跨平台开发时需要重新编译 C++ 工程。

此外 ABI 还包含函数的 Calling Convention。比如这里的foo()函数去调用一个bar()。首先声明了一些局部变量,然后把这些参数传递给bar()的实参。

ABI 还包括二进制的结构。比如在 Apple OS 上我们的二进制是 Mach-O,但在其他平台上(比如我们常说的 Android)可能是 ELF 格式。

那 ABI 稳定意味着什么?稳定意味着我们不同编译器的编译产物是可以兼容加载和运行的,并且语法不会发生较大的变化。一直在坚持学习和使用 Swift 的同学可能在 Swift4 之前有过比较痛苦的经历。在 Swift4 之前 Swift 的源码经常是碎片化的状态,例如上个版本的源码在这个版本就无法编译通过了。但在 Swift5.0 之后这种现象将大大缓解。ABI 稳定还有一个好处是操作系统可以内置运行库,在 iOS12.2 之后我们就不再需要把 Swift 的 runtime 内置到我们的产物中,这将大大减小我们的包大小。除了包大小外,内置运行库还会对启动性能有比较大的提升。

ABI对组件化工程有哪些影响呢?假设有上图中的定义的结构体 Storage,在这个结构体中包含 4 个成员(field1...field4),这 4 个成员有不同的大小和不同的偏移。那么这个结构体实例的内存结构是如上图右侧所示。

我们将上述结构体打包成二进制,假设为 Library A 1.0 版本。

假设我们还有一个依赖上文中的 Library A 的二进制库——Library B 1.0 版本。如果在此时我们重排了结构体 Storage 中的成员的位置(交换 field3 和 field4)。

那么 Storage 的内存布局会发生较大的变化,调整后 Storage 内存布局如上图右侧所示。假设此时我们将源码打包生成 Library A 的 2.0 版本,那这时我们可能会在线上发现一些奇怪的问题,可能会 crash。

那这到底是为什么呢?通常我们认为只要不修改公开的 API 即可认为是稳定的。但是实际上对 Swift 来说,修改自由字段或者重排都会打破二进制的兼容性。

打破模块稳定的行为即:直接访问结构体成员的偏移。访问成员的偏移相当于把内存细节暴露给外部使用者。如果此时存在内部变更,就会导致破坏性的存在。

接下来我们思考下我们的 APP 的执行到底分为几个阶段。我们的 run 通常分为编译+链接+执行(加载)。

业界提到的主流的组件化方式是一般是基于编译时期的二进制组件化。

主要方法就是:通过 Compile Once 减少构建时间。淘宝在 2014 年就完成了二进制组件化,淘宝内几乎所有的代码都不是源码形式,我们 1000 多个工程都是二进制。那为什么之前没有暴露出破坏性访问的问题呢?其实这个问题是一直存在的。在 OC 时代这些问题也比较常见,我们直接对 C 结构体的访问,其实是访问到了结构体的内部细节。再比如宏的定义、内联函数如果发生变更,那么依赖方如果不重新编译会导致上下层结构不一致从而造成问题。

那如何解决呢?其实解法比较简单,可以通过插入中间层来解决。

首先我们来分析下这个问题,在上面(图的左上方 Storage 结构体)有 Storage 最原始的结构,存在(fileld1...field4)的成员。下面有个跨模块的代码访问到了 field4。从底层分析来看,field4 位于第 40 个偏移,从底层机器指令可以看出第 40 个偏移是 C 类型的 8 字节的引用。从汇编中我们可以看到,我们是从栈的 0x28(十进制 40)个偏移把其加载到 x19 寄存器。但是如果此时我们的结构发生了变化,把 field4 提升到了 field3 的位置,那么此时底层结构会发生大的变化,但是汇编代码却依旧从第 0x28 偏移加载 field4 数据,导致运行时出现不兼容的情况。这里的根因是我们直接访问了低等级的内存偏移。

那如何解决呢?我们有特有的 DevOps 打包流程,我们的打包不是在本地构建的,而是在远端构建的。远端有一套特定的插件,打包时在源码阶段插入 Library Evoluation 指令,然后通过产物检测来提醒开发者可能存在风险。我们从图中可以看出,如果我们插入了 Library Evoluation 指令,原本直接访问会变成间接访问。从右边的汇编代码可以看出,我们通过 bl 指令跳转到特定的函数调用里,在这个函数调用里提供间接访问。这个间接访问代码由原始的底层 SDK 提供,它隐藏了相对私有的偏移的细节。

在我们插入 Library Evoluation 指令之后,原本常见的引起二进制兼容的问题都消失了。比如添加字段、重排字段或者修改内部细节等。但是还有 2 种情况会导致此类问题,即删除公开属性以及修改公开属性的类型。除此之外,还有其他影响 ABI 的行为是我们改变不了的。比如改变数据结构的命名空间,我们把一个类从 A 命名空间改变到 B 命名空间;还有函数签名,尤其是带有默认实现的函数。因为 Swift 的编译更像 C++,函数的默认实现是像 C++ 的默认实现一样,是在使用方添加默认传递,并不是在提供方里直接将函数入栈。当然还有其他的情况,比如协议添加了必须要求的实现,泛型特化,内联函数等。

手动开启 Library Evoluation 比较简单,在BuildSetting中有个Build Libraries for Distribution选项。至于为什么我们在淘系内做这个比较复杂,是因为 Library Evoluation 必须要保证传递链上的所有的二进制全部都完成这个编译设置,不然得到的产物是非常不稳定的,这个产物会大大影响线上的稳定性情况。

我们升级 DevOps 还带来了一些非常大的额外的收益。那就是我们支持了 Clang Module,使我们的编译时间有了较大的提升。这里主要分享下它的原理。我们在传统的 C 系编程语言中,在预处理阶段会经过一个 #include 声明式处理,这种处理在某些情况下是比较脆弱的,比如头文件的引入顺序可能会导致编译产物发生一些变化;一些内联定位会冲突;头文件中定义的宏可能存在重复定义或者取消定义等。另外,头文件的引入是低效的,因为 #include 操作相当于文本展开。假设工程中有 N 个文件处理 M 个单元,那么头文件需要处理 M*N 次冗余操作。好在 Clang Module 为我们提供了新的语法可以优化此问题,这种新的语法早在 Xcode 7 就已经提供,但是在此之前在淘系包括集团内的一些 DevOps 一直没有用到。

配置 Enable Module 开关的选项在 BuildSetting里。

开启 Clang Module 的效果是比较明显的,图中是淘宝中几个示例工程中的编译效果。比如首页工程在单机编译之前需要 60s,在开启 Clang Module 后,直接减少一半的编译耗时。有些项目则是更加夸张,直接从 30min 降低到 5min。

升级完 DevOps 之后,做业务就只是比较简单的事情了。在业务上我们完成了从 0 到 1 的进展。

我们用 Swift 实现了一套特有的创新方案——首页的动图渲染。大家可能注意到,在 19 年之后我们淘宝的首页中出现了大量的动图,这些动图对我们APP的稳定性包括性能会有比较大的挑战。我们团队的同学通过 Swift 语言实现了一套自编码自解码的动图渲染方案,对我们 APP 性能有非常大的提升。另外,大家可以看到线上的 widget 也是由 Swift 编写。除此之外,还有很多非 UI 的模块也是由 Swift 实现,在此不再一一展示。

除了这些,我们还做了很多内部文化氛围的沉淀。之前我们提到我们有一些横向组织,这些横向组织会组织一些内部的分享,包括文章分享、经验输出,以及一些 SDK 的输出。目前我们 1000 个 framework 已经有 80%~90% 已经支持了混编。这些 framework 不止是淘宝在用,整个阿里集团很多 APP 都在使用。因此只有我们踏出了第一步,其他的 APP 才能使用我们的基建。目前落地了将近 20 个 Swift 模块,集团将近 50 个 APP 接入了 Swift。在生态建设上,对内我们有一些视频专项培训,对外也有一些输出,还有一直很活跃的官方 Swift 组织以及 Swift 创新产品。

除了在 Apple 领域的探索之外,我们还在其他领域有所探索,包括:severside、端内工具等。

这里展示一些我们其他领域的 Swift 相关产品。在 SwiftUI 还没有发布之前,我们通过短短的 1~2 周之内就完成了很多工具。这些工具很大程度上方便了我们日常研发使用。例如线上值班的一些数据看板、研发平台。图片背部的 Mac APP 集成了我们很多集团内常用工具,包括符号查找、Mock 等。另外,我们也沉淀了一些 SwiftUI 的技术细节,已经对 SwiftUI 有一定的深度探索。

对外输出上,我们团队的同学星志和端智能的明奕同学对 MNN(深度神经网络推理引擎) + Swift 做了深度探索。MNN 本身提供了一套 C++ 的 API,移动端开发者接入成本是非常高的,为此我们结合 Swift 做了深度优化。

其他领域的探索还包括我们自己的 Faas 平台。基于集团的 GAIA 平台,我们搭建了 Swift 的运行时。使用 Swift 语言编译出 Faas 工程产物交付,然后通过 docker 可以快速部署到 sever 上,我们在端内达到云端一体化的效果。

在完成 Swift 0~1 之后,很多同学想了解关于 Swift 的下一步的扩展。其实前面的很多事情都是一次性的工作,我们解决完之后就已经稳定了。那么如何跨出更多的一步呢?

我们的横向组织有很多的探索,包括文化氛围、基础建设、跨 APP 架构、业务探索等。

我们在 19 年的时间里完成了很多功能,这些红色的部分是我们已经完成了的,有些内容是失败了,因为不可能所有的内容都能取得成功,失败的内容也能给我们提供很多经验。

2020 年我们做了更多的内容,比如厂商业务,包括 Widget、Metal 技术、以及 AR。另外就是重构更多的业务,以及 SwiftUI 在 iOS9 上的适配、架构升级等。

架构升级包括升级一些核心业务,这些业务可能有长达 10 年的历史。从业务重构中可以得到一个结论:Swift 对我们的 Native 原生技术确实有比较大的提效。这些提效会在很大程度上降低我们的工程维护复杂度。在业务之下,我们有一些需要支持的部分。我们在做业务的同时也在思考“淘宝这么大的工程结构中有哪些问题是需要我们重新审视的”。在基础架构上我们有三层需要做,首先需要打磨新的框架,这些框架是只为 Swift 语言服务的,这样能更好地服务于业务。还有就是我们引入了一些开源库,这些开源库更新更流行,整个编程风格更加现代化。另外我们有 200~300 个的基础库,这些基础库可能已经存在了很长时间并且不太可能做升级。这些库在混编过程中的调用风格不太符合我们的代码规范。因此需要构建一个 Swift Layer 包装原有的中间件,用 Swift 表现层抹平与 Swift 中间件上的调用差异。

大家可能一直在关注 WWDC2020 和 WWDC2021,SwiftUI 已经推出很久了。但是 SwiftUI 有个比较大的命门就是只有 iOS13+ 才能使用。SwiftUI 距离真正使用可能还存在一段时间,因为我们在做内部工具时发现,SwiftUI 的性能存在非常大的问题,因此真正使用 SwiftUI 的时候可能是 iOS14 甚至 iOS15。因此我们根据 SwiftUI 的特定的 DSL 去准备自研一套声明式 UI 框架,提前用上这些特性。今年推出的 Concurrency 也是一个非常棒的框架,同样它有一个非常严重的缺点,就是 iOS15+ 才能使用。因此我们在也在做一些能提前使用某些优秀框架新特性的探索。

总结

Swift 问世多年以来一直在国内的开发者生态,尤其是大型项目中支持度很差,很多反对的声音,诸如包大小,性能差,环境编译麻烦,包管理有问题。本次分享主要讲述了下淘宝是在什么契机下,了解 Swift, 解决工程问题,从 0 到 1 解决了工程问题,如何顺利保障 Swift 落地,开始拥抱 Swift,以及拥抱完成后,如何在一个庞大的项目中,持续地演进 Swift ,完成从 0 ~ More 的实践经验。

内推

对这次分享感兴趣的朋友可以加入讲师所在的开发团队,一起推进 Swift 的落地~


您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存