Categories
程式開發

一波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混编实践 1

在此之后,沉寂多年的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混编实践 2

在一些热门社区如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混编实践 3

但这个设置似乎和我们之前理解的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混编实践 4

三、Objc-> Swift

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

#import

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

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

#include "path-spec"
#include

引号表示让预处理器去源文件目录下搜索头文件,尖括号则表示去环境变量所指定的目录下去搜索,了解完这个机制后,再来看上面的这个问题。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混编实践 5

明白了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混编实践 6

图1

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

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

图2

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

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

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

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

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

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

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
@[email protected] 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混编实践 11

我们在文章开头部分曾介绍过携程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混编实践 12

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