京東App Swift 混編及元件化落地

京東科技開發者發表於2021-02-08

背景

自 Swift 誕生以來,逐步見證其從飽受詬病到日漸完善。在蘋果的全力推動下,潛移默化地把開發支援中心從 Objective-C 轉向 Swift,在業界的呼聲也越演越烈。當我們相繼迎來 ABI穩定、Module stability、Library evolution 等功能後,我們期盼已久的 Swift 已然到來,毅然啟動了京東 App 的混編之旅。我們依然堅持穩紮穩打,前期對 Swift 技術做了諸多調研工作,具體可見 《Swift環境及編譯最佳化調研》。2020年7月京東 App 的首個混編版本上線蘋果商店,完成了元件內和主工程的混編工作;近期,我們完成了對京東元件化管理工具(iBiuTool)的改造,混編元件化功能正式落地,這也標誌著京東 Swift 混編基礎支援建設完畢。但是,Just the beginning...

期待的Swift已經到來

2.1 ABI穩定

Swift 5.0,提供 ABI 穩定,解決了 Swift runtime 的版本相容問題。這意味著透過 Swift 5.0 及以上的編譯器編譯出來的二進位制,就可以執行在任意 Swift 5.0 及以上的 Swift runtime 上。ABI 穩定後,Swift runtime 和標準庫已經植入 macOS 10.14.4、iOS 12.2、watchOS 5.2 及以上系統中。根據蘋果官方資料,截止到 2020年12月15日,四年內釋出的 iPhone 裝置中 iOS 13及以上佔比已達 98%。

另外,ABI 穩定還帶來了效能上的提升。由於 Swift runtime 已經被深入的整合在了裝置的作業系統中,並且結合系統層做了許多最佳化,這就使得 Swift 程式具有更快的啟動速度、更好的執行效能,以及更少的記憶體佔用量。


2.2 Module Stability

Swift 5.1,支援 Module Stability,解決模組間編譯器版本相容的問題。這意味著使用不同版本編譯器構建的 Swift 模組可以在同一個應用程式中一起使用。即使某些三方庫的 Swift 編譯器版本與你所使用的不同,也不會存在編譯問題。官方文件中舉了一個十分恰當的例子,使用 Swift 6 構建的 framwork,可以被 Swift 6 和未來的 Swift 7 編譯器正常使用。所以這個進化對於開發者來說,絕對是一件非常美好事情。

在 Swift 中有一個 .swiftmodule 檔案,它是一種二進位制檔案,主要包含模組中的資料資訊和內部編譯器的資料結構。由於內部編譯器的資料結構的存在,同一個模組編譯的 swiftmodule 檔案在不同版本的編譯器中都是不一樣的。這也就是為什麼在某個版本編譯器中編譯的二進位制檔案,在另一個版本編譯器中無法被匯入使用的原因。


京東App Swift 混編及元件化落地


Module Stability 解決了這個問題,在模組穩定後,儲存模組資訊的檔案已經替代為 swiftinterface 格式了。它是一個文字格式的檔案,它包含所有 public 或者 open 的 API 以及一些隱式的程式碼或者 API,還包括 swiftinterface 的版本、生成此 swiftinterface 的編譯器版本,以及 Swift 編譯器將其作為模組匯入時所需的命令列標誌的子集。而且這些 API 與原始碼很類似,透過原始碼穩定實現了模組穩定。


2.3 Library Evolution

Swift 5.1, 支援 Library Evolution,解決了二進位制庫向下相容的問題。在 Library Evolution 特性開啟的狀態下,二進位制庫某些場景下的 API 更新後,就會自動實現對舊版本庫的相容。Library Evolution 可以在不破壞二進位制相容性的情況下對庫進行某些修改。

舉例來具體說明一下這個問題。元件 B 和元件 C 都依賴了元件 A,他們的元件版本都是 v1.0。主工程的 v1.0 釋出時,這三個元件需要各種構建,並整合到主工程中。如下圖所示:


京東App Swift 混編及元件化落地


