一波 N 折的携程酒店 Swift-Objc 混编实践

作者:睿东

发布于:2020 年 5 月 26 日 14:05

一波N折的携程酒店Swift-Objc混编实践

说起 Swift,对 iOS 开发者来说那是既熟悉又陌生,虽然早在 2014 年苹果就发布了 Swift1.0 版本,但在这之后的五六年时间里,一直处于不温不火的状态。ABI 的不稳定以及 API 的不向前兼容,更是被程序员调侃为“自从学了 Swift 之后,每年都要学一门新语言”。

这种情况一直持续到 2019 年 3 月,在 WWDC19 大会上,终于传来一个令人期待已久的好消息。伴随着 Swift5.0 发布的同时,也终于宣布了 Swift ABI 的稳定,开发者们不禁奔走相告。因为从此之后,Swift 终于可以摆脱对编译器版本的限制,不同版本 Swift 编译的 app 无需再借助 app 内的 runtime 就能和操作系统互相之间无缝通讯。Swift 终于可以算是一门真正成熟的编程语言了。

一波N折的携程酒店Swift-Objc混编实践

在此之后,沉寂多年的 Swift 突然走上了一条快速发展的道路。苹果公司开始快速发力对 Swift 的布局,步伐快得令人有点猝不及防,在下半年的 WWDC 会上又接连推出了 SwiftUI,Combine,以及 RealityKit 三款纯 Swift 的 Framework 或 API。虽然从兼容性 (仅限 iOS13 及以上) 角度来看,他们的实用性还早,但这一系列动作已经展现出苹果公司对于 Swift 未来的决心,让人惊呼 Swift 的未来已来。

从行业流行度的数据来看,Swift 发展得远比我们想象中要快。根据阿里手淘团队不久前对 app store 排行榜 TOP1000 的 APP 进行文件扫描分析结果得知,美区使用 Swift 的 APP 占比已经达到了 78%,剩余未使用的还是一些来自中国地区的产品,由此可见 Swift 在国外的热度已经非常高了。即便是在中国区,TOP100 的 APP 也有 26 家使用了 Swift,超过了使用 React Native 和 Flutter 的数量,仅次于 Objc,具体数据如下图所示:

一波N折的携程酒店Swift-Objc混编实践

在一些热门社区如 StackOverFlow 上,Swift 问题的热度也已经远超 Objective-C。一些 Objective-C 的问题开始无人关注或解答,苹果官方的开发者网站更是早在 2017 年便开始不再提供 Objective-C 代码的示例。另外,在最近两年的校园招聘中,也有越来越多的学生表示他们已经直接从 Swift 开始学习 iOS 开发。

种种迹象表明,iOS 开发语言的重心已经在悄悄倒向 Swift,开发者们对 Swift 的信心正在被重新点燃。对于我们携程酒店技术团队而言,此时对 Swift 展开调研是一个很好的时机,这不仅仅是为了跟上新技术的发展,也是为了避免将来有技术踏空的风险。因为也许很快 Objective-C 将不再是开发 iOS 的最优选择,并且未来会有可能很难招聘到 Objective-C 的开发,尤其是校园招聘。

于是,我们迅速组织研发人力,对 Swift 开发在携程主 app 内的可行性展开了调研和实践。

一、先从哪里开始呢

万事开头难,不过好在苹果开发者网站给出了一些迁移的经验和守则,其中第一条就说到“Remember that you can’t subclass a Swift class in Objective-C.Therefore, the class you migrate can’t have any Objective-C subclasses.” 既然 Swift 类不能被 Objective-C 继承,那么最适合首先迁移的还是那些底层工具类代码,同时为了让架构看上去更清晰,我们决定新建一个 Swift 库来管理所有迁移好的 Swift 代码。

