百度App Objective-C/Swift 組件化混編之路(二)- 工程化
作者丨張渝、郭金
來源丨百度App技術(shù)
前文《百度App Objective-C/Swift 組件化混編之路》已經(jīng)介紹了百度App 引入 Swift 的影響面評估以及落地的實施步驟,本文主要以依賴管理工具為支撐,介紹百度App 如何實現(xiàn)組件內(nèi)的 Objective-C/Swift 混編、單測、二進(jìn)制發(fā)布和集成,以及組件間的依賴和引用。
百度App 自研的依賴管理工具 EasyBox 工具鏈已經(jīng)把混編作為功能子集,如果你感興趣,可以閱讀百度App 技術(shù)公眾號往期文章《百度App iOS工程化實踐: EasyBox破冰之旅》。掌握 Xcode 編譯、鏈接選項等相關(guān)知識點,有助于理解混編的實現(xiàn)過程。

一. 組件Target類型 和 Module化
為解決大規(guī)模并行開發(fā)問題,百度App 將工程進(jìn)行了組件化拆分,并實現(xiàn)組件的二進(jìn)制化,一個組件即為一個獨立的功能單元和編譯單元,具有兩種形態(tài),源碼形態(tài)和二進(jìn)制形態(tài),開發(fā)過程中可以按需進(jìn)行組件的源碼/二進(jìn)制切換。所以我們要解決這兩種形態(tài)下的組件內(nèi)混編和組件間混合調(diào)用問題。
在介紹混編之前,我們先來了解兩個重要的概念:組件 Target 類型和 Module。
1.1 組件 Target 類型
EasyBox 工具鏈會為源碼形態(tài)的組件生成一個 Xcode 子工程和對應(yīng)的 Target,Target 可以是以下類型中的一種:
dynamic_library:動態(tài)庫,Xcode 7 之前擴(kuò)展名為 .dylib, Xcode 7 后是 .tbd ;目前官方環(huán)境并不允許為 iOS 平臺添加這種類型。
static_library:靜態(tài)庫,擴(kuò)展名 .a
static_framework:靜態(tài)庫,擴(kuò)展名 .framework
dynamic_framework:動態(tài)庫,擴(kuò)展名 .framework
.a 與 .framework 的區(qū)別是:Framework 是分層目錄,它將共享資源(例如動態(tài)共享庫,nib 文件,圖像文件,本地化字符串,頭文件和參考文檔)封裝在一個程序包中。動態(tài)庫與靜態(tài)庫的區(qū)別是:系統(tǒng)根據(jù)需要將動態(tài)庫加載到內(nèi)存中,可以被多個應(yīng)用程序同時訪問,并在所有可能的應(yīng)用程序之間共享資源的一份副本。靜態(tài)庫則是鏈接到某個應(yīng)用程序的二進(jìn)制中。
這些 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 混編相關(guān)的只有單測;而對于主 Target,按照 Target 的文件組織形式可以分兩類:
Library(擴(kuò)展名為 .a)
Framework(擴(kuò)展名為 .framework)
當(dāng) Target 中只有 Objective-C 源碼(.h、.m)時,無論哪種 Target,源文件之間都可以通過 import 頭文件的方式進(jìn)行引用,但 Swift 語言是強(qiáng)制以 module 形式 引用的,所以在 Swfit 中需要將 Target 的產(chǎn)物轉(zhuǎn)換為一個獨立的 module,供其他 module 依賴并引用。所以要實現(xiàn) Swift 混編,每個組件對應(yīng)的主 Target (源碼或二進(jìn)制)都必須以一個 module 的形式存在。下面介紹如何實現(xiàn) Target 內(nèi)的 module 混編、以及 Target 之間的 module 依賴。
1.2 Module 化
1.2.1 基本概念
module:是一個編譯單元,或構(gòu)建產(chǎn)物,對一個軟件庫的結(jié)構(gòu)化替代封裝,供鏈接器使用(更多介紹請查閱 Clang-Module:https://clang.llvm.org/docs/Modules.html#introduction)
umbrella header:module 對外公開的根頭文件,包含了這個 module 中所有其他公開頭文件的引用。以 Foundation 框架的根頭文件
<Foundation/Foundation.h>為例:

對編譯器來講,每次編譯過程一個 module 只會加載一次,避免多次引入并加載相同的頭文件帶來的編譯耗時問題。所以 module 化后編譯效率更高。
modulemap:描述 module 和 module header 間的關(guān)系,描述現(xiàn)有 header 如何映射到 module 的邏輯結(jié)構(gòu)。modulemap 結(jié)構(gòu)如下:
framework module SwiftOCMixture {umbrella header "SwiftOCMixture.h"export *module * { export * }}module SwiftOCMixture.Swift {header "SwiftOCMixture-Swift.h"requires objc}
ModuleMap采用模塊映射語言,但是到現(xiàn)在( 2020 年 Q3 為止)該語法依然不夠穩(wěn)定,所以建議:編寫 modulemap 時需要盡可能使用少的關(guān)鍵字實現(xiàn) module 功能,比如 framework、umbrella、header、extern、use。
建議 modulemap 內(nèi)聲明一個umbrella header,便于快速引用對應(yīng)的頭文件,但必須將所有公開的頭文件填充到 umbrella header 文件內(nèi)。否則將得到一個警告:
<module-includes>
Umbrella header for module 'XXX' does not include header 'absolute path to a public header'
不包含 umbrella header 的 module ,modulemap 中不必添加
module * { export * }
包含 umbrella header 的 framework,不用配置任何(包括 MODULEMAP_FILE )即可自動 module 化
1.2.2 module 相關(guān)的 build setting 參數(shù)
對module自身的描述:
DEFINES_MODULE:YES/NO,module 化需要設(shè)置為 YES
MODULEMAP_FILE:指向 module.modulemap 路徑HEADER_SEARCH_PATHS:modulemap 內(nèi)定義的 Objective-C 頭文件,必須在 HEADER_SEARCH_PATHS 內(nèi)能搜索到PRODUCT_MODULE_NAME:module 名稱,默認(rèn)和 Target name 相同
對外部module的引用:
FRAMEWORK_SEARCH_PATHS:依賴的 Framework 搜索路徑
OTHER_CFLAGS:編譯選項,可配置依賴的其他 modulemap 文件路徑 -fmodule-map-file=${modulemap_path}
HEADER_SEARCH_PATHS:頭文件搜索路徑,可用于配置源碼中引用的其他 Library 的頭文件
OTHER_LDFLAGS:依賴其他二進(jìn)制的編譯依賴選項
SWIFT_INCLUDE_PATHS:swiftmodule 搜索路徑,可用于配置依賴的其他 swiftmodule
OTHER_SWIFT_FLAGS:Swift 編譯選項,可配置依賴的其他 modulemap 文件路徑 -Xcc -fmodule-map-file=${modulemap_path}
本文的后續(xù)部分也會用到 build setting 中的其他關(guān)鍵變量。
1.2.3 非 framework 的 module 處理
包含 Swift 源碼的非 framework 的 module,建議在 buildphase 的 script 里處理編譯后的兩個事情:
編譯生成的 interface header,拷貝作為公開頭文件,供其他 Target 訪問編譯生成的 Swiftmodule,配置追加到 modulemap 文件中
至此,我們已經(jīng)了解了單個組件的 module 化過程。
二. 組件內(nèi)混編

根據(jù)官方說明,Target 內(nèi)支持 Objective-C 和 Swift 語言的混編,無外乎解決兩個問題:
Objective-C 可以引用 Swift 的類和方法
Swift 可以引用 Objective-C 的類和方法
下面我們針對 Framework 和 Library(非 Framework 靜態(tài)庫)兩種類型,分別介紹下組件內(nèi)的混編實現(xiàn)。
2.1 Framework
針對 Framework 類型的 Target 內(nèi)混編,我們要做的就是什么都不做。
簡單吧,對于全新生成的有 umbrella header 的 Framework 默認(rèn)就是 Module化 的,不需要做任何操作即可實現(xiàn) Target 內(nèi)混編。對于沒有umbrella header的Framework,需要參照 如何實現(xiàn) Module化 進(jìn)行 Module 化改造。
Objective-C 引用 Swift 在頭文件內(nèi)添加引入 Swift 的 Interface 頭文件即可,可以訪問 Swift 中以
@objc public或@objc open修飾的類和方法,或者 class 修飾為@objcMembers public#import <xxx/${ModuleName}-Swift.h>
因為 Xcode 在編譯時已經(jīng)對 framework 進(jìn)行 Module 化處理,并自動生成該 Interface 頭文件,編譯成功時拷貝 Headers 文件夾內(nèi)
Swift 引用 Objective-C 直接使用對應(yīng)的類和方法
2.2 Library
針對 Library 類型的 Target 內(nèi)混編,我們首先依然需要參照如何實現(xiàn) Module 化改造。
Objective-C 引用 Swift 與 Framework 的引用方式一致,在頭文件內(nèi)添加引入 Swift 的 Interface 頭文件即可,可以訪問 Swift 中以 @objc 修飾的類和方法,或者 class 修飾為 @objcMembers
Swift 引用 Objective-C 有顯式和隱式兩種方式 1、通過顯式配置橋接文件 BridingHeader,在橋接文件內(nèi) 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 的標(biāo)記:
-import-underlying-module該構(gòu)件標(biāo)記由 Xcode 隱式創(chuàng)建下層 Module,并隱式引入當(dāng)前 Module 內(nèi)所有的 Objective-C 的公開頭文件,Swift 可以直接訪問。該標(biāo)記需要配合USER_HEADER_SEARCH_PATHS或者HEADER_SEARCH_PATHS來搜索當(dāng)前 module 所需的公開頭文件
OTHER_SWIFT_FLAGS = $(inherited) -import-underlying-module
不足:因為隱式創(chuàng)建下層 module,也會將 Swift 的類和方法包含到 Swift 的 Interface 頭文件中,需要在 Swift 的類和方法之前添加
@objc open,經(jīng)測試發(fā)現(xiàn),這樣會造成 module 將近一秒延遲(即修改 Swift 的部分接口后 Interface 文件不立即變更)。
三. 組件間依賴
組件間依賴調(diào)用的核心依然是 Module 化,否則 Swift 無法調(diào)用其他組件,下面介紹組件間依賴調(diào)用相關(guān)的 Build Settings 參數(shù)。

單測也是組件間依賴的一種,單測的 Target 依賴其他需要測試的組件,并且該組件以源碼形態(tài)集成
集成單測,除了配置組件間依賴的
Build Settings,還需要注意兩個要點:
第一,需要鏈接對應(yīng)的靜態(tài)庫到目標(biāo)
testbundle第二,如果當(dāng)前單測是 Objective-C 源碼,而依賴的庫文件包含 Swift 相關(guān)的庫或 Target,必須在單測的 Target 內(nèi)添加空的 Swift 占位源文件(空文件真的可以,后綴為 .swift),否則鏈接時會報錯。
3.1 依賴 Framework 組件
如果依賴組件的Target類型是Framework,So Easy,因為Framework已經(jīng)是一個module了(包含umbrella header),直接配置BuildSettings:
FRAMEWORK_SEARCH_PATHS: 依賴的Framework搜索路徑,在對應(yīng)的路徑下查找
xxx.framework文件OTHER_LDFLAGS:當(dāng)依賴的組件是源碼時,可以有效將依賴的組件順序編譯,根據(jù)Xcode 10.2的升級說明(https://developer.apple.com/documentation/xcode-release-notes/xcode-10_2-release-notes)
// 當(dāng)依賴組件是二進(jìn)制時,可以不用設(shè)置該項OTHER_LDFLAGS = $(inherited) -framework xxxA -framework xxxB ...
3.2 依賴Library組件
當(dāng)依賴組件的Target類型是Library,配置稍微復(fù)雜一點:
3.2.1 當(dāng)前組件包含Objective-C源碼
OTHER_CFLAGS:配置當(dāng)前Target依賴的其他Module
OTHER_CFLAGS = $(inherited) -fmodule-map-file="${path_dir}/xxxA/module.modulemap" -fmodule-map-file="${path_dir}/xxxB/module.modulemap" ...
OTHER_LDFLAGS:同 3.1 依賴 Framework 組件
OTHER_LDFLAGS = $(inherited) -l"xxxA" -l"xxxB" ...
HEADER_SEARCH_PATHS:配置當(dāng)前 Target 的頭文件搜索路徑,包含依賴的其他 Module 內(nèi)配置的頭文件搜索路徑
HEADER_SEARCH_PATHS = $(inherited) "${xxxA_public_header_dir}" "${xxxB_public_header_dir}" ...
3.2.2 當(dāng)前組件包含 Swift 源碼
OTHER_SWIFT_FLAGS;配置當(dāng)前 Target 依賴的其他 Module
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
當(dāng)依賴的 Library 中包含 Swift 源碼,那么該源碼編譯后將生成 swiftmodule,或依賴 Library 二進(jìn)制中包含 swiftmodule,那么當(dāng)前組件需要配置:
SWIFT_INCLUDE_PATHS:依賴組件 swiftmodule 的搜索路徑,需要配置該路徑,目錄下包含
*.swiftmodule
SWIFT_INCLUDE_PATHS = $(inherited) "${xxxA_swift_module_dir}" "${xxxB_swift_module_dir}" ...
3.2.4 編譯順序控制
當(dāng)依賴的組件是 Library,并且包含 Swift 的源碼,需將當(dāng)前 Target 的 Scheme 編譯條件配置為非并行編譯 uncheck Parallelize Build(如下圖所示),達(dá)到控制編譯順序的目的,避免因為依賴組件還未生成的 *-Swift.h 文件(依賴組件編譯成功后生成),造成當(dāng)前組件源碼的編譯錯誤。

四. 混編組件二進(jìn)制打包
為了提升產(chǎn)品線的編譯速度,業(yè)界內(nèi)很多產(chǎn)品線均做了組件二進(jìn)制化,即將組件源碼編譯為多種架構(gòu)的二進(jìn)制,并合并架構(gòu)后以二進(jìn)制的方式引入工程,避免了大量源碼的重復(fù)編譯,提升編譯效率,對于 Swift 的組件來說,如何做二進(jìn)制化?
4.1 module 化
參考 1.2 Module 化要點
4.2 兼容性
雖然 ABI 穩(wěn)定了,但是根據(jù) Swift 的設(shè)計,各自 Swift 編譯器打出的二進(jìn)制并不能在其他版本使用,需要使用到跨 Swift 版本調(diào)用的 interface 文件(在編譯產(chǎn)物 swiftmodule 文件夾中),設(shè)置 BUILD_LIBRARY_FOR_DISTRIBUTION = YES 即可生成,但該標(biāo)記與bridging 沖突,即在混編的 Library 且使用 bridging header 的工程中不可用;如果真要使用 Library 又想 Swift 二進(jìn)制跨 Swift 版本兼容,參考 2.2 介紹的 -import-underlying-module
4.3 SWIFT_OBJC_INTERFACE_HEADER 文件合并
對于 Framework ,Swift 源碼編譯產(chǎn)生的 Objective-C Interface 文件會被自動拷貝到公開頭文件夾,只需要合并多架構(gòu) 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}"
不同架構(gòu)的 *-Swift.h 文件的合并方式:
以
#ifdef 架構(gòu)的方式進(jìn)行(當(dāng)各架構(gòu)提供的接口沒有區(qū)別的情況下,可直接使用模擬器架構(gòu))合并為 XCFramework 的形式
4.4 swiftmodule文件合并
對于包含 Swift 源碼的產(chǎn)物中將包含 swiftmodule 文件夾,直接合并兩個 swiftmodule 目錄即可,不同架構(gòu)以不同的文件名呈現(xiàn)
對于開啟
BUILD_LIBRARY_FOR_DISTRIBUTION的 module 來說,swiftmodule 文件夾內(nèi)包含 *.interface 即為跨 Swift 版本兼容文件
4.5 合并二進(jìn)制
使用 lipo 命令進(jìn)行二進(jìn)制架構(gòu)的常規(guī)合并,這里不做贅述
4.6 二進(jìn)制包
如下圖:模擬器架構(gòu) Framework 形態(tài)的 *.swiftmodule(.a的 *.swiftmodule與之類似),其中 x86_64-apple-ios-simulator.swiftinterface是跨 Swift 版本調(diào)用的 interface 文件

4.7 小知識:swiftmodule 的傳遞依賴性
已知:有組件 A 依賴組件 B,組件 B 依賴組件 C 在 Objective-C 中,B 對外暴露的頭文件中引用了 C 的公開頭文件,我們叫組件 B 傳遞依賴 C,結(jié)果就是編譯組件 A 時必須同時能找到組件 B 和組件 C 的頭文件,否則編譯失敗。
然而 Swift 并沒有公開頭文件一說,只要組件 B import C,導(dǎo)致 swiftmodule 中也明確標(biāo)記了 import C,當(dāng)組件 A import B 時,也同時 import C ,如果組件 A 找不到組件 C 的 module,那組件 A 將編譯失敗。
五. 總結(jié)
對于百度App 的開發(fā)者來說,不用去關(guān)心混編的是如何實現(xiàn)的,只需要跟正常開發(fā)一樣,組件內(nèi)引用所需的頭文件(#import <ModuleXX/xx.h>)或module(@import ModuleXX),組件間在聲明依賴后亦可直接引用頭文件或 module ,EasyBox 工具鏈會根據(jù)源碼文件或配置進(jìn)行module 化和 Xcode Build setting 相關(guān)的處理,以下情況將判定為需要 module 化:
存在 .swift 的源碼文件的組件
存在 .swiftmodule 或 *-Swift.h 文件的二進(jìn)制組件
宿主工程的 Boxfile 中顯式配置 module 化
組件的 boxspec 描述中聲明 modulemap 文件
對于混編組件的二進(jìn)制打包,開發(fā)者們也不用去關(guān)心如何處理編譯產(chǎn)物,諸如 *-Swift.h、二進(jìn)制架構(gòu)、*.swiftmodule、*.interface等,EasyBox 工具鏈打包命令 box package 會全權(quán)處理,降低開發(fā)者們的配置難度和協(xié)同成本。
六. 常見問題
6.1 Swift 組件內(nèi)調(diào)用 Objective-C,只能調(diào)用 Objective-C 的公開頭文件,就不能調(diào)用私有頭文件嗎?
如果組件以源碼的方式被集成,是可行的。
Framework 中將私有頭文件聲明為一個私有 module(modulemap內(nèi)聲明),由組件內(nèi)的 Swift 源碼 import 該私有 module 即可
Library 中使用 bridging header
如果組件是以二進(jìn)制方式被集成,則不可以:
集成 Framework 二進(jìn)制,由于 Swiftmodule 的傳遞依賴的這個特性,這種調(diào)用方式將導(dǎo)致其他組件依賴這個組件的二進(jìn)制時,無法找到對應(yīng)的私有 module,導(dǎo)致編譯失敗
集成 Library 二進(jìn)制,由于編譯二進(jìn)制時無法同時開啟 Bridging Header 和
BUILD_LIBRARY_FOR_DISTRIBUTION,開啟 Bridging Header 后該二進(jìn)制將無法在不同的 Swift 版本下被集成
6.2 到底使用 Framework 還是 Library?
建議直接全部使用 Framework ,因為 Framework 針對 Swift 混編支持非常簡單
對于最低支持版本在 iOS8 及以下的 App,由于 Apple 限制 ipa 中二進(jìn)制包大小為 80M,為了縮小二進(jìn)制體積,一般都采用內(nèi)置動態(tài)庫,如果動態(tài)庫也建議使用 Framework,而非動態(tài)庫的 Library
6.3 App 鏈接一個 Swift 二進(jìn)制時報錯?
當(dāng)一個組件或產(chǎn)物需要鏈接其他 Swift 的產(chǎn)物時,比如 App、單測、動態(tài)庫等,需要告訴 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
相關(guān)文章:
Reviewer:袁晗光、王文軍、陳松、李政
-End-
最近有一些小伙伴,讓我?guī)兔φ乙恍?nbsp;面試題 資料,于是我翻遍了收藏的 5T 資料后,匯總整理出來,可以說是程序員面試必備!所有資料都整理到網(wǎng)盤了,歡迎下載!

面試題】即可獲取