當主工程 v2.0 釋出時,元件 A 對元件 B 在 v1.0 版本所使用的 API 進行了一些 resilient 的修改,但這些修改並沒有影響到元件 C。所以,元件 B 在構建二進位制庫時,就需要更新依賴的元件 A 到 v2.0 版本。而元件 C 沒有功能修改,則不需要更新依賴和釋出新版本。然後,他們都整合到 v2.0 版本的主工程中。

京東App Swift 混編及元件化落地

    

如果元件 A 的 Library Evolution 在沒有啟用的情況下,在元件 C 中與元件 A 相關的程式碼就有可能在執行時產生問題、甚至崩潰。而開啟 Library Evolution 後,就能夠做到對舊版本的相容。

    

混編的方式

京東 App 根本上是一個基於 Cocoapods 實現的元件化工程,總的來看需要劃分為兩個場景:一、主工程的混編;二、各元件內的混編。在這兩個場景中,對 Swift 引入 ObjC 和 ObjC 引入 Swift又做了不同的處理。

3.1 工程中 - Swift 呼叫 ObjC

在主工程的 Target 下,需要透過橋接標頭檔案的方式,將 ObjC 的標頭檔案暴露給 Swift 進行使用。


  • 建立橋接檔案(-Bridging-Header.h)

  • 確保 Build Setting 中 SWIFT_OBJC_BRIDGING_HEADER 為該橋接檔案的路徑

  • 將需要引入到 Swift 的 ObjC 的標頭檔案新增進去


3.2 工程中 - ObjC 呼叫 Swift

在主工程的 Target 下,可以透過引入 Swift Module 的 ObjC Interface Header的方式,在 ObjC 中使用 Swift。由於 Swift Module的緣故,所以引入一個檔案,便可以使用該模組下的所有 Swift 檔案。需要注意的是,這個標頭檔案的命名預設是"ProjectName-Swift.h",如果工程名中有一些 nonalphanumeric 字元,則會被替換為下劃線。


  • 確保 Build Setting 中 SWIFT_OBJC_INTERFACE_HEADER_NAME 的配置正確

  • 在 ObjC 中引入該模組的 Swift 標頭檔案,#import "XXX-Swift.h"

  • 若在 ObjC的 .h 中引入,則可以透過向前宣告的方式,@class XXX


3.3 元件內 - Swift 呼叫 ObjC

在同一個 .framework 或者 .a 中實現 Swift 呼叫 ObjC,透過 Bridging-Header 的方式是無法解決的。如果你嘗試使用 Bridging-Header 的方式,並且透過 .podspec 對 Bridging-Header 進行配置寫入。只會有短暫性的編譯成功,最終將會報錯:


京東App Swift 混編及元件化落地


經過官方文件中的近一步查證,發現在同一個 framework 中的 Swift 想要引入 ObjC,需要將該 ObjC 檔案匯入到其 umbrella-header 檔案中。這樣 Swift 模組就可以對 umbrella-header 中向外暴露的類進行呼叫了。另外,官方文件中還提到 DEFINES_MODULE 要配置為 YES,這樣整個元件就可以作為一個模組被外部匯入使用了。


3.4 元件內 - ObjC 呼叫 Swift

在同一個 .framework 或者 .a 中實現 ObjC 呼叫 Swift,依然需要透過引入 Swift Module 的 ObjC Interface Header。


  • 確保 Build Setting 中 SWIFT_OBJC_INTERFACE_HEADER_NAME 的配置正確

  • 在 ObjC 中引入該模組的 Swift 標頭檔案,.framework 中為 #import <XXX/XXX-Swift.h>,.a 中為 #import "XXX-Swift.h"。

  • 若在 ObjC的 .h 中引入,則可以透過向前宣告的方式,@class XXX

元件內混編

4.1 元件內混編實施方案