虽然在选择是静态库还是动态库的问题上纠结了很久,但由于目前携程 app 的架构主要是由各 bu 之间互相依赖静态库的调用构成,所以最终我们还是选择了对架构变动影响最小的静态库方式。幸运的是,Swift 编译静态库在 xcode9 就已经被苹果支持,所以我们的此次实践并不需要对 app 工程架构做出任何调整,直接以静态库的形式来引入 Swift 即可。

二、Objc& Swift 混编

集成好 Swift 静态库之后,马上开始准备我们第一次的 Objective-C 和 Swift 混编,不幸的是模拟器启动后即崩溃了,控制台上显示“dyld: Library not loaded: @rpath/libswiftCore.dylib”,程序启动时加载 Swift 动态库失败了。

在 stackoverflow 上查阅问题后得知,我们除了需要在 Runpath Search Path 中添加 /usr/lib/swift 之外,还需要将 Always Embed Swift Standard Libraries 设置为 Yes,如下图所示:

一波N折的携程酒店Swift-Objc混编实践

但这个设置似乎和我们之前理解的 ABI 稳定有点冲突,ios12.2 之前的版本因为系统没有内置 Swiftruntime 和动态库,所以需要在 app 中打入 Swift runtime。那么 Always Embed Swift Standard Libraries 设置为 Yes 之后,是不是就意味着我们在 12.2 之后的版本也会打上这个库呢?

答案是肯定的,但这并不意味着最终在用户端也一定会下载到这个库。App store 和操作系统在安装 iOS 或者 watchOS 的 app 时会通过一些列的优化,尽可能减少安装包的大小,使得 app 以最小合适的大小被安装到你的设备上,这个过程被称作为 APP Thinning。

所以开发者只需尽管上传兼容所有版本功能的 app 包,系统会负责将 app 剪裁到最适合用户的最小体积来下发,每台设备都只会下载符合各自机型和操作系统所需要的可执行文件和资源。也就是说每个用户下载到的包大小差异取决于用户手机的操作系统版本,这个过程如下图所示:

一波N折的携程酒店Swift-Objc混编实践

三、Objc-> Swift

解决了混编问题之后,我们开始着手在 Objective-C 工程内尝试调用 Swift 模块,Swift 模块编译后会生成一个以 xxx-Swift.h 结尾的头文件,通过导入这个头文件,如:

复制代码
#import<SwiftLibA/SwiftLibA-Swift.h>

就可以在 Objc 项目里引用 Swift 方法了,试了一下,在 xcode 里很顺利地跑了起来。但如上文所说,携程整个 app 的架构是由对静态库的依赖构成,所以在 CI 平台上是针对各个静态库单独打包编译的。在单独编译 Objc 库的情况下,打包失败了,控制台又给我们留下一句话:“SwiftLibA/SwiftLibA-Swift.h’ file not found”。

在解答这个问题之前,先让我们回顾一下 C 语言家族引入头文件的两种方式,分别是:

复制代码
#include "path-spec"
#include<path-spec>

引号表示让预处理器去源文件目录下搜索头文件,尖括号则表示去环境变量所指定的目录下去搜索,了解完这个机制后,再来看上面的这个问题。Swift 模块编译时产生的头文件是放在 build 目录中的,而不是在源文件目录下,而我们的打包脚本只会在依赖项的源文件目录中搜索,所以在单独编译 Objc 库的时候就会找不到 Swift 头文件。

要修改那个动辄上千行如天书一般难以理解的打包脚本,显然不是最快的解决方案。我们也曾动过要换动态库方式的念头,但这个对工程变动的影响太大,短时间内应该得不到支持,而且苹果也是推荐优先使用静态库,所以只能换个思路去解决这个问题。既然 CI 不支持在环境变量目录中去搜索头文件,那我们就把它从 build 目录中 copy 出来当源文件使用(需加入 git 做版本控制)。

为了方便这个操作,我们使用脚本在每次编译完成后就把最新的 Swift 头文件自动 copy 到 Swift 模块所在的源文件目录中,完整的脚本如下:

