雲音樂 Swift 混編 Module 化實踐

語言: CN / TW / HK

圖片來自:http://unsplash.com
本文作者:冰川

背景

雲音樂 iOS App 經歷多年的迭代,積累了大量的 Objective-C(以下簡稱 OC) 代碼,目前已經完成主工程殼化,各層組件關係如下:

組件化後混編的場景主要集中在 Framework 內混編和 Framework 之間混編,Framework 內的混編成本較低,重頭主要在 Framework 間的混編。

在雲音樂中集成的創新業務,因為依賴的歷史基礎庫較少,已經投入使用 Swift。主站業務遲遲沒有投入,主要原因是涉及到大量的 OC 業務基礎庫和公共基礎庫不支持 Swift 混編,OC 組件庫參與混編的前提是要完成 Module 化。

以上是我們實現混編計劃的幾個階段,本文主要介紹在支持雲音樂 Swift 混編過程中,Module 化階段的分析與實踐。

什麼是 Modules

早在 2012 蘋果就提出了 Modules 的概念(比 Swift 發佈還要早),Module 是組件的抽象描述,包含組件接口以及實現。它的核心目的是為了解決 C 系語言的擴展性和穩定性問題。

Cocoa 框架很早就支持了 Module,並且前向兼容,正因為它的兼容性,純 Objective-C 開發對它的感知可能不強。

AFramework.framework ├─ Headers ├─ Info.plist ├─ Modules │ └─ module.modulemap └─ AFramework

Module 化的 OC 二進制 Framework 組件,在 Modules 目錄下存在一個 .modulemap 格式的文件,它描述了組件對外暴露的能力。當引用的組件包含 modulemap,Clang 編譯器會從中查找頭文件,進行 Module 編譯,並將編譯結果緩存。

Clang 編譯器要求 Swift 引用的 Objective-C 組件必須支持 Module 特性。我們把 OC 組件支持 Module 的過程,稱為 Module 化。

如何開啟 Modules

Xcode Project Target 支持在 「Building Settings -> Defines Module」設置 Module 開關。

如果使用 CocoaPods 組件集成,支持如下幾種方式進行 Module 化:

  1. 在 Podfile 添加 use_modular_headers! 為所有 pod 開啟 Module;
  2. 在 Podfile 為每個 pod 單獨設置 :modular_headers => true
  3. 在 pod 的 podspec 文件中設置 s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
  4. 在 Podfile 使用 use_frameworks! :linkage => :static

前三種方式在編譯產物是 .a 靜態庫時生效,如果使用了 use_framework!,源碼編譯產物是 Framework,默認就會包含 modulemap。

Module 化現狀分析

雲音樂工程使用 CocoaPods 集成依賴庫,幾乎所有庫已經完成 Framework 靜態化,而大部分靜態庫都是在未打開 Module 下的編譯產物。

那麼要讓 OC 靜態庫支持 Module,直觀的方案是,直接打開 Module 化開關,重新構建 Framework 靜態庫,讓產物包含 modulemap。

然而直接打開開關,組件大概率會編譯失敗。原因主要有兩點:

  1. 組件的 Module 具有依賴傳遞性,當前組件打開 Module 編譯,要求它所有的依賴庫,都已經完成 Module 化。在雲音樂龐大的組件體系裏面,即使理清其中的依賴關係,用自動化的方式自下而上構建,成功的可能性也極低。
  2. 歷史代碼存在不少引用方式不規範,宏定義「奇淫技巧」,以及 PCH 隱式依賴等問題,這些問題導致組件庫本身無法正常 Module 編譯。

Module 化方案

目前雲音樂的二進制組件主要分為三種類型:

  • Module Framework
  • 非 Module Framework
  • .a 靜態庫

Module Framework 是在 Defines Module 打開時的編譯產物,這種類型沒有改造成本,只需要在 CI 階段,將不同架構的 Framework 封裝成 XCFramework 壓縮並上傳到服務器。

對於非 Module Framework 我們嘗試了一種成本比較低的方案,在組件庫 Module 關閉的條件下,先將其編譯成靜態庫,再用腳本自動化生成對應的 modulemap 文件,放到 Famework/Modules 目錄。

主動塞 modulemap 的方案之所以可行和 Clang Module 的編譯原理有關。當使用 #import <NMSetting/NMAppSetting.h> 引用依賴時, Clang 首先會去 NMSetting.framework 的 Header 目錄下查找對應的頭文件是否存在,然後在 Modules 目錄下查找 modulemap 文件。

modulemap 中包含的 umbrella header 對應的是組件公開頭文件的集合。如果引用的頭文件能找到,Clang 就會使用 Module 編譯。