參照上文中梳理的大體方案,便可以對京東App元件進行混編實施。京東元件透過自己的工具進行元件管理的,由於歷史原因,某些方面的功能還不能完全支援,比如 modulemap、module stability、new build setting。所以這一些問題需要繞過,這些問題文章後面會針對說明。具體的實施步驟如下:


  • 元件內新增 Swift 檔案,且不需要建立橋接檔案

  • podspec 中 source_files 配置中新增 swift 項

  • ObjC 呼叫 Swift

  • 在蘋果官方文件中,推薦配置 DEFINES_MODULE = YES,並透過 #import <XXX/XXX-Swift.h> 的方式匯入 Swift Module。但在京東元件中動態庫是以 .framework 形式存在的,需要以 #import <XXX/XXX-Swift.h> 的方式匯入 Swift Module;而靜態庫是以 .a 形式存在的,需要以 #import "XXX-Swift.h" 的方式匯入

  • 值得注意的是,要確保你的 Swift 類為 Public 或 Open 的訪問許可權,否則在 Swift Module 之外的 ObjC 檔案中是無論如何都不能呼叫的。對於 ObjC 中想要使用屬性和函式,需要標記 @objc,它會告訴編譯器該屬性或者函式能夠應用於 Objective-C 程式碼中。而且標有 @objc 特性的類必須繼承自 ObjC 的類。

  • Swift 呼叫 ObjC

  • Swift 模組想要呼叫 ObjC 就需要將 ObjC 的標頭檔案暴露在 umbrella-header 中。這樣就需要在 podspec 中將 public_header_files 配置中新增要暴露的 ObjC 標頭檔案後,供 Swift 進行呼叫。

4.2 元件內混編通訊方案

按照上述方案實施後,元件內的通訊歸為 ObjC 呼叫 Swift 和 Swift 呼叫 ObjC 兩個方面。具體通訊方式如下圖所示:


京東App Swift 混編及元件化落地


元件間混編

5.1 Swift 呼叫 ObjC

Swift 呼叫 ObjC API 前,首先需要透過 import module 語法找到對應的模。Module機制是在2013年加入了Xcode中,目的是為了提升編譯速度,解決C、C++中#include機制的一些遺留問題。具體是如何解決的,可以看下這兩篇文章:關於objective-cmodules和autolinking(1)、Clang官方文件(2)。

我們需要解決的問題是如何讓編譯器找到Module,透過檢視 Clang 官方的文件,我們發現:


  • 如果要支援Module,必須提供一個module.modulemap檔案,用來宣告模組與標頭檔案之間的對映關係

  • 針對 framework,Clang 會透過指定路徑查詢命名為module.modulemap的檔案:.framework/Modules/module.modulemap


module.modulemap檔案中的內容大致如下,主要是用來宣告模組與標頭檔案之間的對映關係,支援 import module 方式呼叫。

framework module STStaticBasicStableModule {        
umbrella header "STStaticBasicStableModule-umbrella.h"        
export *        
module * {  export * }    
}    
module STStaticBasicStableModule.Swift {  
      header "STStaticBasicStableModule-Swift.h"  
      requires objc   
}

找到模組,並且知道模組有哪些標頭檔案後,就可以訪問元件提供的類、方法了。


5.2 Swift 呼叫 Swift

我們知道 ObjC 程式碼之間呼叫 API 是透過標頭檔案的形式,但 Swift 是沒有標頭檔案的,它使用一個二進位制格式的檔案(.swiftmodule)來代替標頭檔案,這個檔案中包含了 Swift 模組的所有 API、inlinable function bodies。

編譯器會去哪找 swiftmodule 檔案呢?我們在 swift 原始碼中找到了一些蛛絲馬跡:

// SerializedModuleLoader.cpp
    void SerializedModuleLoaderBase::collectVisibleTopLevelModuleNamesImpl(
            SmallVec torImpl<Identifier> &names, StringRef extension) const { 
                 // ...
                       forEachModuleSearchPath(Ctx, [&](StringRef searchPath, SearchPathKind Kind,
                       bool isSystem) { 
                       switch (Kind) {
                // ... 
case SearchPathKind::Framework: { 
         // 原始碼中的註釋及相關程式碼說明了 swiftmodule 在 framework 中的查詢機制
         // Look for:          
         // $PATH/{name}.framework/Modules/{name}.swiftmodule/{arch}.{extension} 
                  forEachDirectoryEntryPath(searchPath, [&](StringRef path) {
                // ...          
                });         
                 return None;        
                 }        
                 }        
                 llvm_unreachable("covered switch");      
                 });    
                 }

