百度App Objective-C/Swift 組件化混編之路(三)- 實踐篇

Python實戰社羣

Java實戰社羣

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

掃碼關注添加客服

進Python社羣▲

掃碼關注添加客服

進Java社羣

作者丨吳隆旺

來源丨百度App技術

概述

前文《百度App Objective-C/Swift 組件化混編之路(二)- 工程化》已經介紹了百度App 組件內 Objective-C/Swift 混編、單測、以及組件間依賴、二進制發佈、集成的工程化過程。下面重點介紹百度App 組件化 Objective-C/Swift 組件化混編改造實踐,希望能對大家有所啓發和幫助。

組件化混編改造

百度App 經過組件化和二進制化改造後,組件的編譯產物主要是 static_framework   (.framework) 和 static_library(.a)兩種類型,因此百度App 混編主要是圍繞 static_framework 和 static_library 進行。

Swift 5.0 ABI(Application Binary Interface)穩定後,操作系統統一了 ABI 標準,編譯出的二進制產物能在不同的 runtime 下運行。但 ABI 穩定是使用二進制發佈框架(binary frameworks)的必要非充分條件。隨後的 Swift 5.1 又推出了 Module Stability 特性,使不同版本的 Swift 編譯器生成的二進制可以在同一個應用程序中使用,這才掃除二進制廣泛高效使用的障礙。

丨1 static_library 的問題

在 Xcode 中, static_framework 的 Build Settings 設置 BUILD_LIBRARY_FOR_DISTRIBUTION(見圖1)爲 YES,就開啓了 Module Stability。

圖1

而在 static_library 中, BUILD_LIBRARY_FOR_DISTRIBUTION 設置爲 YES 後編譯組件,會產生using bridging headers with module interfaces is unsupported 錯誤。可見 bridging header 與 Swift 的二進制接口文件(.swiftinterface)無法兼容,無法做到 Module Stability。

由於在 static_library 內混編會導致不同 Swift 編譯器版本上生成的二進制不兼容,所以 static_library 要支持組件內混編和二進制兼容性發布必須做 Framework 化(static_framework)改造,下面詳細說明改造過程。

2 組件 Framework 化改造

  • 將 static_library 改成 static_framework,百度App 藉助 EasyBox 可以快速完成,例如:

# 在 Boxfile 文件內,將 target 從 :static_library 修改爲 :static_framework
# 然後 box install 就完成轉化
box 'BBAUserSetting', '2.2.6', :target => :static_framework
  • 修改相關頭文件引用方式,例如:

// static_library 引用頭文件方式 
#import "ComponentA.h"


// static_framework 引用頭文件方式 
#import <ComponentA/ComponentA.h>

去預編譯頭文件 和 規範公開頭文件

百度App 部分 static_library 含有預編譯頭文件(pre-compiled header),主要作用是加快編譯速度。將公開頭文件放入預編譯頭文件中,組件內的頭文件和源文件不用再逐一顯式引用,但 pch 的使用有兩個問題:

  • 不能作爲二進制形態組件的一部分。

  • 當 static_library 改造成 static_framework(支持 module 可以提升編譯速度)後,會導致組件內頭文件缺少公用頭文件的引用,造成組件編譯錯誤。

基於以上原因,需要刪除預編譯頭文件,規範組件公開的頭文件,明確組件內頭文件的引用,儘量減少公開頭文件和接口的數量。

組件 Module 化

LLVM Module 改變了傳統 C-Based 語言的頭文件機制,被 Swift 採用,如果組件沒有 Module 化,Swift 就無法調用該組件,如何 Module 化 見 《百度App Objective-C/Swift 組件化混編之路(二)- 工程化》

組件 module 化編譯產物的目錄結構如下:
├── xxx 
├── Headers 
│   ├── xxxSettingProtocol.h 
│   ├── xxx-Swift.h 
├── Info.plist 
├── Modules 
│   ├── xxx.swiftmodule 
│   │   ├── Project 
│   │   │   ├── x86_64-apple-ios-simulator.swiftsourceinfo 
│   │   │   └── x86_64.swiftsourceinfo 
│   │   ├── x86_64-apple-ios-simulator.swiftdoc 
│   │   ├── x86_64-apple-ios-simulator.swiftinterface 
│   │   ├── x86_64-apple-ios-simulator.swiftmodule 
│   │   ├── x86_64.swiftdoc 
│   │   ├── x86_64.swiftinterface 
│   │   └── x86_64.swiftmodule 
│   └── module.modulemap  # module 化的情況下 
└── _CodeSignature 
    ├── CodeDirectory 
    ├── CodeRequirements 
    ├── CodeRequirements-1 
    ├── CodeResources 
    └── CodeSignature 