复制代码
mkdir -p${include_dir}
cp${generated_header_file} ${include_dir}

# 去掉 xxx-Swift.h 文件头部注释中的编译器的版本号

复制代码
sed -i"" "s/^\/\/ Generated by Apple.*$/\/\/ Generated byApple/g" ${generated_header_file}

# 拷贝 xxx-Swift.h 文件到工程源码目录

复制代码
header_file_in_proj=${SRCROOT}/${PROJECT}-Swift.h
needs_copy=true
if [ -f"$header_file_in_proj" ]; then
echo "${header_file_in_proj} 已存在 "
new_content=$(cat ${generated_header_file})
old_content=$(cat ${header_file_in_proj})
if [ "$new_content" ="$old_content" ];then
echo " 文件内容一致,无需再 Copy:"
echo "${generated_header_file}"
echo "${header_file_in_proj}"
needs_copy=false
fi
fi
if ["$needs_copy" = true ] ; then
echo " 文件内容不一致,需要 Copy:"
echo " 复制文件:"
echo "${generated_header_file} "
echo "${header_file_in_proj} "
cp ${generated_header_file}${header_file_in_proj}
fi

至此,在 Objective-C 项目内调用 Swift 静态库的问题全部得到解决,终于能让 Swift 模块可以愉快的在 objc 项目中被随意使用了。

四、Swift-> Swift

本以为项目会就此进入坦途,但没过几天,就迎来了新问题。随着项目进行的需要,我们要把 Swift 静态库一拆为二,彼此之间单向依赖,于是我们的问题就变成了 Swift 静态库如何互相之间调用的问题。乍一看这并不是什么大问题,Objc 调 Swift 都能解决,Swift 调 Swift 还不简单,几行代码就能实现,如下:

复制代码
importFoundation
import SwiftLibB
@objcMembers
public classSwiftLibA: NSObject {
public func sayHello(name: String) {
SwiftLibB().sayHello(name: name)
print("Hello, this is " +name + "!")
print("-- Printed by SwiftLibA")
}
}

代码非常简单,编译整个工程也没有遇到任何问题,但是跟之前遇到问题一样的是当你试图单独编译模块 SwiftLibA 时,再次发生了报错,“No such module 'SwiftLibB’”,编译器找不到对 SwiftLibB 的引用。

根据之前的经验,我们很快就断定这是同一个原因,但是上文提过我们已经把 Swift 头文件 copy 到源文件目录中了,为什么突然不起作用了呢?很显然是因为 Swift 模块间的互相调用跟 Objc 调用 Swift 不同,他们并不依赖那个编译出来的头文件。所以问题来了,Swift 模块间是通过什么方式来对外暴露 API 的呢?

在官方文档中我们找到了答案, “Swift uses an opaquearchive format called “swiftmodule” to describe the interface of a library”,意思是说 Swift 使用一个叫 swiftmodule 的文件来描述一个库的接口申明,在编译目录下,我们果然找到了这个文件,如下图所示:

一波N折的携程酒店Swift-Objc混编实践

明白了 Swift 模块间的接口声明方式后,接下去就要像之前导出 XXX-Swift.h 文件一样,如法炮制,把 swiftmodule 文件也同样导出到源文件目录,然后再设置 SwiftLibA 的 import path,并把这几个文件添加到 git 库中做版本管理。

一顿操作后大功告成,最后检验下成果,这时单独编译 SwiftLibA 终于没有问题了,于是提交代码,开始准备远程打包然后收工,但令人意外的是 MCD(携程 CI 打包工具)竟然报错了,“error: Module compiled with Swift 5.1 cannot be imported by the Swift 5.1.2compiler”。