.swiftmodule是一個序列化後的二進位制檔案,從檔名SerializedModuleLoader.cpp可以猜測這個是負責載入.swiftmodule檔案的。另外上述程式碼中也說明了針對 framework,Swift 編譯器如何查詢.swiftmodule。

同時為了保證元件支援 x86、arm 架構下編譯,我們還需要將不同架構編譯生成的 swiftmodule 檔案手動合併到最終的 framework 中。


5.3 ObjC 呼叫 Swift

編譯器會透過我們編寫的 Swift 程式碼生成xxx-Swift.h,這樣 ObjC 就可以透過這個標頭檔案訪問 Swift 的 API 了。我們可以透過兩種 import 方式呼叫 Swift API:


  • @import STStaticBasicStableModule

  • #import "STStaticBasicStableModule-Swift.h"


還記得上面 modulemap 中的STStaticBasicStableModule.Swift吧,@import STStaticBasicStableModule在找到模組後,會透過 modulemap 檔案中宣告找到STStaticBasicStableModule-Swift.h:

 module STStaticBasicStableModule.Swift 
 {       
  header "STStaticBasicStableModule-Swift.h"       
   requires objc    
 }

xxx-Swift.h也需要支援多架構,我們需要把不同架構下生成的xxx-Swift.h內容合併到一個檔案中,最終合併的xxx-Swift.h,去掉部分程式碼,大致結構是這個樣子:


#ifndef TARGET_OS_SIMULATOR    
#include <TargetConditionals.h>    
#endif         
#if TARGET_OS_SIMULATOR             
// Release-iphonesimulator/Swift Compatibility Header/XXX-Swift.h        
#if 0        
#elif defined(__x86_64__) && __x86_64__ 
              // __x86_64__ 
              #elif defined(__i386__) && __i386__       
              // __i386__ 
              #endif
              #else            
             // Release-iphoneos/Swift Compatibility Header/XXX-Swift.h       
              #if 0        
              #elif defined(__arm64__) && __arm64__       
             // __arm64__        
              #elif defined(__ARM_ARCH_7A__) && __ARM_ARCH_7A__        
             // __ARM_ARCH_7A__       
              #endif         
              #endif // TARGET_OS_SIMULATOR

5.4 Module stability & Library evolution

文章開篇說過,它們是 Swift 5.1 新增的2個關於二進位制穩定的特性,可以支援釋出和共享 framework。只有當你的庫要獨立於客戶端進行構建的情況下,才需要開啟 Build Libraries for Distribution 選項,而且 Module Stability 和 Library Evolution 就會同時生效。通常我們在開發二進位制庫時,最好儘早開啟此開關,以提供任何二進位制相容性的保證。

如果不支援這2個特性,可能會出現的問題:


User1 使用 Xcode 11.2 釋出了基礎元件,User2 依賴了這個元件,並使用 Xcode 11.7 編譯,結果:編譯報錯Module compiled with Swift 5.1.2 cannot be imported by the Swift 5.2.4compiler

User1 給結構體中新增了一個屬性,然後釋出了新版本元件,依賴該元件的下游較多,User1 需要周知所有依賴方依賴最新版本重新編譯,否則可能會引發執行時崩潰


  • Xcode 中設定 build setting 中的 BUILD_LIBRARY_FOR_DISTRIBUTION 為 YES

  • 需要支援 New Build System。


5.5 元件間混編通訊方案

按照上述方案實施後,元件間的通訊歸為 ObjC 呼叫 Swift 和 Swift 呼叫 ObjC,以及 Swift 呼叫 Swift 三個方面。具體通訊方式如下圖所示:


京東App Swift 混編及元件化落地


京東主工程混編支援方案

6.1 靜態編譯的問題

