雲音樂 Swift 混編 Module 化實踐

雲音樂技術團隊發表於2023-03-07
圖片來自:https://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 的宏定義時,會將其轉為全域性常量。不過轉換能力比較有限,僅支援基本的字面量值,以及基本運算子表示式。

例如:

#define MAX_RESOLUTION 1268
#define HALF_RESOLUTION (MAX_RESOLUTION / 2)

轉換為:

let MAX_RESOLUTION = 1268
let IS_HIGH_RES = 634

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

// Constant.h
#define PIC_SIZE CGSizeMake(60, 60)

+ (CGSize)picSize;

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

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

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

// 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 檔案的預處理結果,共只有幾行程式碼。

// main.m preprocess result.

#pragma clang module import UIKit /* clang -E: implicit import for #import <UIKit/UIKit.h> */

# 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 編譯留下了隱患。

看個具體的例子:

// <B/NMEventModel.h>

#import <UIKit/UIKit.h>

@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 <A/NMEvent.h>
#import <B/NMEventModel.h>

這是因為在非 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 階段對新增程式碼的標頭檔案引用方式進行校驗,避免不規範的程式碼合入。

參考資料:

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

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

https://developer.apple.com/documentation/swift/using-importe...

https://developer.apple.com/documentation/swift/importing-obj...

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

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

相關文章