``` // NMSetting.framework/Modules/NMSetting.modulemap

framework module NMSetting { umbrella header "NMSetting-umbrella.h"

export * module * { export * } } ```

Clang 並不關心 modulemap 來源,只會按照固定的路徑去查找它是否存在。所以採用主動添加 modulemap 的方式,能達到「欺騙」編譯器的目的。

這種方式的好處是,只要當前組件被引用時能正常 Module 編譯即可,不需要考慮它依賴組件的 Module 編譯是否有問題。缺點是不徹底,假設靜態庫組件公開頭文件,存在不符合 Module 規範的情況,即使有 modulemap,編譯時依然會拋出錯誤:

Could not build moudle 'xxx'.

對於未知的 Module 編譯問題,只能拉對應的源碼針對性的解決。

以下是我們遇到的一些比較典型的 Module 問題,以及對應的解決思路。

Module 化問題

宏定義找不到

在使用 OC 開發時,習慣於在 .h 文件定義一些宏,方便外部訪問,然而 Swift 不支持定義宏,在引用 OC 的宏定義時,會將其轉為全局常量。不過轉換能力比較有限,僅支持基本的字面量值,以及基本運算符表達式。

例如:

```ObjC

define MAX_RESOLUTION 1268

define HALF_RESOLUTION (MAX_RESOLUTION / 2)

```

轉換為:

Swift let MAX_RESOLUTION = 1268 let IS_HIGH_RES = 634

宏定義的內容如果包含 OC 的語法實現,那麼這個宏對 Swift 是不可見的。如果要支持 Swift 訪問,需要對宏進行包裝。

```ObjC // Constant.h

define PIC_SIZE CGSizeMake(60, 60)

  • (CGSize)picSize;

// Constant.m + (CGSize)picSize { return PIC_SIZE; } ```

以上的宏問題還算比較直觀,在雲音樂組件中,還存在一些使用 #include 預處理指令,來使用宏的場景。

C 系語言傳統的 #include 引用是基於文本替換的方式實現的,利用這個特性能夠屏蔽宏的實現細節。

```c // A.h

define NM_DEFINES_KEY(key, des) FOUNDATION_EXTERN NSString *const key;

include "ItemList.h"

undef C

// ItemList.h NM_DEFINES_KEY(AKey, @"a key") NM_DEFINES_KEY(BKey, @"b key") ```

在非 Clang Module 下編譯,上述代碼能夠正常工作,然而在打開 Module 之後,宏定義 NM_DEFINES_KEY 就找不到了。

這是因為 Module 編譯時,#include 不再是簡單的文本替換模式,而是與 module 建立鏈接關係。

下面是一個開啟 Module 編譯的例子,main.m 文件的預處理結果,共只有幾行代碼。

```objc // main.m preprocess result.

pragma clang module import UIKit / clang -E: implicit import for #import /

10 "/Users/jxf/Documents/Workspace/Demo/ModuleDemo/ModuleDemo/main.m" 2

int main(int argc, char * argv[]) { NSString * appDelegateClassName; } ```

如果未開啟 Module,UIKit 的所有頭文件都會被複制進來,代碼量將達到數萬行。

正因為這種差異,Module 編譯時 #include "ItemList.h" 不會將內容複製到 A.h 文件,就會導致無法訪問到它的宏定義。

Module 提供了相應的解決方案,就是自定義 modulemap。前面已經介紹,默認情況下 modulemap 的格式為:

``` framework module FrameworkName { umbrella header "FrameworkName-umbrella.h"

export * module * { export * } } ```

FrameworkName-umbrella.h 包含當前組件對外暴露的所有頭文件,該文件會在使用 CocoaPods 集成時同步生成。我們可以使用 textual header 關鍵聲明頭文件,這樣該頭文件在被導入時,會降級為文本替換的形式。

``` framework module FrameworkName { umbrella header "FrameworkName-umbrella.h" textual header "ItemList.h"

export * module * { export * } } ```

自定義 modulemap 還有一些額外的配置,需要自己生成組件公開的頭文件集合 umbrella.h,並在 podspec 指定該 modulemap,。

s.module_map = "#{s.name}.modulemap"

在我們 CI 打包流程中,如果檢測到組件自定義了 modulemap 就會使用自定義的文件,不再自動塞入模版化的 modulemap。

如果 ItemList.h 不需要對外暴露,還有一種更簡單的方案,直接在 podspec 將其聲明為私有,這樣在靜態庫 Headers 目錄下就不會導出,也就不會出現 Module 編譯問題。

