1. 程式人生 > Android開發 >如何在模組化/元件化專案中實現 ObjC-Swift 混編?

如何在模組化/元件化專案中實現 ObjC-Swift 混編?

關鍵詞:模組化/元件化、ObjC-Swift 混編、Swift 靜態庫、ABI Stability、Module Stability、LLVM Module、Umbrella Header

目錄

  • 基礎準備工作
    • 在一個 App Target 內部混編
    • 在一個 Framework Target 中混編
  • 踩坑之旅
    • 專案背景
    • 靜態庫子工程的整合
    • 靜態連結問題
    • 動態連結問題
    • ABI Stability 和Always Embed Swift Standard Library
      選項
    • 當模組化/元件化專案遇到 Swift 靜態庫
      • ObjC 模組呼叫 Swift 模組
      • Swift 模組呼叫 Swift 模組
      • Module Stability
      • Swift 模組呼叫 ObjC 模組
      • LLVM Module 和 Umbrella Header
    • 除錯問題
  • 總結

一、基礎準備工作

在正式開始實踐 Swift-ObjC 混編之前,我們有一些問題是繞不過去的,比如:

  • Swift 和 ObjC 混編,我們怎麼開始?官方檔案有相關的介紹嗎?
  • 在模組化/元件化的專案中,Swift 和 ObjC 怎麼混編?
  • 業界中已經開始 Swift-ObjC 混編的專案,他們是怎麼做的?
  • 我們的現狀如何,針對這些已有的經驗需要做哪些考量?我們應該怎麼做?
  • 如果在現有的 ObjC 專案中引入 Swift,會帶來哪些影響?在哪些方面會有限制?
  • ...

在 Apple 的官方檔案中有關於 Language Interoperability 的詳細介紹,主要是從 ObjC 遷移到 Swift 的角度來描述的,總結下來主要是以下三點:

  • 如何調整專案中現有的 ObjC 和 C 程式碼的 API,以提供給 Swift 呼叫,比如新增 nullability 相關的巨集和關鍵字,新增 Swift API 別名等等
  • 各種基礎資料型別在 Cocoa Framework 與 Swift 之間的轉換關係
  • 如何在 Swift 程式碼中呼叫 ObjC 程式碼,以及如何在 ObjC 程式碼中呼叫 Swift 程式碼

這裡我們重點關注的是如何實現 Swift 程式碼和 ObjC 程式碼的相互呼叫

1. 在一個 App Target 內部混編

如果是在一個 App Target 內部混編的話,當我們在 ObjC 專案中新建 Swift 檔案時或者在 Swift 專案中新建 ObjC 檔案時,Xcode 都會自動幫你新建一個 Objective-C bridging header file(當然我們也可以手動建立),我們可以在這個檔案中匯入需要暴露給 Swift 程式碼呼叫的 ObjC 標頭檔案,這樣我們就能在 Swift 中呼叫 ObjC 的程式碼了。

圖 1 Objective-C bridging header 檔案的建立

圖 2 在 Swift 中呼叫 ObjC 的程式碼

如果我們想在 ObjC 程式碼中呼叫 Swift 的程式碼,只需要寫上一行 import "ProductModuleName-Swift.h"(這裡的 ProductModuleName表示 target 的名字)就可以了,因為在編譯時,編譯器會自動給專案中的 Swift 程式碼生成一個 ProductModuleName-Swift.h 的標頭檔案(這個檔案是編譯產物,我們在 build 目錄可以看到它),暴露給 ObjC 使用。

圖 3 在 ObjC 中呼叫 Swift 的程式碼

2. 在一個 Framework Target 中混編

除了在一個 App Target 內部混編之外,還有一種情況是當我們要寫一個 Library 或者 Framework 給別人用時,這個時候如果有 ObjC 和 Swift 的混編,Objective-C bridging header 的方式已經不適用了,如果我們用了這個標頭檔案,Xcode 在預編譯時也會警告我們。