5 directories, 18 files 

丨5 解決組件間依賴傳遞

Swift Module 要求有明確的依賴,並且會傳遞依賴,組件公開頭文件依賴不明確,就有可能導致編譯錯誤,例如:組件 A 依賴組件 B,組件 B 依賴組件 C,且組件 B 的對外暴露頭文件引用了組件 C,那麼組件 B 依賴傳遞了組件 C,組件 A 也必須依賴組件 C 或者組件 B 聲明傳遞依賴組件 C,否則在 module 化(配置 module.modulemap)的情況下會出現 Could not build module 'XX' 編譯錯誤。

組件間依賴傳遞
# A.boxspec 的配置,聲明組件 A 依賴組件 B 
s.dependency 'B' 


# B.boxspec 的配置,聲明組件 B 依賴組件 C 
s.dependency 'C' 


## 解決方案一 
# A.boxspec 的配置,增加組件 C 的依賴 
s.dependency 'C' 


## 解決方案二 
# Swift 的 module 會傳遞依賴,百度App 使用 EasyBox 的 module_dependency 來解決這個問題 
# B.boxspec 的配置,將直接依賴(dependency)修改成 module 傳遞依賴(module_dependency)組件 C 
s.module_dependency 'C' 

6 開啓 Module Stability

如上面 1 static_library 的問題所述。

7 組件(static_framework)內混編

在 static_framework 中, Swift 通過 module 中的文件訪問 Objective-C 定義的公開數據類型和接口,Objective-C 通過 #import<ProductName/ProductModuleName-Swift.h> 訪問 Swift 定義的公開數據類型和接口。

