百度 App Objective-C/Swift 组件化混编之路(二)- 工程化
作者丨张渝、郭金
来源丨百度 App 技术
前文《百度App Objective-C/Swift 组件化混编之路》已经介绍了百度 App 引入 Swift 的影响面评估以及落地的实施步骤,本文主要以依赖管理工具为支撑,介绍百度 App 如何实现组件内的 Objective-C/Swift 混编、单测、二进制发布和集成,以及组件间的依赖和引用。
百度 App 自研的依赖管理工具 EasyBox 工具链已经把混编作为功能子集,如果你感兴趣,可以阅读百度 App 技术公众号往期文章《百度App iOS工程化实践: EasyBox破冰之旅》。掌握 Xcode 编译、链接选项等相关知识点,有助于理解混编的实现过程。
一. 组件 Target 类型 和 Module 化
为解决大规模并行开发问题,百度 App 将工程进行了组件化拆分,并实现组件的二进制化,一个组件即为一个独立的功能单元和编译单元,具有两种形态,源码形态和二进制形态,开发过程中可以按需进行组件的源码/二进制切换。所以我们要解决这两种形态下的组件内混编和组件间混合调用问题。
在介绍混编之前,我们先来了解两个重要的概念:组件 Target 类型和 Module。
1.1 组件 Target 类型
EasyBox 工具链会为源码形态的组件生成一个 Xcode 子工程和对应的 Target,Target 可以是以下类型中的一种:
dynamic_library:动态库,Xcode 7 之前扩展名为 .dylib, Xcode 7 后是 .tbd ;目前官方环境并不允许为 iOS 平台添加这种类型。
static_library:静态库,扩展名 .a
static_framework:静态库,扩展名 .framework
dynamic_framework:动态库,扩展名 .framework
.a 与 .framework 的区别是:Framework 是分层目录,它将共享资源(例如动态共享库,nib 文件,图像文件,本地化字符串,头文件和参考文档)封装在一个程序包中。动态库与静态库的区别是:系统根据需要将动态库加载到内存中,可以被多个应用程序同时访问,并在所有可能的应用程序之间共享资源的一份副本。静态库则是链接到某个应用程序的二进制中。
这些 Target 可能还存在一个或多个伴生 Target :
bundle
octest_bundle
unit_test_bundle
ui_test_bundle
What's the Xcode target?
https://developer.apple.com/library/archive/featuredarticles/XcodeConcepts/Concept-Targets.html
对于伴生 Target,与 Swift 混编相关的只有单测;而对于主 Target,按照 Target 的文件组织形式可以分两类:
Library(扩展名为 .a)
Framework(扩展名为 .framework)
当 Target 中只有 Objective-C 源码(.h、.m)时,无论哪种 Target,源文件之间都可以通过 import 头文件的方式进行引用,但 Swift 语言是强制以 module 形式 引用的,所以在 Swfit 中需要将 Target 的产物转换为一个独立的 module,供其他 module 依赖并引用。所以要实现 Swift 混编,每个组件对应的主 Target (源码或二进制)都必须以一个 module 的形式存在。下面介绍如何实现 Target 内的 module 混编、以及 Target 之间的 module 依赖。
1.2 Module 化
1.2.1 基本概念
module:是一个编译单元,或构建产物,对一个软件库的结构化替代封装,供链接器使用(更多介绍请查阅 Clang-Module:https://clang.llvm.org/docs/Modules.html#introduction)
umbrella header:module 对外公开的根头文件,包含了这个 module 中所有其他公开头文件的引用。以 Foundation 框架的根头文件
<Foundation/Foundation.h>
为例:
对编译器来讲,每次编译过程一个 module 只会加载一次,避免多次引入并加载相同的头文件带来的编译耗时问题。所以 module 化后编译效率更高。
modulemap:描述 module 和 module header 间的关系,描述现有 header 如何映射到 module 的逻辑结构。modulemap 结构如下:
ModuleMap 采用模块映射语言,但是到现在( 2020 年 Q3 为止)该语法依然不够稳定,所以建议:编写 modulemap 时需要尽可能使用少的关键字实现 module 功能,比如 framework、umbrella、header、extern、use。
建议 modulemap 内声明一个 umbrella header,便于快速引用对应的头文件,但必须将所有公开的头文件填充到 umbrella header 文件内。否则将得到一个警告:
不包含 umbrella header 的 module ,modulemap 中不必添加
module * { export * }
包含 umbrella header 的 framework,不用配置任何(包括 MODULEMAP_FILE )即可自动 module 化
1.2.2 module 相关的 build setting 参数
上古时期,程序员通过 Makefile 来控制程序的编译链接过程。现如今在 IDE 的封装下,复杂度大大降低,只需要通过 IDE 来控制关键变量和自定义变量,在 Xcode 中,这个控制变量被称为 build setting,build setting 和 Module 化相关的变量主要有这些:
对 module 自身的描述:
DEFINES_MODULE:YES/NO,module 化需要设置为 YES
MODULEMAP_FILE:指向 module.modulemap 路径
HEADER_SEARCH_PATHS:modulemap 内定义的 Objective-C 头文件,必须在 HEADER_SEARCH_PATHS 内能搜索到
PRODUCT_MODULE_NAME:module 名称,默认和 Target name 相同
对外部 module 的引用:
FRAMEWORK_SEARCH_PATHS:依赖的 Framework 搜索路径
OTHER_CFLAGS:编译选项,可配置依赖的其他 modulemap 文件路径 -fmodule-map-file=${modulemap_path}
HEADER_SEARCH_PATHS:头文件搜索路径,可用于配置源码中引用的其他 Library 的头文件
OTHER_LDFLAGS:依赖其他二进制的编译依赖选项
SWIFT_INCLUDE_PATHS:swiftmodule 搜索路径,可用于配置依赖的其他 swiftmodule
OTHER_SWIFT_FLAGS:Swift 编译选项,可配置依赖的其他 modulemap 文件路径 -Xcc -fmodule-map-file=${modulemap_path}
本文的后续部分也会用到 build setting 中的其他关键变量。
1.2.3 非 framework 的 module 处理
包含 Swift 源码的非 framework 的 module,建议在 buildphase 的 script 里处理编译后的两个事情:
编译生成的 interface header,拷贝作为公开头文件,供其他 Target 访问编译生成的 Swiftmodule,配置追加到 modulemap 文件中
至此,我们已经了解了单个组件的 module 化过程。
二. 组件内混编
根据官方说明,Target 内支持 Objective-C 和 Swift 语言的混编,无外乎解决两个问题:
Objective-C 可以引用 Swift 的类和方法
Swift 可以引用 Objective-C 的类和方法
下面我们针对 Framework 和 Library(非 Framework 静态库)两种类型,分别介绍下组件内的混编实现。
2.1 Framework
针对 Framework 类型的 Target 内混编,我们要做的就是什么都不做。
简单吧,对于全新生成的有 umbrella header 的 Framework 默认就是 Module 化 的,不需要做任何操作即可实现 Target 内混编。对于没有umbrella header
的 Framework,需要参照 如何实现 Module 化 进行 Module 化改造。
Objective-C 引用 Swift 在头文件内添加引入 Swift 的 Interface 头文件即可,可以访问 Swift 中以
@objc public
或@objc open
修饰的类和方法,或者 class 修饰为@objcMembers public
#import <xxx/${ModuleName}-Swift.h>
因为 Xcode 在编译时已经对 framework 进行 Module 化处理,并自动生成该 Interface 头文件,编译成功时拷贝 Headers 文件夹内
Swift 引用 Objective-C 直接使用对应的类和方法
2.2 Library
针对 Library 类型的 Target 内混编,我们首先依然需要参照如何实现 Module 化改造。
Objective-C 引用 Swift 与 Framework 的引用方式一致,在头文件内添加引入 Swift 的 Interface 头文件即可,可以访问 Swift 中以 @objc 修饰的类和方法,或者 class 修饰为 @objcMembers
Swift 引用 Objective-C 有显式和隐式两种方式 1、通过显式配置桥接文件 BridingHeader,在桥接文件内 import 对 Swift 类公开的头文件,用于 Swift 访问 Objective-C 头文件 (Importing Objective-C into Swift:https://developer.apple.com/documentation/swift/imported_c_and_objective-c_apis/importing_objective-c_into_swift)
不足:无法开启跨 Swift 版本兼容的功能
OTHER_SWIFT_FLAGS 的标记:
-import-underlying-module
该构件标记由 Xcode 隐式创建下层 Module,并隐式引入当前 Module 内所有的 Objective-C 的公开头文件,Swift 可以直接访问。该标记需要配合USER_HEADER_SEARCH_PATHS
或者HEADER_SEARCH_PATHS
来搜索当前 module 所需的公开头文件
不足:因为隐式创建下层 module,也会将 Swift 的类和方法包含到 Swift 的 Interface 头文件中,需要在 Swift 的类和方法之前添加
@objc open
,经测试发现,这样会造成 module 将近一秒延迟(即修改 Swift 的部分接口后 Interface 文件不立即变更)。
三. 组件间依赖
组件间依赖调用的核心依然是 Module 化,否则 Swift 无法调用其他组件,下面介绍组件间依赖调用相关的 Build Settings
参数。
单测也是组件间依赖的一种,单测的 Target 依赖其他需要测试的组件,并且该组件以源码形态集成
集成单测,除了配置组件间依赖的
Build Settings
,还需要注意两个要点:
第一,需要链接对应的静态库到目标
testbundle
第二,如果当前单测是 Objective-C 源码,而依赖的库文件包含 Swift 相关的库或 Target,必须在单测的 Target 内添加空的 Swift 占位源文件(空文件真的可以,后缀为 .swift),否则链接时会报错。
3.1 依赖 Framework 组件
如果依赖组件的 Target 类型是 Framework,So Easy,因为 Framework 已经是一个 module 了(包含 umbrella header),直接配置 BuildSettings:
FRAMEWORK_SEARCH_PATHS: 依赖的 Framework 搜索路径,在对应的路径下查找
xxx.framework
文件OTHER_LDFLAGS:当依赖的组件是源码时,可以有效将依赖的组件顺序编译,根据 Xcode 10.2 的升级说明(https://developer.apple.com/documentation/xcode-release-notes/xcode-10_2-release-notes)
3.2 依赖 Library 组件
当依赖组件的 Target 类型是 Library,配置稍微复杂一点:
3.2.1 当前组件包含 Objective-C 源码
OTHER_CFLAGS:配置当前 Target 依赖的其他 Module
OTHER_LDFLAGS:同 3.1 依赖 Framework 组件
HEADER_SEARCH_PATHS:配置当前 Target 的头文件搜索路径,包含依赖的其他 Module 内配置的头文件搜索路径
OTHER_SWIFT_FLAGS;配置当前 Target 依赖的其他 Module
当依赖的 Library 中包含 Swift 源码,那么该源码编译后将生成 swiftmodule,或依赖 Library 二进制中包含 swiftmodule,那么当前组件需要配置:
SWIFT_INCLUDE_PATHS:依赖组件 swiftmodule 的搜索路径,需要配置该路径,目录下包含
*.swiftmodule
3.2.4 编译顺序控制
当依赖的组件是 Library,并且包含 Swift 的源码,需将当前 Target 的 Scheme 编译条件配置为非并行编译 uncheck Parallelize Build
(如下图所示),达到控制编译顺序的目的,避免因为依赖组件还未生成的 *-Swift.h 文件(依赖组件编译成功后生成),造成当前组件源码的编译错误。
四. 混编组件二进制打包
为了提升产品线的编译速度,业界内很多产品线均做了组件二进制化,即将组件源码编译为多种架构的二进制,并合并架构后以二进制的方式引入工程,避免了大量源码的重复编译,提升编译效率,对于 Swift 的组件来说,如何做二进制化?
4.1 module 化
参考 1.2 Module 化要点
4.2 兼容性
虽然 ABI 稳定了,但是根据 Swift 的设计,各自 Swift 编译器打出的二进制并不能在其他版本使用,需要使用到跨 Swift 版本调用的 interface 文件(在编译产物 swiftmodule 文件夹中),设置 BUILD_LIBRARY_FOR_DISTRIBUTION = YES
即可生成,但该标记与 bridging 冲突,即在混编的 Library 且使用 bridging header
的工程中不可用;如果真要使用 Library 又想 Swift 二进制跨 Swift 版本兼容,参考 2.2 介绍的 -import-underlying-module
4.3 SWIFT_OBJC_INTERFACE_HEADER 文件合并
对于 Framework ,Swift 源码编译产生的 Objective-C Interface 文件会被自动拷贝到公开头文件夹,只需要合并多架构 Interface 头文件即可;但对于 Library 则需要先手动移动头文件再合并 Interface 头文件,建议在 BuildPhase 添加 Script Phase 在编译完成后拷贝操作:
不同架构的 *-Swift.h 文件的合并方式:
以
#ifdef 架构
的方式进行(当各架构提供的接口没有区别的情况下,可直接使用模拟器架构)合并为 XCFramework 的形式
4.4 swiftmodule 文件合并
对于包含 Swift 源码的产物中将包含 swiftmodule 文件夹,直接合并两个 swiftmodule 目录即可,不同架构以不同的文件名呈现
对于开启
BUILD_LIBRARY_FOR_DISTRIBUTION
的 module 来说,swiftmodule 文件夹内包含 *.interface 即为跨 Swift 版本兼容文件
4.5 合并二进制
使用 lipo
命令进行二进制架构的常规合并,这里不做赘述
4.6 二进制包
如下图:模拟器架构 Framework 形态的 *.swiftmodule
(.a 的 *.swiftmodule 与之类似),其中 x86_64-apple-ios-simulator.swiftinterface
是跨 Swift 版本调用的 interface 文件
4.7 小知识:swiftmodule 的传递依赖性
已知:有组件 A 依赖组件 B,组件 B 依赖组件 C 在 Objective-C 中,B 对外暴露的头文件中引用了 C 的公开头文件,我们叫组件 B 传递依赖 C,结果就是编译组件 A 时必须同时能找到组件 B 和组件 C 的头文件,否则编译失败。
然而 Swift 并没有公开头文件一说,只要组件 B import C
,导致 swiftmodule 中也明确标记了 import C
,当组件 A import B
时,也同时 import C
,如果组件 A 找不到组件 C 的 module,那组件 A 将编译失败。
五. 总结
对于百度 App 的开发者来说,不用去关心混编的是如何实现的,只需要跟正常开发一样,组件内引用所需的头文件(#import <ModuleXX/xx.h>)或 module(@import ModuleXX),组件间在声明依赖后亦可直接引用头文件或 module ,EasyBox 工具链会根据源码文件或配置进行 module 化和 Xcode Build setting
相关的处理,以下情况将判定为需要 module 化:
存在 .swift 的源码文件的组件
存在 .swiftmodule 或 *-Swift.h 文件的二进制组件
宿主工程的 Boxfile 中显式配置 module 化
组件的 boxspec 描述中声明 modulemap 文件
对于混编组件的二进制打包,开发者们也不用去关心如何处理编译产物,诸如 *-Swift.h
、二进制架构、*.swiftmodule
、*.interface
等,EasyBox 工具链打包命令 box package
会全权处理,降低开发者们的配置难度和协同成本。
六. 常见问题
6.1 Swift 组件内调用 Objective-C,只能调用 Objective-C 的公开头文件,就不能调用私有头文件吗?
如果组件以源码的方式被集成,是可行的。
Framework 中将私有头文件声明为一个私有 module(modulemap 内声明),由组件内的 Swift 源码 import 该私有 module 即可
Library 中使用 bridging header
如果组件是以二进制方式被集成,则不可以:
集成 Framework 二进制,由于 Swiftmodule 的传递依赖的这个特性,这种调用方式将导致其他组件依赖这个组件的二进制时,无法找到对应的私有 module,导致编译失败
集成 Library 二进制,由于编译二进制时无法同时开启 Bridging Header 和
BUILD_LIBRARY_FOR_DISTRIBUTION
,开启 Bridging Header 后该二进制将无法在不同的 Swift 版本下被集成
6.2 到底使用 Framework 还是 Library?
建议直接全部使用 Framework ,因为 Framework 针对 Swift 混编支持非常简单
对于最低支持版本在 iOS8 及以下的 App,由于 Apple 限制 ipa 中二进制包大小为 80M,为了缩小二进制体积,一般都采用内置动态库,如果动态库也建议使用 Framework,而非动态库的 Library
6.3 App 链接一个 Swift 二进制时报错?
当一个组件或产物需要链接其他 Swift 的产物时,比如 App、单测、动态库等,需要告诉 Xcode 开启 Swift 链接功能,开启方法就是添加一个 Swift 文件,否则报错。
七. 参考
官方文档
https://swift.org
What are Frameworks?
https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPFrameworks/Concepts/WhatAreFrameworks.html
Clang Module
http://clang.llvm.org/docs/Modules.html
Importing Objective-c Into Swift
https://developer.apple.com/documentation/swift/imported_c_and_objective-c_apis/importing_objective-c_into_swift
Xcode Release Notes
https://developer.apple.com/documentation/xcode_release_notes
Xcode Build Settings
https://xcodebuildsettings.com/#category-core-build-system
评论