在正式开始实践Swift-ObjC混编之前,我们有一些问题是绕不过去的,比如:
如果是在一个AppTarget内部混编的话,当我们在ObjC项目中新建Swift文件时或者在Swift项目中新建ObjC文件时,Xcode都会自动帮你新建一个Objective-Cbridgingheaderfile(当然我们也可以手动创建),我们可以在这个文件中导入需要暴露给Swift代码调用的ObjC头文件,这样我们就能在Swift中调用ObjC的代码了。
如果我们想在ObjC代码中调用Swift的代码,只需要写上一行import"ProductModuleName-Swift.h"(这里的ProductModuleName表示target的名字)就可以了,因为在编译时,编译器会自动给项目中的Swift代码生成一个ProductModuleName-Swift.h的头文件(这个文件是编译产物,我们在build目录可以看到它),暴露给ObjC使用。
除了在一个AppTarget内部混编之外,还有一种情况是当我们要写一个Library或者Framework给别人用时,这个时候如果有ObjC和Swift的混编,Objective-Cbridgingheader的方式已经不适用了,如果我们用了这个头文件,Xcode在预编译时也会警告我们。
先来看看Swift怎么调用ObjC,正确的做法是将BuildSettings中的DefinesModule选项设置为YES,然后新建一个umbrellaheader,再将需要暴露给(内部的)Swift调用的ObjC的头文件在这个umbrellaheader中导入(LLVMModule和umbrellaheader是两个新概念,后面会做具体介绍)。
--------------------------------------------------Hotel|HotelOrder|...业务层--------------------------------------------------HotelFoundation|HotelModel|...业务基础层--------------------------------------------------Network|Foundation|MapKit|RNKit|...基础框架层--------------------------------------------------图4笔者所在公司iOS客户端架构示意图
目前笔者所在公司的项目整体架构是采用模块化设计的,而且整个项目完全都是使用ObjC/C实现的,在实际开发时,各模块既可以以源码的形式使用,也可以以.a+.h+资源bundle的形式使用,简而言之,既可以源码依赖,也可以是静态库依赖。那么我们可以直接在项目中使用Swift静态库吗?
图5项目结构示意图(简化模型)
我们都知道,从Xcode9开始,Apple就开始支持Swift静态库的使用了,所以我们现有的项目架构并不需要调整,引入Swift代码的话是可以以静态库的形式出现的。
我们要做的第一步,就是创建一个Swift静态库工程,然后再把它作为子工程集成到ObjC主工程中去。
大概的步骤如下:
示例代码:
@objcMemberspublicclassSwiftLibA:NSObject{publicfuncsayHello(){print("Hello,thisisSwiftworld!")}}@implementationViewController-(void)viewDidLoad{[superviewDidLoad];[[SwiftLibAnew]sayHello];}@end问题:
1.为什么需要设置Dependencies?
设置Dependencies是为了告诉Xcodebuildsystem在编译主工程之前,需要先编译哪些其他的target,简而言之,就是编译依赖。
2.为什么需要设置LinkBinaryWithLibraries?
Xcode在build主工程时,会先编译好各个子工程,最后再链接成一个可执行文件,通过这个LinkBinaryWithLibraries设置,我们可以指定需要参与链接的静态库。
3.为什么需要复制xxx-Swift头文件到build目录下?
因为编译时自动生成的头文件是在Intermediates目录中各子工程所属的DerivedSources中,比如在我的电脑上就是/Users/ShannonChen/Library/Developer/Xcode/DerivedData/MainProject-aptbbpsumoitdlhbzjckyglkspoi/Build/Intermediates.noindex/SwiftLibA.build/Debug-iphonesimulator/SwiftLibA.build/DerivedSources/SwiftLibA-Swift.h,而主工程在编译时会到Build目录下的Products目录去找头文件,在我的电脑上就是/Users/ShannonChen/Library/Developer/Xcode/DerivedData/MainProject-aptbbpsumoitdlhbzjckyglkspoi/Build/Products/Debug-iphonesimulator/include,所以主工程或者其他子工程在编译时就找不到这个头文件了。
因此,我们就需要把这个xxx-Swift头文件复制到build目录下,具体脚本内容如下:
#将编译器生成的xxx-Swift头文件拷贝到build目录下的include目录中include_dir=${BUILT_PRODUCTS_DIR}/include/${PRODUCT_MODULE_NAME}/mkdir-p${include_dir}cp${DERIVED_SOURCES_DIR}/*-Swift.h${include_dir}参考:
集成好Swift静态库之后,我们再build一下,发现在链接时仍然会报错。
根据报错信息来看,是因为找不到swiftFoundation这些动态库,这是由于我们的主工程是纯ObjC项目,所以我们需要告诉Xcodebuildsystem这些Swift动态库的路径。
在BuildSettingstab下找到LibrarySearchPaths,添加上$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME),另外还需要添加Swift5.0的动态库所在的路径$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)。
这两个目录都可以在我们的电脑上看到:
参考:
静态链接的问题已经解决了,此时按下+R,模拟器启动后发生崩溃。控制台上的日志信息显示dyld:Librarynotloaded:@rpath/libswiftCore.dylib,这是因为程序启动时Swift动态库加载失败了。
为了解决这个问题,我们需要设置两个地方(只要你的项目iOSDeploymentTarget是12.2以下,这两个就都需要设置):
为什么我们要分别针对iOS12.2之前和之后的系统做不同的设置呢?将AlwaysEmbedSwiftStandardLibraries设置为YES是不是意味着每次打包时都会把Swift标准库打进去呢?
2019年对iOS开发者来说,最大的新闻莫过于SwiftABI终于稳定了。ABIStability意味着什么呢?ABIStability也就是binary接口稳定,在运行的时候只要是用Swift5.0或更高版本的编译器(Swift5.0对应Xcode10.2)构建出来的app,就可以跑在任意的Swift5.0或更高版本的Swiftruntime上了。这样,我们就不需要像以往那样每次打一个新的app时都要带上一套Swiftruntime和standardlibrary了,iOS和macOS系统里就会内置一套Swiftruntime和standardlibrary。
图11在这个例子中,基于Swift5.0构建出来的app可以直接在内置了Swift5或者Swift5.1,甚至Swift6标准库的系统上运行
但是如果你用的是Swift5.0以前版本的编译器,那么打包时还是会带上一套Swiftruntime和standardlibrary。
另外,对于用Swift5.0或更高版本的编译器构建出来的app,在发布app时,Apple将根据iOS系统创建不同的下载包。对于iOS12.2及以上的系统,因为系统内置了Swift5的runtime和standardlibrary,所以app中不再需要嵌入Swift的库,它们会被从appbundle中删掉。但是对于iOS12.2以下的系统,因为系统中没有内置Swift5的runtime和standardlibrary,所以打包时仍然需要带上。
理解了什么是ABIStability,就好理解我们前面在BuildSettings所做的两个设置了。
app在启动/运行时,会先看appbundle中有没有Swiftruntime,如果找不到,动态链接器dyld会到runpath路径下查找dylib(这个runpath路径是一个系统目录路径)。所以我们针对iOS12.2及以后的系统添加了RunpathSearchPath:/usr/lib/swift,针对iOS12.2以前的系统设置了AlwaysEmbedSwiftStandardLibrary。
AlwaysEmbedSwiftStandardLibrary曾经叫做EmbeddedContentContainsSwiftCode,字面上看上去像是“总是嵌入Swift标准库”,但是实际上这里只是告诉buildsystem要做的事,并不代表用户手机上下载的app是这样的,因为在发布app时,appthinning会自动根据目标系统来决定是否将appbundle中的Swift标准库删掉。
那么这个AlwaysEmbedSwiftStandardLibrary是用来告诉buildsystem做什么的呢?只要你的target会引用到Swift文件或者库,就需要把它设置为YES,比如我们这里的主工程用到了Swift静态库,所以就需要设置为YES,还有一种情况是你的target是一个测试工程,但是引用了Swift代码,那么也需要设置为YES。另外,笔者试验了一下,如果给一个纯ObjC的项目中添加了一个Swift文件,Xcode会自动将这个选项设置为YES。
前面提到过,笔者所在公司的iOS项目是采用的是模块化架构,而模块之间是有依赖关系的。一般是上层模块依赖于下层的模块,如图4所示。
这里先说明一下我们这里所说的模块的概念,在我们的项目中,一个ObjC模块就是.a静态库+.h头文件+bundle资源文件的组合。
如前面所说,ObjC调用Swift代码时,只需要导入编译Swift模块时自动生成的头文件xxx-Swift.h就可以了。
比如,模块ObjCLibA调用模块SwiftLibA:
这是因为SwiftLibA-Swift.h文件是编译模块SwiftLibA时的产物,是生成在build目录中,而不是工程代码所在的目录中。这一点我们在前面已经讨论过,这里不再赘述。
所以要想解决这个问题,我们可以换个思路,这个SwiftLibA-Swift.h文件是根据我们写的Swift代码公有API生成的,那么我们每次修改Swift代码的公有API时,它就会更新一次,所以,我们可以在每次build这个模块时把最新生成的拷贝到源码所在目录下(这个文件需要加入到版本控制中和其他代码一起提交),然后再把新的路径添加到ObjC模块的HeaderSearchPath中,另外,ObjC模块中头文件导入的方式也要改成双引号的形式。
完整脚本如下:
ObjC模块调用Swift模块的问题解决了,那么如果Swift模块调用Swift模块呢?会不会也存在类似的问题?
先来看一个例子,还是前面的那个示例项目,只不过多了一个模块SwiftLibB:
-MainProject-ObjCLibA-SwiftLibA-SwiftLibB然后我们在模块SwiftLibA中调用了模块SwiftLibB中的API:
importFoundationimportSwiftLibB@objcMemberspublicclassSwiftLibA:NSObject{publicfuncsayHello(name:String){SwiftLibB().sayHello(name:name)print("Hello,thisis"+name+"!")print("--PrintedbySwiftLibA")}}这个时候如果编译主工程是没问题的,但是如果单独编译模块SwiftLibA就会报错:Nosuchmodule'SwiftLibB'。
这个问题看上去跟前面遇到的ObjC模块调用Swift模块的问题是一样的,但是我们要知道Swift中是没有头文件的概念的,那么Swift是通过什么方式暴露公开API的呢?
不同于C-based语言使用manually-written头文件来提供公开接口,Swift是通过一个叫做swiftmodule的文件来描述一个library的interface,这个swiftmodule文件是编译器自动生成的。我们打开SwiftLibB模块的build目录,可以看到编译器自动生成的SwiftLibB.swiftmodule,这个SwiftLibB.swiftmodule目录下有两种文件:swiftmodule文件和swiftdoc文件。swiftmodule文件和swiftdoc文件都是二进制文件,我们可以用反编译工具查看其中的内容,swiftmodule文件里面保存了模块的信息,而swiftdoc文件则保存了源代码中的注释内容。
看到这里,你可能会想我们只要像导出xxx-Swift.h文件一样,把这几个swiftmodule文件导出到源代码目录,然后再设置SwiftLibA的importpath,另外再把这几个文件加入git版本控制中就解决了。
是的,我一开始也是这么想的,然后我就这么去做了,单独编译SwiftLibA确实问题,但是提交到git远程仓库之后,持续交付平台上的SwiftLibA模块却编译报错了:
...error:ModulecompiledwithSwift5.1cannotbeimportedbytheSwift5.1.2compiler...ModuleStability上面的方法之所以行不通,是因为swiftmodule文件跟编译器版本是绑定的,在Swift5.1之前,Apple官方没有提供解决办法,在发布Swift5.1的时候,除了ABIStability之外,Apple还解决了一个重要的,就是ModuleStability,也就是我们这里遇到的问题。
针对ModuleStability,Apple提供的解决方案是swiftinterface文件,swiftinterface文件是作为swiftmodule的一个补充,它是一个描述module公开接口的文本文件,不受编译器版本限制。比如,你用Swift5.0的编译器编译出了一个library,它的swiftinterface文件可以在Swift5.1的编译器上使用。
我们现在打开SwiftLibB的BuildSetting,找到BuildOptions->BuildLibrariesforDistribution,把它设置为YES,重新编译一下,再看看build目录中生成的SwiftLibB.swiftmodule,里面多了几个swiftinterface文件。
我们可以打开swiftinterface文件跟源代码对一下,它其实就是一个swift头文件。
源代码:
importFoundation@objcMemberspublicclassSwiftLibB:NSObject{publicfuncsayHello(name:String){print("Hello,thisis"+name+"!")print("--PrintedbySwiftLibB")}}swiftinterface文件中的内容:
//swift-interface-format-version:1.0//swift-compiler-version:AppleSwiftversion5.1(swiftlang-1100.0.270.13clang-1100.0.33.7)//swift-module-flags:-targetx86_64-apple-ios13.0-simulator-enable-objc-interop-enable-library-evolution-swift-version5-enforce-exclusivity=checked-Onone-module-nameSwiftLibBimportFoundationimportSwift@objc@objcMemberspublicclassSwiftLibB:ObjectiveC.NSObject{@objcpublicfuncsayHello(name:Swift.String)@objcoverridedynamicpublicinit()@objcdeinit}为了能够满足模块SwiftLibA的单独编译,跟前面对xx-Swift.h文件的操作一样,我们用脚本把SwiftLibB.swiftmodule拷贝到源代码目录中,然后再把这个新路径添加到SwiftLibA的BuildSetting->SwiftCompiler-SearchPaths->ImportPaths中。
这个方案对于模块化/组件化有个缺点就是,每次编译Swift模块时需要考虑多种不同的CPU架构。
除了这个方案之外,还有其他两个方案可以解决Swift模块之间依赖的问题:
参考
如果是在同一个apptarget里,Swift调用ObjC可以通过Objective-Cbridgingheader来实现,但是如果是跨模块的调用呢?Swift模块怎么调用ObjC模块?
根据Apple官方文档中的介绍,在Library或者Framework中不能使用bridgingheader的,而应该使用umbrellaheader。
在ObjC中可以通过@import指令导入module,在Swift中通过import关键字导入module。
Module机制中一个很重要的文件就是modulemap文件,modulemap文件是用来描述头文件和module结构的在逻辑上的对应关系的。
Thecruciallinkbetweenmodulesandheadersisdescribedbyamodulemap,whichdescribeshowacollectionofexistingheadersmapsontothe(logical)structureofamodule.Forexample,onecouldimagineamodulestdcoveringtheCstandardlibrary.EachoftheCstandardlibraryheaders(stdio.h,stdlib.h,math.h,etc.)wouldcontributetothestdmodule,byplacingtheirrespectiveAPIsintothecorrespondingsubmodule(std.io,std.lib,std.math,etc.).Havingalistoftheheadersthatarepartofthestdmoduleallowsthecompilertobuildthestdmoduleasastandaloneentity,andhavingthemappingfromheadernamesto(sub)modulesallowstheautomatictranslationof#includedirectivestomoduleimports.
Modulemapsarespecifiedasseparatefiles(eachnamedmodule.modulemap)alongsidetheheaderstheydescribe,whichallowsthemtobeaddedtoexistingsoftwarelibrarieswithouthavingtochangethelibraryheadersthemselves(inmostcases[2]).
Themodulemaplanguagedescribesthemappingfromheaderfilestothelogicalstructureofmodules.Toenablesupportforusingalibraryasamodule,onemustwriteamodule.modulemapfileforthatlibrary.Themodule.modulemapfileisplacedalongsidetheheaderfilesthemselves,andiswritteninthemodulemaplanguagedescribedbelow.
一个C标准库的modulemap文件可能就是这样的:
Aheaderwiththeumbrellaspecifieriscalledanumbrellaheader.Anumbrellaheaderincludesalloftheheaderswithinitsdirectory(andanysubdirectories),andistypicallyused(inthe#includeworld)toeasilyaccessthefullAPIprovidedbyaparticularlibrary.Withmodules,anumbrellaheaderisaconvenientshortcutthateliminatestheneedtowriteoutheaderdeclarationsforeverylibraryheader.Agivendirectorycanonlycontainasingleumbrellaheader.
如果你创建的是Framework,在创建这个Framework时,definesmodule默认会设置为YES,编译这个Framework之后,可以在build目录下看到自动生成的Module目录,这个Module目录下有自动创建的modulemap文件,其中引用了自动创建的umbrellaheader。但是如果你创建的是staticlibrary,那就需要开发者手动为这个module创建modulemap文件和要引用的umbrellaheader。
接下来我们创建一个ObjCLibB模块,然后让SwiftLibA模块来调用它。
首先要做的是给模块ObjCLibB新建一个umbrellaheader文件和一个modulemap文件,然后再把modulemap文件的路径添加到SwiftLibA的importpaths,把umbrellaheader文件的路径添加到SwiftLibA的headersearchpaths,这样就大功告成了。
如果你的Swift模块要调用的模块是ObjC-Swift混编的,也可用同样的方式来实现,核心点就在于将C-based语言的头文件用modulemap和umbrellaheader封装起来。
如果你的主工程是纯ObjC实现的,那么当你在断点调试Swift模块中的代码时,会无法看到变量值,即便在console上使用LLDB命令也打印不出来。
Swift5的到来终于让我们看到了期待已久的ABI稳定,相信更现代、更安全的Swift会变得越来越流行。另外,在模块化/组件化项目中落地Swift时,LLVMModule是一个绕不过去的话题,LLVMModule改变了传统C-Based语言的头文件机制,取而代之的是Module的思维。技术的发展会带来更先进的生产力,我们期待Swift在未来能够进一步提升我们的开发效率和编程体验。