为什么会这样,仔细再看了下文档,原来之前的话还有后半句被我们忽略了,“However, the “swiftmodule” format is also tied to the currentversion of the compiler”。原来 swiftmodule 是跟编译器版本强相关的,不同版本编译器编译出来的库是不能被互相兼容的,也就是说 Swift5.0 虽然已经做到了运行时 ABI stability,但还没有做到编译时的模块稳定 (Module stability)。不过幸运的是当我们遇到这个问题的时候,Swift 已经发布了 5.1 版本,及时加入了解决 Module stability 的方案,下面先用图 1 来表示我们最初使用 Swift 模块的方法。

一波N折的携程酒店Swift-Objc混编实践

图 1

图 2 则是模块稳定后的解决方案,唯一的区别只是将 swiftmodule 文件改成了 swiftinterface 文件,swiftinterface 文件作为 swiftmodule 的一个补充,它是一个描述 module 公开接口的文本文件,不受编译器版本限制,并可以被手动编辑。

一波N折的携程酒店Swift-Objc混编实践

图 2

比如,你用 Swift6 编译器编译出了一个 library,通过它的 swiftinterface 文件,这个库就也可以在 Swift7 编译器上使用,如下图所示:

一波N折的携程酒店Swift-Objc混编实践

下面就让我们来实践一下获取,打开 SwiftLibB 的 BuildSetting,找到 Build Options -> Build Libraries for Distribution,设置为 YES,如下图所示:

一波N折的携程酒店Swift-Objc混编实践

然后再重新编译一下,打开 build 目录,这时就能看到里面多了几个 swiftinterface 文件,这是一个可以被编辑的文件,也可以进行手动修改,如下图所示:

一波N折的携程酒店Swift-Objc混编实践

swiftinterface 文件中的内容大概如下:

复制代码
//swift-interface-format-version: 1.0
//swift-compiler-version: Apple Swift version 5.1 (swiftlang-1100.0.270.13clang-1100.0.33.7)
//swift-module-flags: -target x86_64-apple-ios13.0-simulator -enable-objc-interop-enable-library-evolution -swift-version 5 -enforce-exclusivity=checked -Onone-module-name SwiftLibB
importFoundation
import Swift
@objc@objcMembers public class SwiftLibB : ObjectiveC.NSObject {
@objc public func sayHello(name: Swift.String)
@objc override dynamic public init()
@objc deinit
}

所以,除了 swiftmodule 外,我们还需要把 swiftinterface 文件也一起提供给第三方调用者,并一起 copy 到源文件目录。

模块的稳定意味者二进制库的稳定,Swift 库之间的调用终于不用再依赖源码或者编译器版本,这对于 Swift 的发展来说是一个很大的进步,将更有助于推动 Swift 的发展。

五、Swift-> Objc

原本以为到这里应该是解决完了所有问题,但计划不如变化来得快。虽然在设计之初我们在原则上约定了只允许 ojbc 引用 swift,不允许被反过来引用,但很快我们就不得不推翻了这个约定。因为我们发现这是一件不可避免的事情,比如我们很多引用都来自携程公共团队的底层模块,这些模块都是基于 objc 的,甚至还有一些第三方的 objc 库,在公共底层库没转 Swift 之前,这就是一个无法被避免的问题。

不过好在苹果官网早就提供了解决方案,在《ImportingObjective-C into Swift》一文中分别提供了 Objc 文件是在同一 app target 内被引用还是作为 Framework 使用时的两套解决方案。

在同一 app target 内被引用时较为简单,只需创建一个以“-Bridging-Header.h”为后缀名的文件即可,并把需要暴露给 Swift 的 objc 头文件在这里进行编辑就可以了,具体如何创建这个文件本文就不做赘述了。

一波N折的携程酒店Swift-Objc混编实践