先來看看 Swift 怎麼呼叫 ObjC,正確的做法是將 Build Settings 中的 Defines Module 選項設定為 YES, 然後新建一個 umbrella header,再將需要暴露給(內部的) Swift 呼叫的 ObjC 的標頭檔案在這個 umbrella header 中匯入(LLVM Module 和 umbrella header 是兩個新概念,後面會做具體介紹)。

如果要想在 ObjC 呼叫 Swift,同樣也要將 Build Settings 中的 Defines Module 選項設定為 YES,然後在要引用 Swift 程式碼的 ObjC 檔案中匯入編譯器生成的標頭檔案 #import <ProductName/ProductModuleName-Swift.h>

參考

二、踩坑之旅

1. 專案背景

--------------------------------------------------     
              Hotel | HotelOrder | ...                   業務層
--------------------------------------------------
      HotelFoundation | HotelModel | ...               業務基礎層
--------------------------------------------------
  Network | Foundation | MapKit | RNKit | ...          基礎框架層
--------------------------------------------------
 
複製程式碼

圖 4 筆者所在公司 iOS 客戶端架構示意圖

目前筆者所在公司的專案整體架構是採用模組化設計的,而且整個專案完全都是使用 ObjC/C 實現的,在實際開發時,各模組既可以以原始碼的形式使用,也可以以.a + .h + 資源 bundle 的形式使用,簡而言之,既可以原始碼依賴,也可以是靜態庫依賴。那麼我們可以直接在專案中使用 Swift 靜態庫嗎?

圖 5 專案結構示意圖(簡化模型)

我們都知道,從 Xcode 9 開始,Apple 就開始支援 Swift 靜態庫的使用了,所以我們現有的專案架構並不需要調整,引入 Swift 程式碼的話是可以以靜態庫的形式出現的。

2. 靜態庫子工程的整合

我們要做的第一步,就是建立一個 Swift 靜態庫工程,然後再把它作為子工程整合到 ObjC 主工程中去。

大概的步驟如下:

  • 建立 Swift 靜態庫工程(這裡我們給它取個名字,叫 SwiftLibA,主工程叫 MainProject
  • 在主工程中整合 Swift 靜態庫工程
    • 新增子工程:將 Swift 靜態庫工程的 xcodeproj 檔案拖到主工程中
    • 新增構建依賴:在 Build Phases 面板的 Dependencies 中新增這個靜態庫的 target 為構建依賴
    • 新增要連結的靜態庫:在 Build Phases 面板的 Link Binary With Libraries 中連結這個 Swift 靜態庫
    • 匯出 xxx-Swift 標頭檔案:在 Swift 靜態庫工程的 Run Script Phase 中新增指令碼,將編譯器生成的 SwiftLibA-Swift 標頭檔案複製到 build 目錄下(如圖 6 所示)
  • 在 ObjC 程式碼中呼叫 Swift API
    • 在 Swift 程式碼中新增 @objcpublic 等關鍵字
    • 在 ObjC 程式碼中新增 #import <SwiftLibA/SwiftLibA-Swift.h>(這裡的 SwiftLibA 是新新增的靜態庫的名字)

圖 6 在主工程中整合 Swift 靜態庫工程

圖 7 複製 xxx-Swift 標頭檔案到 build 目錄下

示例程式碼

@objcMembers
public class SwiftLibA: NSObject {
    public func sayHello() {
        print("Hello,this is Swift world!")
    }
}

複製程式碼
@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    [[SwiftLibA new] sayHello];
}

@end
複製程式碼

問題:

1.為什麼需要設定 Dependencies

設定 Dependencies 是為了告訴 Xcode build system 在編譯主工程之前,需要先編譯哪些其他的 target,簡而言之,就是編譯依賴。

2.為什麼需要設定 Link Binary With Libraries

Xcode 在 build 主工程時,會先編譯好各個子工程,最後再連結成一個可執行檔案,通過這個 Link Binary With Libraries 設定,我們可以指定需要參與連結的靜態庫。

