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

Python實戰社羣

Java實戰社羣

長按識別下方二維碼,按需求添加

掃碼關注添加客服

進Python社羣▲

掃碼關注添加客服

進Java社羣

作者丨張渝、郭金

來源丨百度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 結構如下:

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 中不必添加 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 所需的公開頭文件

OTHER_SWIFT_FLAGS = $(inherited) -import-underlying-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)

// 當依賴組件是二進制時,可以不用設置該項
OTHER_LDFLAGS = $(inherited) -framework xxxA -framework xxxB ...

3.2 依賴Library組件

當依賴組件的Target類型是Library,配置稍微複雜一點:

3.2.1 當前組件包含Objective-C源碼

  • OTHER_CFLAGS:配置當前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:配置當前 Target 的頭文件搜索路徑,包含依賴的其他 Module 內配置的頭文件搜索路徑

HEADER_SEARCH_PATHS = $(inherited) "${xxxA_public_header_dir}" "${xxxB_public_header_dir}" ...
3.2.2 當前組件包含 Swift 源碼
  • OTHER_SWIFT_FLAGS;配置當前 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

當依賴的 Library 中包含 Swift 源碼,那麼該源碼編譯後將生成 swiftmodule,或依賴 Library 二進制中包含 swiftmodule,那麼當前組件需要配置:

  • SWIFT_INCLUDE_PATHS:依賴組件 swiftmodule 的搜索路徑,需要配置該路徑,目錄下包含 *.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. 以 #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 的源碼文件的組件

  • 存在 .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

相關文章:

Reviewer:袁晗光、王文軍、陳松、李政

程序員專欄 掃碼關注填加客服 長按識別下方二維碼進羣

近期精彩內容推薦:  

 朋友入職中軟一個月(外包華爲)就離職了!

 再見,胡阿姨!再見,共享單車!

 一代經典銷聲匿跡:WinXP徹底再見了!

 2021年1月編程語言排行榜


在看點這裏好文分享給更多人↓↓

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章