目前百度App 的 static_framework 默認會將所有 public header 公開出來,然後在 umbrella header 文件內引用了這些 public header,這樣 Swift 文件就可以直接調用到。美中不足的是如果 Objective-C 頭文件是 static_framework 私有頭文件,爲了 Objective-C/Swift 混編且能夠被 Swift 文件調用到,需要將這些私有頭文件改成公開頭文件,詳情見 Import Code Within a Framework Target  (https://developer.apple.com/documentation/swift/imported_c_and_objective-c_apis/importing_objective-c_into_swift)。

而 Objective-C 文件調用 Swift,需要在 Swift 類前面要用 open 或 public 修飾,以及滿足其他互操作性要求。

8 組件混編理想態

組件內混編只是中間態,理想態是單個組件完全使用 Swift;而組件間混編,是一個長期存在的形態,最終某個組件要麼是 Swift 組件,要麼是 Objective-C 組件,調用方式比較簡單,static_framework 內的 Swift 文件使用直接 import 其他組件,例如:

// static_framework 內的 Swift 文件使用直接 import
import ComponentA

互操作性

1 Objective-C APIs Are Available in Swift

在 Objective-C 的頭文件裏,點擊左上角的 Related Items 按鈕,選擇 Generated Interface 後,就可以查看 Objective-C API 自動生成對應的 Swift API,如圖所示:

2 Nullability for Objective-C

// 在 Objective-C 頭文件中沒有加上
// NS_ASSUME_NONNULL_BEGIN 和 NS_ASSUME_NONNULL_END


@interface ObjClass : NSObject
// objClassString 值有可能爲空
@property (nonatomic, copy) NSString *objClassString; 
// getObjClassInstance 值有可能爲空
- (ObjClass *)getObjClassInstance; 
@end


// Objective-C 轉化 Swift 代碼後
open class ObjClass : NSObject {
    open var objClassString: String!
    open func getInstance() -> ObjClass!
}


// 在 Swift 文件中調用


let cls = ObjClass.init()
print(cls.getInstance().objClassString)

在 Objective-C 頭文件中沒有加上 NS_ASSUME_NONNULL_BEGIN 和 NS_ASSUME_NONNULL_END,轉化 Swift 代碼後,對應的返回值會轉換爲隱式解析可選類型(implicitly unwrapped optionals),如果直接使用 getObjClassInstance,返回值爲空就會導致 crash。

// 在 Objective-C 頭文件中加上
// NS_ASSUME_NONNULL_BEGIN 和 NS_ASSUME_NONNULL_END


NS_ASSUME_NONNULL_BEGIN
@interface ObjClass : NSObject
// objClassString 值有可能爲空
@property (nonatomic, copy) NSString *objClassString; 
// getObjClassInstance 值有可能爲空
- (ObjClass *)getObjClassInstance; 
@end
NS_ASSUME_NONNULL_END


// Objective-C 轉化 Swift 後
open class ObjClass : NSObject {
    open var objClassString: String 
    open func getInstance() -> ObjClass 
}


// 在 Swift 文件中調用
let cls = ObjClass.init() 
print(cls.getInstance().objClassString)

在 Objective-C 頭文件中加上 NS_ASSUME_NONNULL_BEGIN 和 NS_ASSUME_NONNULL_END 標明屬性或者方法返回值不能爲空,實際上業務方不注意還是有可能返回空,在這種情況下轉化爲對應的 Swift 代碼,不會轉換爲隱式解析可選類型(implicitly unwrapped optionals),直接使用不會 crash ,所以建議在 Objective-C 頭文件中開始和結束分別加上 NS_ASSUME_NONNULL_BEGIN 和 NS_ASSUME_NONNULL_END。

3 安全集合類型參照實現

// Objective-C NSArray 沒有指定類型
// Objective-C
@interface UIView
@property(nonatomic,readonly,copy) NSArray *subviews;
@end
// Swift
class UIView {
 var subviews: [Any] { get } 
}   


// Objective-C NSArray 指定類型
// Objective-C
@interface UIView
@property(nonatomic,readonly,copy) NSArray<UIView *> *subviews;
@end
// Swift
class UIView {
  var subviews: [UIView] { get }
}

在 Objective-C 中的 NSArray 可以插入不同類型,當聲明屬性沒有指定類型 @property (nonatomic, strong, readonly) NSArray *subviews 轉化 Swift 後就變成open var subviews: [Any] { get },這時候在 Swift 中使用數組 subviews 裏面的對象,需要通過 as? 進行判斷是否是 UIView 類型,所以在 Objective-C 中聲明數組的時候,聲明指定類@property (nonatom-ic, strong, readonly) NSArray<UIView *> *subviews 轉換 Swift 後也是指定類型,獲取數據更安全。

4 Objective-C/Swift 混編關鍵字

  • @objc  聲明 Swift 類中需要暴露給 Objective-C 的方法要用關鍵字 @objc

  • @objc(name)  聲明修改 Swift 類中需要暴露給 Objective-C 的方法名稱

  • @nonobjc  聲明 Swift 類中不暴露給 Objective-C 的方法要用關鍵字 @nonobjc

  • @objcMembers  聲明 Swift 類會隱式地爲所有的屬性或方法添加 @objc 標識,聲明爲 @objc 的類需要繼承自 NSObject ,而 @objcMembers 不需要繼承自 NSObject,但是這種情況下 Objective-C 就不能訪問 Swift 類的方法或者屬性

  • dynamic  聲明 dynamic 使得 Swift 具有動態派發特性

Objective-C 是動態語言,所有方法、屬性都是動態派發和動態綁定的,而 Swift 卻相反,它一共包含三種方法分派方式:Static dispatchTable dispatch Message dispatch。在 Swift 類中聲明爲 @objc 的屬性或方法有可能會被優化爲靜態調用,不一定會動態派發,如果要使用動態特性,需要將 Swift 類的屬性或方法聲明爲 @objc dynamic,此時 Swift 的動態特性將使用 Objective-C Runtime 特性實現,完全兼容 Objective-C。

@objc dynamic func testViewController() {}
  • NS_SWIFT_UNAVAILABLE  在 Swift 中不可見,不能使用

+ (instancetype)collectionWithValues:(NSArray *)values
                             forKeys:(NSArray<NSCopying> *)keys
NS_SWIFT_UNAVAILABLE("Use a dictionary literal instead.");
  • NS_SWIFT_NAME  在 Objective-C中,重新命名在 Swift 中的名稱

// 在 Objective-C 文件中
NS_SWIFT_NAME(Sandwich.Preferences)
@interface SandwichPreferences : NSObject
@property BOOL includesCrust NS_SWIFT_NAME(isCrusty);
@end 


@interface Sandwich : NSObject
@end 


// 在 Swift 文件中使用
var preferences = Sandwich.Preferences()
preferences.isCrusty = true# 在 Objective-C 文件中
NS_SWIFT_NAME(Sandwich.Preferences)
@interface SandwichPreferences : NSObject
@property BOOL includesCrust NS_SWIFT_NAME(isCrusty); 
@end 


@interface Sandwich : NSObject 
@end 
// 在 Swift 文件中使用 
var preferences = Sandwich.Preferences() 
preferences.isCrusty = true 
  • NS_REFINED_FOR_SWIFT  Swift 調用 Objective-C 的 API 時可能由於數據類型等不 一致導致無法達到預期(例如,Objective-C 裏的方法採用了 C 語言風格的多參數類型;或者 Objective-C 方法返回值是 NSNotFound,在 Swift 中期望返回 nil)。這時候就可以使用 NS_REFINED_FOR_SWIFT

// 在 Objective-C 中
@interface Color : NSObject


- (void)getRed:(nullable CGFloat *)red
         green:(nullable CGFloat *)green
          blue:(nullable CGFloat *)blue
         alpha:(nullable CGFloat *)alpha NS_REFINED_FOR_SWIFT;


@end


// 在 Swift 中
extension Color {
    var rgba: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
        var r: CGFloat = 0.0
        var g: CGFloat = 0.0
        var b: CGFloat = 0.0
        var a: CGFloat = 0.0
        __getRed(red: &r, green: &g, blue: &b, alpha: &a)
        return (red: r, green: g, blue: b, alpha: a)
    }
}