由於我們之前是純 ObjC 的開發環境,所以即使實現了元件內混編,京東元件化的主工程(或者元件的Example工程)也並不能成功編譯。原因在於它們還不支援 Swift 混編環境,在編譯時可能會報類似錯誤:


京東App Swift 混編及元件化落地


或者

京東App Swift 混編及元件化落地


上述的錯誤資訊說明,編譯器不能自動連結到 Swift 相關的一些靜態庫和動態庫。而這些資源是存在於 Xcode Toolchains 下的,在本地的路徑為:


  • /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


接下來將這些資源路徑配置在工程中,一般地需要在Build Settings -> Library Search Paths 中新增:


  • "$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)"

  • "$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)"


6.2 動態庫載入的問題

當配置好可連結資源的路徑之後,就可以成功編譯了。但在啟動時,動態庫載入的問題將會引起程式的崩潰。諸如以下錯誤:


京東App Swift 混編及元件化落地


如果你的裝置是 iOS 12.2 及以上,可能報錯如下:


京東App Swift 混編及元件化落地


在 Build Settings -> Runpath Search Paths 中首行新增配置 /usr/lib/swift 配置,特別要注意的是隻能在首行配置才能解決問題。

如果你的裝置是 iOS 12.2 以下,可能報錯如下:


京東App Swift 混編及元件化落地


iOS 12.2 以下,將 Build Settings -> Always Embed Swift Standard Libraries 設定為 YES。

這兩個問題都是在應用啟動後,動態庫載入時,發生的崩潰。那為什麼要以 iOS 12.2 為分水嶺呢?這就是從 iOS 12.2 Swift 已經實現 ABI 穩定了。所以上述兩處的解決方案缺一不可,因為它們分別針對 ABI 穩定前後的系統版本。這些配置完成後,京東元件的主工程(或者元件的Example工程)就已經完全支援 Swift 混編環境了。另外,還有一種更加便捷方案也可以達到同樣的效果。


6.3 一鍵配置混編環境

除了 Xcode Build Settings 配置的方式,還可以透過在工程中新建一個 Swift 檔案(檔案中無需新增任何程式碼),透過這種方式 Xcode 會自動完成部分環境配置,能夠解決靜態編譯問題和部分裝置的動態庫載入問題。另外,還需要處理 iOS 12.2 以下 Swift 動態庫載入的問題。將 Always Embed Swift Standard Libraries 設定為 YES。

假如透過 Xcode Build Settings 配置的方式,從 Xcode 11 beta 4 開始,就需要在工程配置中新增 swift-5.0 的新配置項了。但如果透過新建 Swift 檔案的方式,就不必更新配置,它的優勢在於機動性好。

京東是一個標準的元件化應用,主工程中無程式碼實現,所以不需要實現混編程式碼,僅需要使其支援 Swift 開發環境即可。因此 Xcode Build Settings 配置的方式能夠滿足需求,無需多餘檔案,在工程的簡潔性上更好。


通訊方案總結

最後,對京東 App 中涵蓋的混編通訊方式做個彙總。以元件內、元件間,以及主工程的混編形式為基礎,將整體的混編通訊方式彙總如下:


京東App Swift 混編及元件化落地

   

作者: 王彥昌、姚琦、林曉峰


參考文獻

*(1)%E5%85%B3%E4%BA%8Eobjective-cmodules%E5%92%8Cautolinking

*(2)

*https://developer.apple.com/documentation/swift/imported_c_and_objective-c_apis/importing_objective-c_into_swift

*https://developer.apple.com/documentation/swift/imported_c_and_objective-c_apis/importing_swift_into_objective-c

*

* https://swift.org/blog/library-evolution/

* https://swift.org/blog/abi-stability-and-more/

*

*

*

*-evolution/blob/master/proposals/0260-library-evolution.md


歡迎點選【 京東科技 】,瞭解開發者社群

更多精彩技術實踐與獨家乾貨解析

歡迎關注【京東科技開發者】公眾號





來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69912185/viewspace-2756733/,如需轉載,請註明出處,否則將追究法律責任。

相關文章