百度App Objective-C/Swift 组件化混编之路(二)- 工程化

783次阅读  |  发布于3年以前

前文《百度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 可以是以下类型中的一种:

.a 与 .framework 的区别是:Framework 是分层目录,它将共享资源(例如动态共享库,nib 文件,图像文件,本地化字符串,头文件和参考文档)封装在一个程序包中。动态库与静态库的区别是:系统根据需要将动态库加载到内存中,可以被多个应用程序同时访问,并在所有可能的应用程序之间共享资源的一份副本。静态库则是链接到某个应用程序的二进制中。

这些 Target 可能还存在一个或多个伴生 Target :

What's the Xcode target?

https://developer.apple.com/library/archive/featuredarticles/XcodeConcepts/Concept-Targets.html

对于伴生 Target,与 Swift 混编相关的只有单测;而对于主 Target,按照 Target 的文件组织形式可以分两类:

当 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 只会加载一次,避免多次引入并加载相同的头文件带来的编译耗时问题。所以 module 化后编译效率更高。

framework module SwiftOCMixture {
  umbrella header "SwiftOCMixture.h"

  export *
  module * { export * }
}


module SwiftOCMixture.Swift {
    header "SwiftOCMixture-Swift.h"
    requires objc
}

ModuleMap采用模块映射语言,但是到现在( 2020 年 Q3 为止)该语法依然不够稳定,所以建议:编写 modulemap 时需要尽可能使用少的关键字实现 module 功能,比如 framework、umbrella、header、extern、use。

建议 modulemap 内声明一个umbrella header,便于快速引用对应的头文件,但必须将所有公开的头文件填充到 umbrella header 文件内。否则将得到一个警告:

<module-includes>
Umbrella header for module 'XXX' does not include header 'absolute path to a public header'

不包含 umbrella header 的 module ,modulemap 中不必添加 <span style="font-size: 14px;">module * { export * } 包含 umbrella header 的 framework,不用配置任何(包括 MODULEMAP_FILE )即可自动 module 化

1.2.2 module 相关的 build setting 参数

上古时期,程序员通过 Makefile 来控制程序的编译链接过程。现如今在 IDE 的封装下,复杂度大大降低,只需要通过 IDE 来控制关键变量和自定义变量,在 Xcode 中,这个控制变量被称为 build setting,build setting 和 Module 化相关的变量主要有这些:- 对module自身的描述:

本文的后续部分也会用到 build setting 中的其他关键变量。

1.2.3 非 framework 的 module 处理

包含 Swift 源码的非 framework 的 module,建议在 buildphase 的 script 里处理编译后的两个事情:

至此,我们已经了解了单个组件的 module 化过程。

二. 组件内混编

根据官方说明,Target 内支持 Objective-C 和 Swift 语言的混编,无外乎解决两个问题:

下面我们针对 Framework 和 Library(非 Framework 静态库)两种类型,分别介绍下组件内的混编实现。

2.1 Framework

针对 Framework 类型的 Target 内混编,我们要做的就是什么都不做

简单吧,对于全新生成的有 umbrella header 的 Framework 默认就是 Module化 的,不需要做任何操作即可实现 Target 内混编。对于没有umbrella header的Framework,需要参照 如何实现 Module化 进行 Module 化改造。

因为 Xcode 在编译时已经对 framework 进行 Module 化处理,并自动生成该 Interface 头文件,编译成功时拷贝 Headers 文件夹内

2.2 Library

针对 Library 类型的 Target 内混编,我们首先依然需要参照如何实现 Module 化改造。

不足:无法开启跨 Swift 版本兼容的功能

OTHER_SWIFT_FLAGS = $(inherited) -import-underlying-module

不足:因为隐式创建下层 module,也会将 Swift 的类和方法包含到 Swift 的 Interface 头文件中,需要在 Swift 的类和方法之前添加 <span style="font-size: 14px;">@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:

// 当依赖组件是二进制时,可以不用设置该项
OTHER_LDFLAGS = $(inherited) -framework xxxA -framework xxxB ...

3.2 依赖Library组件

当依赖组件的Target类型是Library,配置稍微复杂一点:

3.2.1 当前组件包含Objective-C源码

OTHER_CFLAGS = $(inherited) -fmodule-map-file="${path_dir}/xxxA/module.modulemap" -fmodule-map-file="${path_dir}/xxxB/module.modulemap" ...
OTHER_LDFLAGS = $(inherited) -l"xxxA" -l"xxxB" ...
HEADER_SEARCH_PATHS = $(inherited) "${xxxA_public_header_dir}" "${xxxB_public_header_dir}" ...

3.2.2 当前组件包含 Swift 源码

OTHER_CFLAGS = $(inherited) -Xcc -fmodule-map-file="${path_dir}/xxxA/module.modulemap" -Xcc -fmodule-map-file="${path_dir}/xxxB/module.modulemap" ...

3.2.3 依赖 swiftmodule

当依赖的 Library 中包含 Swift 源码,那么该源码编译后将生成 swiftmodule,或依赖 Library 二进制中包含 swiftmodule,那么当前组件需要配置:

SWIFT_INCLUDE_PATHS = $(inherited) "${xxxA_swift_module_dir}" "${xxxB_swift_module_dir}" ...

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 在编译完成后拷贝操作:

// 仅供参考
COMPATIBILITY_HEADER_PATH="${公开头文件目录}/${PRODUCT_MODULE_NAME}-Swift.h"
ditto "${DERIVED_SOURCES_DIR}/${PRODUCT_MODULE_NAME}-Swift.h" "${COMPATIBILITY_HEADER_PATH}"

不同架构的 *-Swift.h 文件的合并方式:

  1. <span style="font-size: 14px;">#ifdef 架构 的方式进行(当各架构提供的接口没有区别的情况下,可直接使用模拟器架构)
  2. 合并为 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.h、二进制架构、*.swiftmodule*.interface等,EasyBox 工具链打包命令 box package 会全权处理,降低开发者们的配置难度和协同成本。

六. 常见问题

6.1 Swift 组件内调用 Objective-C,只能调用 Objective-C 的公开头文件,就不能调用私有头文件吗?

6.2 到底使用 Framework 还是 Library?

建议直接全部使用 Framework ,因为 Framework 针对 Swift 混编支持非常简单

对于最低支持版本在 iOS8 及以下的 App,由于 Apple 限制 ipa 中二进制包大小为 80M,为了缩小二进制体积,一般都采用内置动态库,如果动态库也建议使用 Framework,而非动态库的 Library

6.3 App 链接一个 Swift 二进制时报错?

当一个组件或产物需要链接其他 Swift 的产物时,比如 App、单测、动态库等,需要告诉 Xcode 开启 Swift 链接功能,开启方法就是添加一个 Swift 文件,否则报错。

七. 参考

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8