我们在文章开头部分曾介绍过携程 app 架构主要采用的是静态库依赖的构成方式,所以上面的方案对我们并不适用。因为 Swift 终于引入了命名空间的概念(Objective-C 一直以来令人诟病的地方之一就是没有命名空间),但是和 C#这样显式在文件中指定命名空间的做法不同。Swift 的命名空间是基于 module 而不是在代码中显式地指明,每个 module 代表了 Swift 的一个命名空间,在这种情况下我们的 Swift 静态库无法采用 Bridging header 方式,这时就必须要把这些头文件导入到 Objective-C 的 umbrella header 中,Swift 会通过这个文件看到所有你在 umbrella header 中公开暴露出来的头文件。

看到这里我们不禁有个疑问,到底什么是 umbrellaheader?其实这并非是个新鲜玩意,相反,这是早在 2012 年就由苹果在 LLVM DevMeeting 提出并实现的概念,目的就是要颠覆传统的头文件引用方式。

我们知道在 C/C++ 以及 Object-C 这一系列 C 语言家族的编程语言里,在需要引用到其他库的时候,通常是通过引用头文件的方式来访问。但这类机制有很多问题,其中最大的问题是预编译效率不高,因为头文件的描述是基于文本 (textual) 形式的,所以预编译器需要对其进行语义分析。由于这个过程是递归进行的,所以会导致编译时间变得非常不可控,假设有 N 个源文件每个都有 M 个头文件,那么所带来的编译成本就是 N x M,即便有很多头文件是重复引用的也是如此。

所以 LLVM 引入 Module 的概念来解决这个问题,Module 采用更高效的树形结构描述来导入头文件,整个 Module 只会编译一次,头文件也只解析一次,避免了被重复引用,这样一来之前 M x N 的问题就变成了简单的 M+N。

而 Module 机制中一个很重要的文件就是 modulemap,它是 module 和头文件之间产生联系的关键,是用来描述头文件和 module 结构在逻辑上的对应关系。如果一个库 (library) 想要作为 module 被使用,那就必须要有一个对应的“module.modulemap”文件,在这个文件中声明要引用的头文件,并和那些头文件放在一起,一个 C 标准库的 module map 文件可能是这样的:

一波N折的携程酒店Swift-Objc混编实践

modulemap 中的内容是通过 module map 语言来实现的,module map 语言中有一些保留字,其中带 umbrella 关键字的 header 申明就叫做 umbrella header,作用是可以把它所在目录下的所有头文件都包含进来,这样开发者中只要导入一次就可以使用这个 library 的所有 API 。

创建 modulemap 的方法很简单,如果是动态库在编译的时候系统会自动替我们生成,如果是静态库则需要我们手动生成并编辑这个文件。

做到这里不禁会联想到目前携程 app 项目内头文件引用的灾难,导致编译效率极其低下,其实是时候用 module 的思路来重构一下我们的项目了,当然这又会是一项庞大的工程。

六、总结

至此,我们终于解决完了 Swift 在携程 app 内应用的所有已知问题,让 Swift 以静态库的形式完美集成到项目中,并可以在 Swift 和 Objective-C 之间互相调用,和携程的 CI 平台也能无缝集成。目前在实际项目中已经开始使用 Swift 来写部分需求,未来的一些新功能我们也会考虑直接用 Swift 来开发。

在这次的实践过程中我们领略到了 Swift 作为一门先进语言的魅力,众多的新特性让研发效率有了显著提高,经过我们 Swift 重写的 framework 代码量都有不同程度的下降。

由于篇幅和主题的原因,本文就止步于探讨将 Swift 集成到 Objc 工程中的一些问题和经验。对于 Swift 语言本身的一些探讨有机会可以另作分享,我们相信更现代、更安全的 Swift 会变得越来越流行,希望有越来越多的开发者可以早日加入 Swift 的阵营。

作者介绍

睿东,2009 年加入携程,从事无线研发,现负责酒店无线研发工作。

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

原文链接

https://mp.weixin.qq.com/s/N6ToEkN9c-2_rIvkv4o9hA

阅读数:468 发布于:2020 年 5 月 26 日 14:05

评论

发布
暂无评论