常見問題

  1. Swift framework module name 和 class name 一致會造成 .swiftinterface file bug (https://forums.swift.org/t/frameworkname-is-not-a-member-type-of-frameworkname-errors-inside-swiftinterface/28962/4)

  2. static_framework 內 Swift 文件調用 Objective-C 文件,如果該 Objective-C 公開頭文件內引用其他組件的公開頭文件,且這個組件沒有 module 化(配置 module.modulemap)就會出現  include of non-modular header inside framework module  錯誤,因此公開給 Swift 調用的組件都是需要 module 化,例如:

    // 在 ComponentA 組件的 ComponentA.h 頭文件內,引用 ComponentB 組件的公開頭文件
    // ComponentB 組件剛好沒有 module 化(配置module.modulemap)
    #import <ComponentB/ComponentB.h>
    
  3. 在 static_framework 組件內的 Swift 文件調用 static_library 組件,需要將 static_library module 化(配置 module.modulemap),否則不能在 Swift 文件內直接使用,在 Xcode debug Swift 文件時,發現 Swift 文件內調用的 static_library,如果 static_library 的頭文件寫法有問題,在 Xcode 控制檯打印 self 例如 "po self",就會出現  Error while loading Swift module  錯誤,例如:

    // 在 static_library 的 ComponentA.h 頭文件內
    // 錯誤的寫法
    #import "TestA.h"
    #import "TestB.h"
    
    
    // 正確的寫法
    // 需要修改暴露的頭文件,不然會導致無法加載 Swift module
    #if __has_include(<ComponentA/ComponentA.h>)
    #import <ComponentA/TestA.h>
    #import <ComponentA/TestB.h>
    #else
    #import "TestA.h"
    #import "TestB.h"
    #endif
    
  4.  Cycle Reference Error  

    在說明 Cycle Reference 之前先看一下錯誤信息 

    error: Cycle inside XXX; building could produce unreliable results.

   

下面通過舉例具體分析一下

  • 在 static_library 中,如前面所述,Objective-C/Swift 混編是通過 bridging header 作爲橋接,假設 ComponentA 裏面有個 Swift 類 MySwiftClass,Objective-C 的 MyObjcClass 頭文件中使用了該 Swift 類,需要  #import "ComponentA-Swift.h" 頭文件

  #import "ComponentA-Swift.h"

@interface MyObjcClass : NSObject
- (MySwiftClass *)returnSwiftClassInstance;
// ... 
@end 

而 MyObjcClass 又在 MySwiftClass 中使用,需要將 MyObjcClass.h 頭文件加入到 ComponentA-Bridging-Header.h中

// ComponentA-Bridging-Header.h 
#import "MyObjcClass.h" 
  • 在 static_framework 中 假設 ComponentA 裏面有個 Swift 類 MySwiftClass,Objective-C 的 MyObjcClass 頭文件中使用了該 Swift 類,需要引用 ComponentA-Swift.h 頭文件,例如:

#import "ComponentA/ComponentA-Swift.h" // 測試過程中通過這種方式引用會導致 Cycle Reference 問題
// #import <ComponentA/ComponentA-Swift.h> // 測試通過這種方式引用正常


@interface MyObjcClass : NSObject 
- (MySwiftClass *)returnSwiftClassInstance; 
// ... 
@end 

而 MyObjcClass 又在 MySwiftClass 中使用,需要將 MyObjcClass.h 頭文件加入到 umbrella header 中,例如:

// ComponentA.h 在百度App 默認組件名稱 .h 就是作爲 static_framework 的 umbrella header
#import <ComponentA/MyObjcClass.h>
Objective-C 與 Swift 進行混編時,編譯過程大致如下:
  • 預編譯處理 bridging header 或者 umbrella header,然後編譯 Swift 源文件,再 merge swiftmodule

  • Swift 編譯完成後,生成 ProjectName-Swift.h 的頭文件供 Objective-C 使用

  • 最後編譯 Objective-C 源文件

因此,編譯 Swift 需要先處理 bridging header 或者 umbrella header,而 bridging header 或者 umbrella header 裏面的 MyObjcClass.h 又引用 ComponentA-Swift.h 頭文件,此時由於 Swift 還沒編譯完成,就有可能導致編譯錯誤。

建議:在 Objective-C/Swift混編中,儘量保持單向引用(OC 類引用 Swift 類或者 Swift 類引用 OC 類),減少循環引用,特殊情況可以使用前置聲明(Forward Declaration),解決 Circle Reference,參考 Include Swift Classes in Objective-C Headers Using Forward Declarations (https://developer.apple.com/documentation/swift/import-ed_c_and_objective-c_apis/importing_objective-c_into_swift)

@class MySwiftClass;


@interface MyObjcClass : NSObject
- (MySwiftClass *)returnSwiftClassInstance;
// ...
@end

總結

隨着 Apple 大力推進、開源社區對 Swift 支持,Swift 普及已經大勢所趨,目前百度App 經過 EasyBox 工具鏈支持混編組件二進制打包,以及組件的改造,業務層 30% 組件可以使用 Swift 混編開發,不支持混編的業務層組件也在陸續改造中,服務層(百度App組件化之路)及以下組件(佔總組件數比 55% )都可以使用 Swift 混編開發,並在基礎功能清理緩存、Feed 等相關業務完成 Swift 混編落地。作爲 iOS 開發的你,還在等什麼,趕緊升級技術棧吧!

參考資料

1. Importing Objective-C into Swift (https://developer.apple.com/documentation/swift/imported_c_and_objective-c_apis/importing_objective-c_into_swift)

2. Importing Swift into Objective-C (https://developer.apple.com/documentation/swift/imported_c_and_objective-c_apis/importing_swift_into_objective-c)

3. Swift and Objective-C Interoperability (https://developer.apple.com/videos/play/wwd-c2015/401/)

4. Nullability and Objective-C (https://developer.apple.com/swift/blog/?id=25)

5. Library Evolution in Swift (https://swift.org/blog/library-evolution/)

6. Improving Objective-C API Declarations for Swift (https://developer.apple.com/documentation/swift/objective-c_and_c_code_customization/improving_objective-c_api_declarations_for_swift)

7. Making Objective-C APIs Unavailable in Swift (https://developer.apple.com/documentation/swift/objective-c_and_c_code_customization/making_objective-c_apis_unavailable_in_swift)

8. Renaming Objective-C APIs for Swift   (https://developer.apple.com/documentation/sw-ift/objective-c_and_c_code_customization/renaming_objective-c_apis_for_swift)

Reviewer:張渝、王文軍、陳松、陳佳、李政、趙家祝

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

近期精彩內容推薦:  

 幾句話,離職了

 中國男性的私密數據大賞,女生勿入!

 爲什麼很多人用“ji32k7au4a83”作密碼?

 一個月薪 12000 的北京程序員的真實生活 !


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

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