3.為什麼需要複製 xxx-Swift 標頭檔案到 build 目錄下?

因為編譯時自動生成的標頭檔案是在 Intermediates 目錄中各子工程所屬的 DerivedSources 中,比如在我的電腦上就是 /Users/ShannonChen/Library/Developer/Xcode/DerivedData/MainProject-aptbbpsumoitdlhbzjckyglkspoi/Build/Intermediates.noindex/SwiftLibA.build/Debug-iphonesimulator/SwiftLibA.build/DerivedSources/SwiftLibA-Swift.h,而主工程在編譯時會到 Build 目錄下的 Products 目錄去找標頭檔案,在我的電腦上就是 /Users/ShannonChen/Library/Developer/Xcode/DerivedData/MainProject-aptbbpsumoitdlhbzjckyglkspoi/Build/Products/Debug-iphonesimulator/include,所以主工程或者其他子工程在編譯時就找不到這個標頭檔案了。

因此,我們就需要把這個 xxx-Swift 標頭檔案複製到 build 目錄下,具體指令碼內容如下:

# 將編譯器生成的 xxx-Swift 標頭檔案拷貝到 build 目錄下的 include 目錄中
include_dir=${BUILT_PRODUCTS_DIR}/include/${PRODUCT_MODULE_NAME}/
mkdir -p ${include_dir}
cp ${DERIVED_SOURCES_DIR}/*-Swift.h ${include_dir}
複製程式碼

參考:

3. 靜態連結問題

整合好 Swift 靜態庫之後,我們再 build 一下,發現在連結時仍然會報錯。

圖 8 靜態連結時報錯

根據報錯資訊來看,是因為找不到 swiftFoundation 這些動態庫,這是由於我們的主工程是純 ObjC 專案,所以我們需要告訴 Xcode build system 這些 Swift 動態庫的路徑。

Build Settings tab 下找到 Library Search Paths,新增上 $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME),另外還需要新增 Swift 5.0 的動態庫所在的路徑 $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)

這兩個目錄都可以在我們的電腦上看到:

  • /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphoneos
  • /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift-5.0/iphoneos

圖 9 Swift 標準庫

參考:

4. 動態連結問題

靜態連結的問題已經解決了,此時按下 ⌘+R,模擬器啟動後發生崩潰。控制檯上的日誌資訊顯示 dyld: Library not loaded: @rpath/libswiftCore.dylib,這是因為程式啟動時 Swift 動態庫載入失敗了。

圖 10 程式啟動時發生崩潰

為瞭解決這個問題,我們需要設定兩個地方(只要你的專案 iOS Deployment Target 是 12.2 以下,這兩個就都需要設定):

  • 針對 iOS 12.2 及以後的系統,需要在 Build Settings tab 下的 Runpath Search Path 中最前面新增 /usr/lib/swift
  • 針對 iOS 12.2 以前的系統,需要將 Build Settings tab 下的 Always Embed Swift Standard Libraries 設定為 YES

為什麼我們要分別針對 iOS 12.2 之前和之後的系統做不同的設定呢?將 Always Embed Swift Standard Libraries 設定為 YES 是不是意味著每次打包時都會把 Swift 標準庫打進去呢?

參考:

5. ABI Stability 和 Always Embed Swift Standard Library 選項

2019 年對 iOS 開發者來說,最大的新聞莫過於 Swift ABI 終於穩定了。ABI Stability 意味著什麼呢?ABI Stability 也就是 binary 介面穩定,在執行的時候只要是用 Swift 5.0 或更高版本的編譯器(Swift 5.0 對應 Xcode 10.2)構建出來的 app,就可以跑在任意的 Swift 5.0 或更高版本的 Swift runtime 上了。這樣,我們就不需要像以往那樣每次打一個新的 app 時都要帶上一套 Swift runtime 和 standard library 了,iOS 和 macOS 系統裡就會內建一套 Swift runtime 和 standard library。

圖 11 在這個例子中,基於 Swift 5.0 構建出來的 app 可以直接在內建了 Swift 5 或者 Swift 5.1,甚至 Swift 6 標準庫的系統上執行

但是如果你用的是 Swift 5.0 以前版本的編譯器,那麼打包時還是會帶上一套 Swift runtime 和 standard library。

另外,對於用 Swift 5.0 或更高版本的編譯器構建出來的 app,在釋出 app 時,Apple 將根據 iOS 系統建立不同的下載包。對於 iOS 12.2 及以上的系統,因為系統內建了 Swift 5 的 runtime 和 standard library,所以 app 中不再需要嵌入 Swift 的庫,它們會被從 app bundle 中刪掉。但是對於 iOS 12.2 以下的系統,因為系統中沒有內建 Swift 5 的 runtime 和 standard library,所以打包時仍然需要帶上。

理解了什麼是 ABI Stability,就好理解我們前面在 Build Settings 所做的兩個設定了。

app 在啟動/執行時,會先看 app bundle 中有沒有 Swift runtime,如果找不到,動態連結器 dyld 會到 runpath 路徑下查詢 dylib(這個 runpath 路徑是一個系統目錄路徑)。所以我們針對 iOS 12.2 及以後的系統添加了 Runpath Search Path:/usr/lib/swift,針對 iOS 12.2 以前的系統設定了 Always Embed Swift Standard Library

圖 12 新增 Runpath Search Path

Always Embed Swift Standard Library 曾經叫做 Embedded Content Contains Swift Code,字面上看上去像是“總是嵌入 Swift 標準庫”,但是實際上這裡只是告訴 build system 要做的事,並不代表使用者手機上下載的 app 是這樣的,因為在釋出 app 時,app thinning 會自動根據目標系統來決定是否將 app bundle 中的 Swift 標準庫刪掉。

圖 13 app thinning 會自動根據目標系統來決定是否將 app bundle 中的 Swift 標準庫刪掉

那麼這個 Always Embed Swift Standard Library 是用來告訴 build system 做什麼的呢?只要你的 target 會引用到 Swift 檔案或者庫,就需要把它設定為 YES,比如我們這裡的主工程用到了 Swift 靜態庫,所以就需要設定為 YES,還有一種情況是你的 target 是一個測試工程,但是引用了 Swift 程式碼,那麼也需要設定為 YES。另外,筆者試驗了一下,如果給一個純 ObjC 的專案中添加了一個 Swift 檔案,Xcode 會自動將這個選項設定為 YES

圖 14 設定 Always Embed Swift Standard Library

參考:

6. 模組化/元件化

前面提到過,筆者所在公司的 iOS 專案是採用的是模組化架構,而模組之間是有依賴關係的。一般是上層模組依賴於下層的模組,如圖 4 所示。

這裡先說明一下我們這裡所說的模組的概念,在我們的專案中,一個 ObjC 模組就是 .a 靜態庫 + .h 標頭檔案 + bundle 資原始檔 的組合。

6.1 ObjC 模組呼叫 Swift 模組

如前面所說,ObjC 呼叫 Swift 程式碼時,只需要匯入編譯 Swift 模組時自動生成的標頭檔案 xxx-Swift.h 就可以了。

比如,模組 ObjCLibA 呼叫模組 SwiftLibA:

#import "ObjCLibA.h"
#import <SwiftLibA/SwiftLibA-Swift.h>

@implementation ObjCLibA

- (void)sayHello {
    [[SwiftLibA new] sayHelloWithName:@"ObjCLibA"];
}

@end
複製程式碼

這樣的確沒問題,但是考慮到持續持續交付平臺上各個模組都是獨立編譯的情況,像上面的這個例子中,如果單獨編譯模組 ObjCLibA 的話,就會出現標頭檔案找不到的錯誤: 'SwiftLibA/SwiftLibA-Swift.h' file not found

圖 15 模組 ObjCLibA 呼叫模組 SwiftLibA,(a)編譯主工程沒問題,(b)但是單獨編譯模組 ObjCLibA 就報錯了

這是因為 SwiftLibA-Swift.h 檔案是編譯模組 SwiftLibA 時的產物,是生成在 build 目錄中,而不是工程程式碼所在的目錄中。這一點我們在前面已經討論過,這裡不再贅述。

我們都知道使用 #import 指令匯入標頭檔案有兩種形式,#import "xxx.h"#import <xxx.h>,編譯器在編譯 ObjC 程式碼時會根據不同的指令形式去搜索標頭檔案,對於前者來說是到專案(原始碼)所在目錄下搜尋,對於後者是到環境變數所指的目錄或者指定目錄下去搜索的。

所以要想解決這個問題,我們可以換個思路,這個 SwiftLibA-Swift.h 檔案是根據我們寫的 Swift 程式碼公有 API 生成的,那麼我們每次修改 Swift 程式碼的公有 API 時,它就會更新一次,所以,我們可以在每次 build 這個模組時把最新生成的拷貝到原始碼所在目錄下(這個檔案需要加入到版本控制中和其他程式碼一起提交),然後再把新的路徑新增到 ObjC 模組的 Header Search Path 中,另外,ObjC 模組中標頭檔案匯入的方式也要改成雙引號的形式。

完整指令碼如下:



generated_header_file=${DERIVED_SOURCES_DIR}/*-Swift.h
include_dir=${BUILT_PRODUCTS_DIR}/include/${PRODUCT_MODULE_NAME}/

# 將編譯器生成的 xxx-Swift 標頭檔案拷貝到 build 目錄下的 include 目錄中
mkdir -p ${include_dir}
cp ${generated_header_file} ${include_dir}

# 去掉 xxx-Swift.h 檔案頭部註釋中的編譯器的版本號
sed -i "" "s/^\/\/ Generated by Apple.*$/\/\/ Generated by Apple/g" ${generated_header_file}

# 拷貝 xxx-Swift.h 檔案到工程原始碼目錄 
header_file_in_proj=${SRCROOT}/${PROJECT}-Swift.h
needs_copy=true
if [ -f "$header_file_in_proj" ]; then
    echo "${header_file_in_proj} 已存在"
    
    new_content=$(cat ${generated_header_file})
    old_content=$(cat ${header_file_in_proj})
    if [ "$new_content" = "$old_content" ];then
        echo "檔案內容一致,無需再Copy:"
        echo "${generated_header_file} "
        echo "${header_file_in_proj} "

        needs_copy=false
    fi
fi

if [ "$needs_copy" = true ] ; then
    
    echo "檔案內容不一致,需要Copy:"
    echo "複製檔案: "
    echo "${generated_header_file} "
    echo "${header_file_in_proj} "

    cp ${generated_header_file} ${header_file_in_proj}
fi

複製程式碼

圖 16 將編譯器生成的標頭檔案拷貝到原始碼目錄

參考:

6.2 Swift 模組呼叫 Swift 模組

ObjC 模組呼叫 Swift 模組的問題解決了,那麼如果 Swift 模組呼叫 Swift 模組呢?會不會也存在類似的問題?

先來看一個例子,還是前面的那個示例專案,只不過多了一個模組 SwiftLibB:

- MainProject
  - ObjCLibA
  - SwiftLibA
  - SwiftLibB
複製程式碼

然後我們在模組 SwiftLibA 中呼叫了模組 SwiftLibB 中的 API:

import Foundation
import SwiftLibB

@objcMembers
public class SwiftLibA: NSObject {
    
    public func sayHello(name: String) {
        SwiftLibB().sayHello(name: name)
        print("Hello,this is " + name + "!")
        print("-- Printed by SwiftLibA")
    }
}

複製程式碼

這個時候如果編譯主工程是沒問題的,但是如果單獨編譯模組 SwiftLibA 就會報錯:No such module 'SwiftLibB'

圖 17 模組 SwiftLibA 呼叫模組 SwiftLibB,(a)編譯主工程沒問題,(b)但是單獨編譯模組 SwiftLibA 就報錯了

這個問題看上去跟前面遇到的 ObjC 模組呼叫 Swift 模組的問題是一樣的,但是我們要知道 Swift 中是沒有標頭檔案的概念的,那麼 Swift 是通過什麼方式暴露公開 API 的呢?

不同於 C-based 語言使用 manually-written 標頭檔案來提供公開介面,Swift 是通過一個叫做 swiftmodule 的檔案來描述一個 library 的 interface,這個 swiftmodule 檔案是編譯器自動生成的。我們開啟 SwiftLibB 模組的 build 目錄,可以看到編譯器自動生成的 SwiftLibB.swiftmodule,這個 SwiftLibB.swiftmodule 目錄下有兩種檔案:swiftmodule 檔案和 swiftdoc 檔案。swiftmodule 檔案和 swiftdoc 檔案都是二進位制檔案,我們可以用反編譯工具檢視其中的內容,swiftmodule 檔案裡面儲存了模組的資訊,而 swiftdoc 檔案則儲存了原始碼中的註釋內容。

圖 18 build 目錄下的 swiftmodule 檔案

看到這裡,你可能會想我們只要像匯出 xxx-Swift.h 檔案一樣,把這幾個 swiftmodule 檔案匯出到原始碼目錄,然後再設定 SwiftLibA 的 import path,另外再把這幾個檔案加入 git 版本控制中就解決了。

是的,我一開始也是這麼想的,然後我就這麼去做了,單獨編譯 SwiftLibA 確實問題,但是提交到 git 遠端倉庫之後,持續交付平臺上的 SwiftLibA 模組卻編譯報錯了:

... error:
Module compiled with Swift 5.1 cannot be imported by the Swift 5.1.2 compiler
...
複製程式碼

Module Stability

上面的方法之所以行不通,是因為 swiftmodule 檔案跟編譯器版本是繫結的,在 Swift 5.0 之前,Apple 官方沒有提供解決辦法,在釋出 Swift 5.0 時,除了 ABI Stability 之外,Apple 還解決了一個重要的,就是 Module Stability,也就是我們這裡遇到的問題。

ABI Stability 解決的是不同 Swift 版本的程式碼在執行時的相容性問題,而 Module Stability 則要解決的是不同 Swift 版本的程式碼在編譯時的相容性問題。具體介紹可以看一下 Swift 官方部落格 ABI Stability and More 和 WWDC 2019 的視訊 Binary Frameworks in Swift,以及社群的討論 Plan for module stabilityUpdate on Module Stability and Module Interface Files

圖 19 swift.org 官方部落格上關於 Module Stability 的介紹

針對 Module Stability,Apple 提供的解決方案是 swiftinterface 檔案,swiftinterface 檔案是作為 swiftmodule 的一個補充,它是一個描述 module 公開介面的文字檔案,不受編譯器版本限制。比如,你用 Swift 5.0 的編譯器編譯出了一個 library,它的 swiftinterface 檔案可以在 Swift 5.1 的編譯器上使用。

我們現在開啟 SwiftLibB 的 Build Setting,找到 Build Options -> Build Libraries for Distribution,把它設定為 YES,重新編譯一下,再看看 build 目錄中生成的 SwiftLibB.swiftmodule,裡面多了幾個 swiftinterface 檔案。

圖 20 Build Libraries for Distribution 選項

圖 21 編譯器自動生成的 swiftinterface 檔案

我們可以開啟 swiftinterface 檔案跟原始碼對一下,它其實就是一個 swift 標頭檔案。

原始碼:

import Foundation

@objcMembers
public class SwiftLibB: NSObject {

    public func sayHello(name: String) {
        print("Hello,this is " + name + "!")
        print("-- Printed by SwiftLibB")
    }
}
複製程式碼

swiftinterface 檔案中的內容:

// swift-interface-format-version: 1.0
// swift-compiler-version: Apple Swift version 5.1 (swiftlang-1100.0.270.13 clang-1100.0.33.7)
// swift-module-flags: -target x86_64-apple-ios13.0-simulator -enable-objc-interop -enable-library-evolution -swift-version 5 -enforce-exclusivity=checked -Onone -module-name SwiftLibB
import Foundation
import Swift
@objc @objcMembers public class SwiftLibB : ObjectiveC.NSObject {
  @objc public func sayHello(name: Swift.String)
  @objc override dynamic public init()
  @objc deinit
}

複製程式碼

為了能夠滿足模組 SwiftLibA 的單獨編譯,跟前面對 xx-Swift.h 檔案的操作一樣,我們用指令碼把 SwiftLibB.swiftmodule 拷貝到原始碼目錄中,然後再把這個新路徑新增到 SwiftLibA 的 Build Setting -> Swift Compiler-Search Paths -> Import Paths 中。

圖 22 新增 swiftmodule 檔案的路徑到 SwiftLibA 的 import paths

這個方案對於模組化/元件化有個缺點就是,每次編譯 Swift 模組時需要考慮多種不同的 CPU 架構。

除了這個方案之外,還有其他兩個方案可以解決 Swift 模組之間依賴的問題:

  • 我們還可以把 SwiftLibB 作為 SwiftLibA 的子工程(因為 xcodeproj 檔案可以作為 reference 引用),然後再設定編譯依賴,但不設定 Link Binary With Libraries,這樣就能保證 SwiftLibA 編譯通過,但是不會重複連結。
  • 在 Swift 5 正式釋出之前,還不支援 Module Stability,有 Swift 開發者用 ObjC 把 Swift 包一層,然後 ObjC 標頭檔案作為公開介面(詳見 Swift 5 Module Stability Workaround for Binary Frameworks

參考

6.3 Swift 模組呼叫 ObjC 模組

如果是在同一個 app target 裡,Swift 呼叫 ObjC 可以通過 Objective-C bridging header 來實現,但是如果是跨模組的呼叫呢?Swift 模組怎麼呼叫 ObjC 模組?

根據 Apple 官方檔案中的介紹,在 Library 或者 Framework 中不能使用 bridging header 的,而應該使用 umbrella header。

LLVM Module 和 Umbrella Header

什麼是 umbrella header?這就涉及到了 LLVM Module 的概念,LLVM 引入 Module 是為瞭解決傳統的 #include#import 這些標頭檔案匯入機制所存在的問題,也就是說這是一種新的標頭檔案管理機制,LLVM 官方檔案中對此有詳細的介紹。

在 ObjC 中可以通過 @import 指令匯入 module,在 Swift 中通過 import 關鍵字匯入 module。

Module 機制中一個很重要的檔案就是 module map 檔案,module map 檔案是用來描述標頭檔案和 module 結構的在邏輯上的對應關係的。

The crucial link between modules and headers is described by a module map,which describes how a collection of existing headers maps on to the (logical) structure of a module. For example,one could imagine a module std covering the C standard library. Each of the C standard library headers (stdio.h,stdlib.h,math.h,etc.) would contribute to the std module,by placing their respective APIs into the corresponding submodule (std.io,std.lib,std.math,etc.). Having a list of the headers that are part of the std module allows the compiler to build the std module as a standalone entity,and having the mapping from header names to (sub)modules allows the automatic translation of #include directives to module imports.

Module maps are specified as separate files (each named module.modulemap) alongside the headers they describe,which allows them to be added to existing software libraries without having to change the library headers themselves (in most cases [2]).

每一個 library 都會有一個對應的 module.modulemap 檔案,這個檔案中會宣告要引用的標頭檔案,這些標頭檔案就跟 module.modulemap 檔案放在一起。

The module map language describes the mapping from header files to the logical structure of modules. To enable support for using a library as a module,one must write a module.modulemap file for that library. The module.modulemap file is placed alongside the header files themselves,and is written in the module map language described below.

一個 C 標準庫的 module map 檔案可能就是這樣的:

module std [system] [extern_c] {
  module assert {
    textual header "assert.h"
    header "bits/assert-decls.h"
    export *
  }

  module complex {
    header "complex.h"
    export *
  }

  module ctype {
    header "ctype.h"
    export *
  }

  module errno {
    header "errno.h"
    header "sys/errno.h"
    export *
  }

  module fenv {
    header "fenv.h"
    export *
  }

  // ...more headers follow...
}
複製程式碼

modulemap 中的內容是使用 module map 語言來實現的,module map 語言中有一些保留字,其中 umbrella 就是用來宣告 umbrella header 的。umbrella header 可以把所在目錄下的所有的標頭檔案都包含進來,這樣開發者中只要匯入一次就可以使用這個 library 的所有 API 了。

A header with the umbrella specifier is called an umbrella header. An umbrella header includes all of the headers within its directory (and any subdirectories),and is typically used (in the #include world) to easily access the full API provided by a particular library. With modules,an umbrella header is a convenient shortcut that eliminates the need to write out header declarations for every library header. A given directory can only contain a single umbrella header.

如果你建立的是 Framework,在建立這個 Framework 時,defines module 預設會設定為 YES,編譯這個 Framework 之後,可以在 build 目錄下看到自動生成的 Module 目錄,這個 Module 目錄下有自動建立的 modulemap 檔案,其中引用了自動建立的 umbrella header。但是如果你建立的是 static library,那就需要開發者手動為這個 module 建立 modulemap 檔案和要引用的 umbrella header。

接下來我們建立一個 ObjCLibB 模組,然後讓 SwiftLibA 模組來呼叫它。

首先要做的是給模組 ObjCLibB 新建一個 umbrella header 檔案和一個 modulemap 檔案,然後再把 modulemap 檔案的路徑新增到 SwiftLibA 的 import paths,把 umbrella header 檔案的路徑新增到 SwiftLibA 的 header search paths,這樣就大功告成了。

圖 23 新建 umbrella header 檔案

圖 24 新建 modulemap 檔案

圖 25 新增 modulemap 檔案的路徑到 SwiftLibA 的 import paths

圖 26 新增 umbrella header 檔案的路徑到 SwiftLibA 的 header search paths

如果你的 Swift 模組要呼叫的模組是 ObjC-Swift 混編的,也可用同樣的方式來實現,核心點就在於將 C-based 語言的標頭檔案用 modulemap 和 umbrella header 封裝起來。

參考:

7. 除錯問題

如果你的主工程是純 ObjC 實現的,那麼當你在斷點除錯 Swift 模組中的程式碼時,會無法看到變數值,即便在 console 上使用 LLDB 命令也打印不出來。

(lldb) po name
Cannot create Swift scratch context (couldn't load the Swift stdlib)Cannot create Swift scratch context (couldn't load the Swift stdlib)Shared Swift state for MainProject could not be initialized.
The REPL and expressions are unavailable.

複製程式碼

圖 27 除錯 Swift 程式碼時無法看到變數值

這是因為主工程中沒有 Swift 程式碼,所以就沒有 Swift 相關的環境和設定選項,解決辦法就是在主工程中建立一個新的 Swift 檔案。

三、總結

Swift 5 的到來終於讓我們看到了期待已久的 ABI 穩定,相信更現代、更安全的 Swift 會變得越來越流行。另外,在模組化/元件化專案中落地 Swift 時,LLVM Module 是一個繞不過去的話題,LLVM Module 改變了傳統 C-Based 語言的標頭檔案機制,取而代之的是 Module 的思維。技術的發展會帶來更先進的生產力,我們期待 Swift 在未來能夠進一步提升我們的開發效率和程式設計體驗。