百度App Objective-C/Swift 组件化混编之路(三)- 实践篇

概述

前文《百度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。

图片

而在 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>

丨3 去预编译头文件和 规范公开头文件

百度App 部分 static_library 含有预编译头文件(pre-compiled header),主要作用是加快编译速度。将公开头文件放入预编译头文件中,组件内的头文件和源文件不用再逐一显式引用,但 pch 的使用有两个问题:

  • 不能作为二进制形态组件的一部分。

  • 当 static_library 改造成 static_framework(支持 module 可以提升编译速度)后,会导致组件内头文件缺少公用头文件的引用,造成组件编译错误。

基于以上原因,需要删除预编译头文件,规范组件公开的头文件,明确组件内头文件的引用,尽量减少公开头文件和接口的数量。

丨4 组件 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 dispatch,**Table 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>
  1. 在 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
  1. 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)

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,214评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,307评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,543评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,221评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,224评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,007评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,313评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,956评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,441评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,925评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,018评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,685评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,234评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,240评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,464评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,467评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,762评论 2 345

推荐阅读更多精彩内容