2019年3月,Apple发布了Swift 5.0版本,并宣布ABI稳定。相比之前的版本,Swift 5.0后的更新基本杜绝了"一个版本,一门语言"的乱象。到2020年,Apple更是陆续发布了如SwiftUI、Combine、CareKit等一系列Swift专属的SDK,其中不乏Apple这些年重点推广的技术方向如AR、AI、Health等。这些迹象都表明,Apple官方是鼓励开发者放弃Objective-C,直接拥抱Swift的。在官方这种广力度之下,越来越多的开发者、开源项目都加快了纯Swift生态建设的脚步。我们也看到很多知名开源库如Massonry、SnapKit等都推出了Swift版,并且有比Objective-C更好的更新节奏。
撇开官方的支持态度,只从语言本身的特性来说,Swift作为一门新兴语言,相比Objective-C有巨大的后发优势:安全、高效、高性能等。这些特性也有利于了提高App的质量。因此,国内很多代表性的互联网厂商如淘宝、百度、字节等,也相继在各自的拳头产品中全部或者部分完成了Swift化。
在这股Swift浪潮中,58集团于2020年底启动了混天项目,目标是搭建、产出Swift开发环境的基础组件、调试工具、开发规范文档、与Objective-C混编的基础设施等,为58的iOS开发者们提供一个友好的Swift入手契机,最终为58集团各App的Swift化奠定坚实的基础,预计将带来3个显而易见的收益:1、规避Apple的审核风险:已经有越来越多的功能实现如CareKit等,Apple规定只能用 Swfit 来开发,需尽快落地,规避后期可能出现Apple审核风险。2、降低开发成本:在前文提到的大趋势下,集团各 iOS 团队都要进行 Swift 语言落地的技术研究和实践,重复度高。通过混天项目,大家协同开发,技术共享,在集团层面大大节省了研发成本。3、业务提效:使用 Swift 进行业务开发,研发效率相比 Objective-C,能大幅提升(崩溃率降低30%,代码量降低20%-30%)。目前58的iOS团队维护了包含58同城、58同镇、赶集网、招才猫、安居客、新英才、车商通等在内的十几款App,为了降低维护成本,团队将一些基础功能进行了组件化改造,提供了大概20个通用的自研基础组件。不同的App对基础组件的使用情况各不相同,同时为了解决部分垂直业务在跨App维护时多App底层差异的问题,团队在基础组件的基础上又增加了中间件层,使依赖关系的复杂度进一步增加。上层逻辑的Swift改造需要底层基础库的支持,加上全面推进Swift的改造,将涉及大量的业务改造,各层模块之间的协同也是不小的挑战。为了支持业务的Swift改造能够循序渐进地平稳进行,基础组件各SDK首先需要为Swift调用提供一个友好的调用环境。经过调研,如上图所示,基础组件层的所有SDK,包括自研和三方采购的组件,基本全部为Objective-C编写,如果在限定时间内将各SDK彻底Swift化,无疑需要可怕的工作量,并且其中的代码重构风险和外部的不可抗力也是开发者无法承受的。最终,混天项目成立了基础组件小组,来完成各SDK支持Swift调用的混编工作,将这个过程中遇到的问题进行总结归纳并提供行之有效的解决方案,最终目的是最大限度降低业务在混编环境下的SDK调用成本。
基础组件小组主要从两个方面进行了SDK的Swift适配工作:1、完成SDK的Module化,支持上层以Module形式声明SDK调用;2、调研Objective-C的API在Swift调用环境下面临的实际问题并提供解决方案。实际开发中经常会封装一些基础的组件,或者是集成第三方SDK以供业务开发时使用,所以在Swift混编实践中,就涉及到这些SDK的导入和调用问题。在Objective-C中,我们需要通过#import 或#include导入头文件,编译器在预处理阶段,将这些头文件里面的API复制到当前文件,才能顺利访问到。由于Swift缺少了预处理阶段,头文件不能像 Objective-C一样使用#import 或#include导入了,但还是需要类似的操作才能访问到这些SDK。Apple为我们提供了两种方案来实现这个操作;这两种分案分别适用于处理文件调用和库(Framework,.a库)调用。桥接文件
调用 Objective-C 文件,主要通过桥接文件来实现,在桥接文件中导入需要暴露给 Swift 模块的 Objective-C 类头文件,即可在 Swift 模块中直接调用,如下图所示:
调用 Objective-C 编写并打包封装的静态/动态库
不同于文件调用,Swift 调用 Objective-C 库可以通过 LLVM 的 Module 系统来实现,Xcode 打包时默认支持 Module 系统,它对当前开发者来说没有任何成本,只是 Framework 中自动生成 modulemap 文件(后文会介绍),我们主要通过这个描述文件来访问。
桥接文件、Module 是目前我们实现 Swift 调用 OC 的主要方案,对Swift开发者来说,这两种方案的成本都比较低,桥接文件相当于我们的头文件汇总声明,而Module对调用方来说,基本是零成本,并且混天项目所需要处理的情况主要是针对目前58集团各App内的自研SDK和第三方SDK。因此,SDK的Module化是实现混编的重要途径,混天项目也是通过Module这个方案来推动混编实践,本文的后续内容也主要围绕Module进行描述。日常的工程开发中,为了处理不同文件之间的函数调用场景,我们需要对一些当前文件中没有的函数进行声明。在这个背景下产生了#include机制,特定的头文件经过#include 后,会被包含进当前文件。事实上,#include 的作用是告诉编译器将目标文件的代码粘贴到当前文件。
#import 是 #include 的优化版本,本质上它也是做粘贴的工作,但是为了防止头文件的重复引用引起的命名冲突,编译器在处理 #import 判断逻辑,保证每个头文件只会被引用一次,具体实现原理是通过 #ifndef #define 一个头文件标记,在处理头文件引用的时候则判断该标记,如果已经定义过就不再对目标文件进行粘贴。从 #include 与 #import 原理可以看出来,它们是基于文本语义,在文本替换这个方案下,会带来一些问题:- 大量的预处理成本
假如有M个头文件,每个头文件又#include了N个头文件,那么整个预处理的成本是N*M。这会在目标头文件中引入数量庞大的代码。假如是编译C++语法文件,那情况就更糟了,因为它还包含了一些模板代码,数量比 C 还要多好几倍; - 宏定义冲突
因为是文本导入,并且按照include依次替换,当一个头文件定义了#define std hello_world,而另一个头文件刚好又是C++标准库,那么include顺序不同,可能会导致所有的std都会被替换。那最终运行时结果就出乎意料了,这使得预处理变得非常“脆弱”; - 边界不明显
什么时候用什么工具、库来开发软件,仅仅从头文件上面看其实你并不能看得懂,因为它并不是“语义化”的,比如哪些命名空间属于特定的库,比如拿到一组.a和.h文件,很难确定.h是属于哪个.a的?这些命名空间又该以如何的顺序包含,需要以什么样的顺序导入才能正确编译?或者你又只想引入这个库的一部分定义,只通过 “#include”之类的预处理是很难搞清楚的。
Clang Module
Apple于2012年引入了Module机制,不同于#include,Module采用了更高效的语义模型。用树形的结构化描述来取代以往的平坦式 #include并缓存下来,生成一个树形结构的modulemap,用于描述框架、系统头文件、控制导出的范围、依赖关系、链接参数等等。
对于支持Module的模块,在编译时会被当做一个独立的编译单元,该单元只会被编译一次,编译器会维护一个已经编译的单元列表。如果在目标文件中引用到了某个Module,首先会在这个单元列表中进行查找,如果没有找到会进入编译流程并添加进来,如果找到了则直接使用已编译好的Module单元,类似于App中常用的缓存机制。在Module这个引用机制下,预处理消耗由M*N减低到了M+N的级别。
在实际使用中,可以将Module看作一个框架接入的中间件,这个中间件维护了编译单元和具体headers的路由关系,这种路由关系是通过modulemap这种形式来表达的。我们通过UIKit的modulemap来具体解释一下:如图所示,该modulemap的内容主要分为3部分:- umbrella header
我们应该非常熟悉<UIKit.h>这个文件,在modulemap中,它起到一个汇总的作用,我们查看UIKit.h这个文件,会发现它其实是对UIKit所有可调用头文件的汇总,这里主要是为了语法上的便利,避免在该文件中描述所有的头文件。 - export *
export *语义作用是描述所有子Module,即:假如调用方可能需要调用UIKit中所有的头文件,只需要声明UIKit即可. - explicit module
explicit module语义是针对子Module进行的描述,它将Module根据实际需要拆分成了不同的子Module。即:调用方需要针对调用手势识别相关的内容,只声明UIGestureRecognizerSubclass这个Module即可。
在Xcode内开启Module支持选项
手动为工程开启支持module选项很简单,只需要将Target配置选项开启即可。即:Target -> Build Settings -> Packaging -> Defines Module 设置为YES即可。如下图所示:
Framework如何支持
创建支持Module的Framework
在Xcode中创建framework并支持Module也是很简单的: 在新建Framework的时候,Xcode默认在build settings中设置了Defines Module为YES:
当你编译framework的时候,在对应路径下就已经生成了module文件,格式为:frameworkmodule SwiftFramework {
umbrella header "SwiftFramework.h"
export *
module * { export * }
}
已有的Framework支持Module
对于旧的已经存在的framework或者Static Library为了支持Modules,需要做如下配置:- 创建modulemap文件,Defines Module手动开启
- 将module.modulemap添加到copy Files中:
- 在 Module Map File 中设置路径即可
.a库如何支持
由于.a库默认是不支持 module 的,为了对项目中引入的.a库也支持 module,我们可以通过以下方法进行支持:- 新建framework库,然后将 .a库进行封装,重新生成支持 module的 framework,只是套壳而已,不建议;
- 对于自己开发的 .a 库工程,可以通过新建 framework 工程来支持 module;这样可以彻底改造,而且之前的git提交记录也是存在的,推荐
- 对于支持Pod导入的.a库生成支持module的framework,给 *.podspec 文件添加如下代码:
# 通过 's.prepare_command' 增加新建.m文件的命令
# Cocoapods 会检测库是否含有.m文件还是仅仅含有静态库来决定是否打包成 framework.
# 以下为静态库 .a 转为 framework
s.prepare_command = <<-EOF
touch include/Read.m
cat <<-EOF > include/Read.m
//framework module WPush {
// umbrella header "WPush-umbrella.h"
//
// export *
// module * { export * }
//}
\EOF
EOF
- 在项目的 Podfile 文件中 开启:use_frameworks!
- 重新 pod update,即可在 Development Pods 中生成支持 module的 framework
解除Framework间的依赖关系
Framework间的循环依赖问题会为SDK的module改造带来问题,为此我们提供了通过协议桥接的方式解除依赖。在具体实施时,请选择循环链中头文件API最为简洁的SDK进行协议化,最大程度降低成本。下面我们以HunterKit和DeviceSDK为例,进行解依赖。
通过观察头文件,我们发现,DeviceSDK更为简洁(HunterKit有四类API需要调整),因此我们对DeviceSDK进行协议改造。@interface DeviceSDK : NSObject
+ (void)deviceCreateWithKey:(NSString *)key;
+ (NSString *)idQuery;
@property (nonatomic, copy) void(^Block)(void);
...
@end
而在HunterKit使用时,目前的模式为引入头文件,进而调用API方法,如:#import <DeviceSDK/Device.h>
+ (NSString *)xxxid
{
NSString *xxxid = [DeviceSDK idQuery];
return xxxid ?: @"";
}
此处,我们需要对引入进行隔离,首先创建类的同名Protocol,如DeviceProtocol,并且在该协议中声明DeviceSDK的API方法:@protocol DeviceProtocol <NSObject>
+ (void)deviceCreateWithKey:(NSString *)key;
+ (NSString *)IdQuery;
@property (nonatomic, copy) void(^Block)(void);
...
@end
之后再调用,当使用DeviceSDK类时,动态获取这个类,调用API时,通过协议调用即可。Class DeviceSDK= NSClassFromString(@"DeviceSDK");
if (!DeviceSDK) return;
id <DeviceProtocol> cls = [DeviceSDK self];
[cls idQuery];
[cls deviceCreateWithKey:@"XXX"];
id <DeviceProtocol> printObj = [[DeviceSDK alloc] init];
[printObj deviceCreateWithKey:@"XXX"];
printObj.Block = ^{
//...
};
至此,HunterKit中不再引入DeviceSDK的头文件,两个SDK之间不存在引用,解除依赖关系。导入到Swift
封装的基础组件及第三方SDK支持Module之后,即可通过import
导入到Swift,完成调用。// 导入WPush.framework
import WPush
可能遇到的问题
如果 SDK-A 公开头文件内引用了 SDK-B 的公开头文件,而这个被引用的 SDK-B 没有支持 module 化,就会出现include of non-modular header inside framework module
的错误,我们可以通过以下方式解决:
1、被引用 SDK-B 支持 module 化改造 - 推荐
2、被引用 SDK-B 由于某些原因不能支持 module 化,我们可以通过在 SDK-A 的实现文件中(.m)引入SDK-B的公开头文件,并封装实现对应功能,然后再在SDK-A的公开头文件中提供对外API;
若 SDK-A 的公开头文件中依赖了 SDK-B中的枚举类型,我们可以将枚举类型改成 NSUInteger 基本数据类型或者在SDK-A 的公开头文件中前向声明该枚举来解决此问题;如:
typedef NS_ENUM(NSUInteger, WBEnvLevel);
对基础组件进行Swift module化改造,重点要关注如何使基础组件中的Objective-C SDK在被Swift业务或Swift中间件使用时能够正常运行。在这一过程中主要是解决Swift 与Objective-C的混编问题,其中最典型的三类问题是:
1. Swift 调用 Objective-C API
2. Objective-C +load 与 Swift 替代方案
3. Objective-C SDK 支持 Module1、Swift 调用 Objective-C API
首先要解决的是支持Swift业务或中间件调用Objective-C SDK 的 API的问题。由于现阶段团队提供的所有的 SDK 都是通过 Objective-C 进行编写的,为了支持业务的Swift改造,Objective-C 编写的SDK项目必须进行改造以支持Swift业务的调用。Apple的Swift团队在OC与Swift混编改造方面已经做了很多努力,编译器对自动兼容的支持大大减少了我们的工作量,但是为了使Swift业务更加规范地使用我们自研的Objective-C的SDK,还需要根据Apple官方的建议进一步进行适配工作,主要是SDK的API的规范化调整。在Swift 中存在一个可选类型的语法,即Optional Type。简单来说就是在声明变量或函数参数的时候,在类型声明后面添加’?‘或’!’ 符号来标识该值是一个有可能为空(optional)的值 或者不会为空的值(non-optional)。但是Objective-C 并不存在这样的语法,与 Swift 混编时编译器无法自动识别Objective-C函数的参数是optional 还是 non-optional,这种情况下编译器会隐示地都当成是 non-optional 来处理,这就使得Swift业务在使用Objective-C SDK值时很不方便,一些明明是optional的参数在Swift侧变成了non-optional参数。其实为解决这一问题,Apple在 Xcode6.3 曾为Objective-C 引入了一个新特性 Nullability Annotations。这一新特性的核心是两个新的类型修饰:__nullable 和 __nonnull,其中__nullable:表示对象可以是 NULL 或 nil,__nonnull则表示对象不能为空。代码编写时,若不遵循这一规则,编译器就会给出警告。之后由于与第三方库的潜在冲突,在 Xcode7 中,Apple将关键字 __nullable/__nonnull 改成 _Nullable/_Nonnull,但Apple同样也支持没有下划线的写法 nullable/nonnull,这三种写法本质上是互通的,只是放的位置不同。你可以在 Objective-C 头文件标记一块区域作为audited for nullability,在这块区域内,任何简单指针类型都将被标注为 nonnull 类型,这样一来,如果有一大段代码需要加注 nonnull,就比前面那种繁琐的方式简单多了。我们只需要去指定那些 nullable 指针对象即可。代码示例如下:NS_ASSUME_NONNULL_BEGIN
@interface AAPLList : NSObject <NSCoding, NSCopying>
@property (copy, nullable) NSString *name;
@property (copy, readonly) NSArray *allItems;
- (nullable AAPLListItem *)itemWithName:(NSString *)name;
- (NSInteger)indexOfItem:(AAPLListItem *)item;
- (void)updateWithCompletion:(id _Nonnull(^ _Nullable)(id _Nullable params))completion;
@end
NS_ASSUME_NONNULL_END
// --------------
self.list.name = nil; // okay
AAPLListItem *matchingItem = [self.list itemWithName:nil]; // warning!
Apple官方对该特性有更详细的说明,可以通过文章《 Nullability and Objective-C》获取更进一步的了解。下面表格说明了不同的Nullability写法对属性、返回值、参数、Block 的推荐情况:Nullability Annotations | 属性 | 方法返回值 | 方法参数 | C 函数参数、Block 的参数、Block 返回值 |
---|
__nullable/__nonnull | ❌ | ❌ | ❌ | ❌ |
nullable/nonnull | ✅ | ✅ | ✅ | ✔️ |
_Nullable/_Nonnull | ✔️ | ✔️ | ✔️ | ✅ |
由于基础组件各项目的代码历史较早,大部分基础组件几乎没有函数的参数声明是增加 Nullability声明的,于是我们对基础组件的第一个重大的兼容改造就是为所有暴露给接入方使用的API都增加了Nullability声明。在Objective-C 中用 id 类型来表示不确定的对象类型,编译器在Swift侧会将其转为Any类型。编译器会自动在Objective-C与Swift代码之间进行转换。接下来主要需要了解被转为Any类型的值是如何使用即可。
Swift 中,在使用 Any 类型对象时,如果知道其基础类型,一般将这些对象转换为基础类型即可。但是,由于 Any 类型可以引用任何类型,编译器无法保证能够成功地转换为更具体的类型。这时候可以使用条件判断类型转换运算符 (as?),得到想要尝试转换到的类型的一个可选值:var x: Any = "hello" as String
x as? String // String with value "hello"
x as? NSString // NSString with value "hello"
x = "goodbye" as NSString
x as? String // String with value "goodbye"
x as? NSString // NSString with value "goodbye"
let userDefaults = UserDefaults.standard
let lastRefreshDate = userDefaults.object(forKey: "LastRefreshDate")
// lastRefreshDate is of type Any? if let date = lastRefreshDate as? Date { print("\(date.timeIntervalSinceReferenceDate)") }
如果十分确定对象的类型,可以改为使用强制向下转换运算符 (as!):let myDate = lastRefreshDate as! Date
let timeInterval = myDate.timeIntervalSinceReferenceDate
let myDate = lastRefreshDate as! String
说到 id 类型,不得不提一下 instancetype。对于简易构造函数,更提倡用 instancetype,id 是通用对象,但如果是 instancetype,编译器就知道方法返回什么类型的对象,使得代码更加明确。示例如下:+ (id)buttonWithType:(UIButtonType)buttonType
class func buttonWithType(buttonType: UIButtonType) -> AnyObject!
var button = UIButton.buttonWithType(UIButtonType.System) as UIButton
+ (instancetype)stringWithCString:(const char *)cString encoding:(NSStringEncoding)enc
class func stringWithCString(cString: CString, encoding enc: UInt) -> Self!
var str = NSString.stringWithCString("foo", encoding: NSUTF8StringEncoding)
简单来说Objective-C 中的 id 导入到 Swift 中则是 Any,对于 id 类型的调用,我们需要用类型转换运算符来判断。而instancetype导入到Swift后其返回值的类型则是确定的。确定类型使用强制向下转换运算符 (as!) 即可。不确定的类型的值则需要使用条件判断类型转换运算符 (as?) 尝试向下转换为目标类型的optional值。前面讲过我们几乎所有的 SDK 都是通过 Objective-C 进行编写的。为提高开发效率,产出足够精简的代码,这些 Objective-C SDK使用了大量的宏。于是在Swift混编实践中,我们不可避免的要调用到这些Objective-C 宏。然而不幸的是,Swift中对宏的态度是消极的。我们已经知道,Objective-C开发环境下的Clang编译器在进行编译时的第一个阶段就是预处理,这个阶段一方面是处理 include 和 import 等头文件,另一个重要的任务就是宏展开。但是在Swift中是没有引进预处理指令的,Swift对Objective-C代码的调用其实是导入的Clang处理过的抽象语法树(AST),这些就导致Swift无法完成宏展开的任务,也不能直接使用Objective-C定义的宏。在我们的实际混编开发工作中,Objective-C中的宏可能会非常的繁杂,甚至存在生成一系列方法的复杂宏,经过调研,基础组件组总结举例了四个宏的使用场景和解决方案:- 对于没有参数的,简单的常量替换的宏,在Swift中直接使用全局常量即可
#define kScreenHeight ([[UIScreen mainScreen] bounds].size.height)
let kScreenHeight = UIScreen.main.bounds.size.height
- 对于简单的表达式,常量已经不能满足我们的需求,此时我们可以直接将宏展开成Swift表达式
#define WBNetwork [WBNetworkManager sharedManager]
WBNetworkManager.sharedManager()
- 有些复杂的表达式,如果需要频繁调用,展开成Swift表达式显然是太麻烦了,此时我们可以将OC的表达式封装成一个Swift全局方法,以供调用:
#define kColorWithHex(rgbValue) \
[UIColor colorWithRed:((float)((rgbValue & 0xFF0000) >> 16)) / 255.0 \
green:((float)((rgbValue & 0xFF00) >> 8)) / 255.0 \
blue:((float)(rgbValue & 0xFF)) / 255.0 alpha:1.0]
public func hexColor(_ value: UInt64) -> UIColor {
let redValue = CGFloat((value & 0xFF0000) >> 16)/255.0
let greenValue = CGFloat((value & 0xFF00) >> 8)/255.0
let blueValue = CGFloat(value & 0xFF)/255.0
return UIColor(red: redValue, green: greenValue, blue: blueValue, alpha: 1.0);
}
hexColor(0x333333)
+ (UIColor *)hexColor:(long)hexValue {
return kColorWithHex(hexValue);
}
WBSwiftTool.hexColor(0x333333)
将宏封装为OC方法,能够适用于大部分自定义的宏,且不需要重新实现原来宏的功能。原来的宏如果被修改,也能自动适配,影响较小。如果旧代码比较重,可以考虑这种方案。
- 除了我们自己定义的宏,还有一些是系统级的宏,对于这种宏,Apple也很贴心的为我们准备了相应的标准库函数,直接调用即可。
NSAssert(condition, desc, ...)
assert(condition: Bool, message: String)
众所周知,React Native使用了大量的模板宏来生成代码,非常复杂,不是简单的封装一层就能够轻松适配的。官方给出的适配方案是通过OC桥接。假定我们有一个用 Swift 实现的类,我们需要使用@objc
标记使得Swift的类和函数对 Objective-C 公开。
@objc(CalendarManager)class CalendarManager: NSObject {
@objc(addEvent:location:date:) func addEvent(name: String, location: String, date: NSNumber) -> Void {
}
@objc func constantsToExport() -> [String: Any]! {
return ["someKey": "someValue"]
}
}
接着,创建一个私有的实现文件,并将必要的信息注册到 React Native 中。
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(CalendarManager, NSObject)
RCT_EXTERN_METHOD(addEvent:(NSString *)name location:(NSString *)location date:(nonnull NSNumber *)date)
@end
更多详情参考React Native 从Swift导出
在基础组件改造以支持Swift业务使用的过程中,除了对原有Objective-C SDK进行Swift调用支持,也会逐步地将一部分基础组件直接改造成Swift组件。其中很多SDK在开发过程中使用了Objective-C的+load方法,需要在App启动时自动执行一些如设置系统通知监听者或方法交换等逻辑。然而当前最新版的Swift已经不支持 +load 方法了,所以需要寻找替代方案。实践过程中解决+load问题的方案有很多,主要有如下三类:第一种类型是利用runtime的API遍历ClassList找到实现固定协议、或实现有固定方法的类,并调用相应的协议方法或固定方法,并在启动时手动统一触发。示例如下:protocol LaunchLoad: class {
static func launch_load()
}
class test2: NSObject, LaunchLoad {
static func launch_load() {
print("ssss2");
}
}
class test: NSObject, LaunchLoad {
static func launch_load() {
print("ssss1");
}
}
class LaunchConfg {
static func do_load() {
let typeCount = Int(objc_getClassList(nil, 0))
let types = UnsafeMutablePointer<AnyClass>.allocate(capacity: typeCount)
let safeTypes = AutoreleasingUnsafeMutablePointer<AnyClass>(types)
objc_getClassList(safeTypes, Int32(typeCount));
for index in 0 ..< typeCount {
(types[index] as? LaunchLoad.Type)?.launch_load()
}
types.deallocate()
}
}
test与test2遵循了LaunchLoad协议,实现了launch_load()方法。App启动时,手动调用LaunchConfg.do_load()方法。通过遍历Class_list中的类,找到test与test2的launch_load()方法并调用。
这种方案的优点是:侵入性低,可以最大限度还原OC中的+load形式。而它的缺点是:遍历classList会影响启动速度,理论上工程越大、类越多,该方案影响启动时间越长。第二种类型需要创建一个启动项配置管理对象,将所有实现固定协议、或实现有固定方法的类注册到启动项配置管理对象中,最后在启动时统一调用相应的协议方法或固定方法。示例如下:
struct LaunchConfg {
static func do_load() {
SwiftMiddlewareA.launch_load();
SwiftMiddlewareB.launch_load();
}
}
protocol SwiftLaunchConfig {
static func launch_load();
}
struct SwiftMiddlewareA: SwiftLaunchConfig {
static func launch_load() {
}
}
struct SwiftMiddlewareB SwiftLaunchConfig {
static func launch_load() {
}
}
这种方案的优点是:所有类在启动时的加载逻辑都集中在一起,便于统一维护和管理。但它的缺点就是:新类接入时需要主动去进行注册才能使其load替代方法在App启动时得到执行。第三类方法则是利用OC与Swift混编语法支持,借助@objc来修饰OC对象的+load方法,从而把分类中的+load传给主类,实现Swift类的调用。示例如下:
@protocol NSSwiftyLoadProtocol <NSObject>
@optional
+ (void)swiftyLoad;
+ (void)swiftyInitialize;
@end
#define SWIFTY_LOAD_INITIALIZE(className) \
@interface className(swizzle_swifty_hook)\
@end\
\
@implementation className(swizzle_swifty_hook)\
+ (void)load {if ([[self class] respondsToSelector:@selector(swiftyLoad)]) {[[self class] swiftyLoad];}}\
+ (void)initialize {if ([[self class] respondsToSelector:@selector(swiftyInitialize)]) {[[self class] swiftyInitialize];}}\
@end
SWIFTY_LOAD_INITIALIZE(TestObject)
@objc class TestObject: NSObject {
}
extensionextension TestObject : NSSwiftyLoadProtocol {
public static func swiftyLoad() {
print("TestObject--->swiftyLoad")
}
}
这种方案在原理上与第二中方案有很大不同,但是在使用形式上差别并不是很大。
目前这三类方案均可以用来解决基础组件在App启动时需要自动统一执行固定逻辑的需求。首先看一下整体完成情况,在OC&Swift混编改造项目中,我们分别完成了直播SDK、播放器SDK、微聊SDK等30多个内部SDK,覆盖率占集团内部总SDK的90%以上,并且进行了百度地图SDK、支付宝SDK、BuglySDK等常用的6个三方SDK的Module化改造。
做了这么多的改造工作,那实际的效果怎样呢?我们从组件使用的引入方式入手,结合文章前面章节的介绍,分别看一下#import、PCH以及Module的效果对比。#import的原理与缺陷
import做的事情非常简单,就是单纯的复制粘贴,将要引入的目标.h文件中的内容拷贝到当前文件,与include不同的是,还具备避免文件重复引用的能力。但是当引入的头文件不断增加的同时,就会明显遇到文件大小的问题。
我们以Apple公司对自家产品Mail这个App的分析为例,看下具体情况。下图是 Mail 这个APP里所有 .m 文件的集合,其中横轴是将所有.m文件进行的编号排序,纵轴是文件大小。
可以看到,该APP的文件大小区间很广泛,从几kb到200+kb不等,整体来看,大部分代码的大小都在50kb以下。但是我们如果往该项目的某个通用文件引入UIKit(大约400多kb)这样的组件,那么其他文件也会把UIKit.h包含的内容引入进来,这样做成的文件大小就超乎想象了,如下图所示:所以这种方式的扩展性太差了,也不允许用这样的方式引入代码,如上文所说,假设你有 M 个源文件且每个文件会引入 N 个头文件,按照刚才的说明,编译的时间就会是 M * N !PCH的弊端
PCH的出现就是优化上面的问题,在编译任意.m文件前,编译器都会先从PCH的缓存中读取,命中的话直接读取,无需再次编译。但是它带来的首要问题就是维护成本,随着项目的迭代,哪些文件需要移除与添加需要耗费大量时间梳理;其次就是冗余性,组件的所有地方都会引入PCH中的内容,但是很多引用文件都是没必要的,这并不是我们希望的结果。Module
最后Clang提出了Module的概念,具体的在前面的章节也有过介绍,概述来说,它就是包含了对接口(API)和实现(dylib/a)的描述,同时 Module 的产物是被独立编译出来的,不同的 Module 之间是不会影响的。所以从健壮性、扩展性以及维护成本等多角度分析,选择Module化的处理方式效果更优,并且在 Build Setting 中开启 Use Header Map 选项,引入Header Maps机制,编译效果更有显著提升。下图是58同城分别在非module、module化和使用Header Maps机制三种场景下编译时间数据,从整体时间来看,使用Header Maps的module化比非module下优化了36%左右。
以上是基础组件组在处理组件Swift适配中的一些实践总结,后续的工作内容主要如下:
制定 Objective-C 与 Swift 混编代码规范
该方向的规划主要规范OC SDK的代码编写,主要目的是为Swift的调用提供更友好的API,避免出现混编的链接问题,主要产出以下内容:- 用NS_SWIFT_NAME这个宏为OC的API命名
- …
该规划的落地形式计划产出OC代码Swift化参考手册的形式,主要集合在OC后续代码编写中,如何正确的适配Swift,使得混编工程安全快捷的编译。
Objective-C 代码转换 Swift 的工具探究
在Swift的推广革新中,很多独立模块想要使用Swift语言来实现,那么如何将这部分老的OC代码迁移至Swift版本?
1:重写(量大的话不太现实), 比较耗费精力和时间
2:使用转化工具
该部分的规划即实现一个OC代码转化为Swift代码的工具,基本原理是基于LLVM的语法树,将Swift与OC同样API的语法树进行映射,从而实现转化。目前处于调研阶段,该工具的可行性、准确率等指标都在调研中,准确率能达到95%以上的话,该工具的价值就能体现,能帮助开发人员节省大量的工作,只需要手动处理一下个别情况即可。
作者简介:
- 刘长城:58同城-多媒体技术部-iOS资深开发工程师
- 彭辉:58同城-多媒体技术部-iOS高级开发工程师
- 宋立国:58同城-信息安全部-iOS高级开发工程师
- 王晓晖:58同城-用户价值增长部-iOS资深开发工程师
参考文献:
- https://clang.llvm.org/docs/Modules.html#introduction
- https://developer.apple.com/swift/blog/?id=25
- https://developer.apple.com/swift/blog/?id=39