頭文件缺失

雲音樂業務基礎庫默認會使用 PCH(Precompiled Headers) 文件,它的好處主要有兩點,一是能一定程度上提高編譯效率,二是為當前組件庫提供統一外部依賴,這種依賴關係是隱式的,PCH 已經添加的依賴,組件內使用時不需要再手動 import。

這種方式確實能提供便利性,隨着業務的快速迭代,大家也都適應了不引頭文件的習慣,然而依靠隱式依賴關係,為 Module 編譯留下了隱患。

看個具體的例子:

```ObjC //

import

@interface NMEventModel : NSObject @property (nullable, nonatomic, strong) NMEvent *event; @end ```

B 組件中的 NMEventModel 引用了 NMEvent,它來自另一個組件庫 A,A 已經在 B.pch 中 import,所以在 B 組件源碼編譯時能通過隱式依賴找到 NMEvent

當 C 組件同時引用 A 組件和 B 組件的靜態庫時,因為 B 組件靜態化後已經沒有 PCH,正常來説訪問 NMEventModel.h 應該編譯報找不到 NMEvent 才對,而實際上在非 Module 編譯時是不會有問題的。

``` // C/Header.h

import

import

```

這是因為在非 Module 環境下 #import <A/NMEvent.h> 會把 NMEvent 的定義複製到當前文件,為 NMEventModel.h 編譯提供了上下文環境。

然而當開啟 Module 編譯時,會報 B 組件是非 Module 的錯誤(Module 依賴傳遞性),錯誤原因是 NMEventModel.h 頭文件找不到NMEvent類。

其實還是前面介紹的 Clang Module import 機制改變的原因,開啟 Module 後,會使用獨立的上下文編譯 B 組件的 NMEventModel.h,缺少了NMEvent上下文。

要解決該場景下的問題,比較粗暴的方式是,在 Module 編譯上下文中注入它的 PCH 依賴。但是對於二進制組件來説,它已經沒有 PCH 了,如果顯式地暴露 PCH,僅僅是為了頭文件的 Module 編譯,會導致依賴關係進一步惡化。

我們對這種情況做了針對性的治理,補充缺失的頭文件依賴,歷史庫解決完一波後,默認都開啟 Module 編譯,如果開發過程中,使用不當編譯器會及時反饋。對於新組件庫增加 PCH 卡口限制。

.a 靜態庫

Module 化的關鍵是需要有 modulemap 文件,而歷史的二方、三方庫,有些是.a的靜態庫。

.a 文件只是可執行文件的集合,不包含資源文件,針對這種情況需要使用 Framework 進行二次封裝。

主要有兩種方案:

第一種,在 .a 文件目錄注入一個空的 .swift 文件,並在 podspec 指定 source_filesswift_version,pod install 時 Cocopods 會自動生成對應的 modulemap 文件。

第二種,採用 CocoaPods 插件,在 pre_install 階段,設置pod_target.should_build,讓 CocoPods 自動生成 modulemap。

方案二的成本相對較低,最終我們採用了方案二。

總結

Objective-C 組件庫 Module 化是支持 Swift 混編的基礎,Module 化的核心是提供 modulemap 文件,要生成 modulemap,組件需打開 Module 編譯,這個過程中可能會遇到各種未知問題。

雲音樂在治理過程中遇到的問題相對比較收斂,主要集中在 Module 編譯方式的變化,導致一些上下文信息丟失,一部分問題能夠通過自動化的方案解決,而有些問題仍然需要進行人工驗證。

規劃展望

Module 組件防劣化。 在 Module 化完成後,需防止再次劣化,我們在本地源碼開發階段開啟 Module,儘可能早的暴露問題。針對 PCH 禁止公開的頭文件對它隱式依賴,並限制新組件使用 PCH。

Objective-C 接口兼容性改造。 OC 接口轉成 Swift 可能會存在一些安全性和易用性問題,甚至有些 API 無法實現自動橋接,都需要進行改造。

規範化頭文件引用。 頭文件不規範問題,導致 Module 編譯失效,也是比較常見的例子。通過在 CI 階段對新增代碼的頭文件引用方式進行校驗,避免不規範的代碼合入。

參考資料:

http://clang.llvm.org/docs/Modules.html#id12

http://llvm.org/devmtg/2012-11/Gregor-Modules.pdf

http://developer.apple.com/documentation/swift/using-imported-c-macros-in-swift

http://developer.apple.com/documentation/swift/importing-objective-c-into-swift

http://tech.meituan.com/2021/02/25/swift-objective-c.html

本文發佈自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!