让 Objective-C 框架与 Swift 友好共存的秘籍
红魔曼联的超级拥痞
手速达不到 180 的废柴鼓手
正在学习 Blues 吉他的二货萌新
做得一手白日梦的理想主义者
目前是一名在美团工作的 iOS 工程师兼 Swift 布道者
Sessions: https://developer.apple.com/videos/play/wwdc2020/10680/
引子
每一年的 WWDC 里都会有一些类型 Apple 工程师教你如何写代码的 Session,这些 Seesion 的内容都偏向最佳实践,告诉你如何写出 Apple 风格的代码,解答你对代码里的各种疑惑,甚至给出你如何继续深入研究的方向,这对开发者来说,是一个非常好的学习机会。
在这个 Session 中,Apple 的工程师将告诉我们如何改造现有的 Objective-C 框架,使其能够更符合 Swift 的使用体验,所以你不仅能学习很多实际的技巧,也会进一步了解他们背后的思考。
话不多说,来看正文吧!
背景介绍
相比于六年前推出的 Swift 语言,Objective-C 在 Apple 生态圈的历史更为悠久,导致了历史包袱比较重的或者现有的工程中还会持续存在许多 Objective-C 的框架,这些框架不是孤立的,会与 Swift 的框架产生依赖关系和调用关系。而这种微妙的关系产生了许多棘手的问题。
不光社区里的开发者会遇到这样的问题,Apple 公司的工程师也无法例外,在 Session 里,Brent 用这样一段话来形容这个问题,我感觉很贴切:
We understand that, because Apple is in the same boat. We probably have more Objective-C frameworks than anyone in the world.
所以如何让 Objective-C 框架更好的为 Swift 服务也是他们要解决的问题之一,虽然 Swift 编译器在转换 Objective-C 接口时做了很多不错的优化工作,但很难满足所有开发者的期望,不过这不代表你没有办法去优化它,因为今天的 Session 就是做这个事儿的。
从改变途径来看的话,主要是通过以下几种方式:
-
遵循编译器的某些规则 -
在头文件里进行特殊标注 -
用 Swift 做中间层,重新封装原有代码 -
根据自己的喜好进一步优化
知识目录
Demo 工程
这个 Session 是围绕一个用于描述 NASA 载⼈航天计划的 SDK 展开的,这个 SDK 的名字叫做 SpaceKit,它是 Objective-C 编写的。
现在这个 SDK 会被一些 Swift 代码调用,所以我们要通过一些改造,使其更符合 Swift 的使用习惯。
如何查看编译器生成的 Swift 接口
考察一个 Objective-C SDK 是否符合 Swift 的使用习惯,最重要的一点就是看它生成的 Swift API 质量。那么,我们如何查看 Swift Compiler 自动生成的接口呢?
在 Objective-C 的头文件里,点击左上角的 Related Items 按钮,选择 Generated Interface 后,就会出现满足不同 Swift 版本的接口文件。
点开后,它的样子大体如下
这个功能对于我们理解如何生成更符合 Swift 使用习惯的 API 来说是非常重要的,所以希望你能掌握这个技巧!
自动生成的接口利与弊
下面是根据 Objective-C 源码自动生成的 API 接口,我们可以看到 Swift 编译器已经做了不少的优化,例如:
-
将 NSString,NSDate 类型转换成了 String,Date; -
将 Objective-C 里的初始化方法转换成了 Swift 里的构造器方法; -
将原有的 - (NSSet *)previousMissionsFlownByAstronaut:(SKAStronaut *)astronaut
的方法名优化成了previousMissionFlown(by astronaut:)
-
将原有的 -(BOOL)saveToURL:(NSURL *)url error:(NSError **)error
的错误处理 API 改成了 Swift 风格的 API
但这样的 API 接口还存在多问题,让我们列举一下:
-
SKMission 的问题:
-
过多的隐式解析可选类型 -
crew
属性里的 Any 定义过于模糊 -
save(to url:)
可能会在不该抛异常的时机点抛异常 -
previousMissionFlown(by astronaut:)
的方法名还不够优雅 -
SKAstronaut 的问题:
-
构造器之间关系不够清晰 -
SKErrorDomain 和 SKErrorCode 的问题:
-
NSError 风格的 API 在 Swift 里的使用体验非常不好,尤其在 try catch 中 -
SKCapsule 和 SKRocket 类型的常量
-
用于枚举的字符串常量在 Swift 里更适合使用 enum 类型来描述 -
SKCapsuleApolloCSM
的 API 消失了 -
SKRocketStageCount(_ rocket: String!) -> Unit
的 API 还有不少潜在的风险
如果你还看不出上面存在的所有问题,也无法提供所有问题的解决方案,那么这篇文章将十分适合你阅读。
所以让我一起来看看 Apple 工程师给出的解决方案吧!
改进的方法
如果想解决上面提到的各种问题,可以从下面四个方向入手:
-
提供更丰富的类型信息 -
遵守 Objective-C 的约定 -
解决缺少 API 的问题 -
改善框架在 Swift 里的使用体验
提供更丰富的类型信息
增加 nullability 的描述信息
Objective-C 指针既可以是一个有效值,也可以是空值,例如 null 或者 nil,这与 Swift 里的可选值行为十分相似。
如果我们再仔细想一下,就会发现在 Objective-C 里面,每个指针类型实际上都是可选类型,每个非指针类型都是非可选类型。可是大部分时间,一个属性或者方法不会处理输入值是 nil 的情况,或者永远不会返回 nil。
所以,默认情况下 Swift 会把 Objective-C 里的指针当做隐式解析可选类型,因为它认为这个值大部分情况下不会是 nil,但它也不完全确定。
虽说这种转换规则没什么毛病,但大量的隐式解析可选类型让代码变得意图模糊,好在我们有两个关键字注解可以去描述这个意图,他们分别是 nonnull 和 nullable
这两个注解在 Objective-C 里面只是用于记录开发者的意图,不是强制的。但 Swift 会用到这些信息来决定是否转换为可选类型。
另外需要注意的是,在标注完 nullability 后,原有的 Objective-C 代码可能会出现一些新的警告,这里请认真检查并按照提示进行修改,这会让你的代码更健壮。
除了 nonnull 和 nullable 以外,还有一对配合使用的宏 NS_ASSUME_NONNULL_BEGIN
和 NS_ASSUME_NONNULL_END
可以让我们的代码更清爽。
在这两个宏包裹的代码片段中,属性,⽅法参数和返回值的默认注解都是 nonnull 类型的,这样一来,我们就可以删掉许多冗余的代码。
但是这些方法并不适用所有的场合,例如你将 nonnull 直接放在常量前会触发编译器错误。还好这种错误是有解决办法的!
nonull 和 nullable 只能在方法和属性上使用,如果想拓展其使用场景,就需要直接调用这两关键字底层的内容,也就是 _Nonnull
和 _Nullable
。
这两种注解除了可以用在全局常量,全局函数的场景外,也适用于任何 Objective-C 任何地方的指针类型,甚至那种指向指针类型的指针。
现在我们看到 SKRocketSaturnV 终于如期所愿的摆脱了隐式解析可选类型!
然后我们看看下面的 API 可能存在的问题,在这里我们从 API 层面假设 capsule 是一个非空值,但可能这是不合理的,并不是每次的飞行计划都需要载人,不是么?
那么,如果 Objective-C 返回了⼀个 nil 值,⽽在 API 层⾯,Swift 认为这是⼀个⾮空值,又会发⽣什么呢?
如果是 NSString 或者 NSArray 的话,Swift 会得到⼀个空的字符串或者数组,这可能会引起一些问题,但对于其他类型,可能会拿到⼀个⽆效的对象,总之,可能与你的预期不⼀样。
如果是 Objective-C 对象,你可能很难注意到这⼀点,因为 Objective-C 会忽略 nil,但在某些 case 下,你可以会因为 null 指针崩溃或者触发异常⾏为。
编译器不会对这种⾏为作出任何的承诺,所以改变 release mode 或者 xcode 版本可能有不同的表现!
不论怎样,需要记住的是,当你头⽂件⾥某个东西不会是 nil 的时候,Swift 不会对其强制解包,所以你不会在返回 nil 的地⽅看到崩溃。
那么对于这种 case,就没有解决办法了么?
好消息是 Objective-C 编译器和 Clang 的静态检查能够很好的解决这个问题,所以在写好 nullability 的注解后,最好关注⼀下编译器警告和静态分析结果!
就如下图所示一样
当然我们知道,开发者可能还会遇到一些特殊的 case,在这些 case 里,他们没法确定代码到底是有值,还是没有值,所以 Apple 还提过了 _Null_unspecified
的注解词。
被 _Null_unspecified
标注的内容会被转换成隐式解析可选类型,这种类型在 Swift 里的使用场景大体如下,例如某个属性在其⽣命周期早期为 nil,之后再不会是 nil 的情况。
当然,在你⽆法确定的 case 里也可以这样使用,因为
-
如果一切按照预期,你可以⽆需解包,继续使⽤ -
如果返回的是 nil,你会稳定的复现这个 bug,⽽不是⼀些奇怪的⾏为
利用泛型约束接口
原有的接口中,没有对 crew 这个数组里的元素进行约束,这会使得其转换到 Swift 的 API 时,将其中的元素描述为 Any。
虽然也不是什么大的毛病,但用起来确实会显得有点别扭!
我们都知道 Objective-C 也提供了一些泛型的能力,所以我们完全可以将其优化到一个更好的层次上,通过在 Objective-C 里添加相应的语法内容,就可以将其在 Swift 的使用体验改善不少。
当然除了 NSArray,NSDictionary 等基本类型也适用这个技巧!
对于数字处理统一使用 Int
我们先看看这样一段代码,下⾯的函数返回⼀个计数值,很显然,你在喊倒计时的时候,数值不会为负,所以在 Objective-C ⾥⾯以 NSUInteger 的形式返回。
这样的声明,意味着在 Swift 里会返回⼀个 UInt,而这意味打破了 Swift 的使用习惯。
当我们想对比特位进行运算的时候,我们通常会使用 unsigned 类型的数据,因为 signed 的数据在处理起来会有些麻烦,而且在这种场景下,我们还十分关注数据的位数,但是由于 NSUInteger 的大小会因架构不同而产生一些变化,导致使用它的人并不多。
与此初衷不同的是,大多数人使用 NSUInteger 是为了表明这个数值是⾮负的,虽然这种用法是可行的,但它还是会存在一些严重的安全漏洞,所以这种设计思路并没有被 Swift 采用。
Swift 采取的策略是在进⾏有符号运算时,要求开发者必须将⽆符号类型转换为有符号类型,如果 Swift 在处理⽆符号运算时,产⽣了负值,就会直接停⽌运算。
也正是这样的策略,会让 Swift 中的 Int 和 UInt 在混合起来使用的时候变得很麻烦,当然,这在 Objective-C ⾥⾯的也是一个棘手的问题。
所以混合使用 Int 和 UInt 并不是 Swift 里的最佳实践,在 Swift 里面,我们建议将所有进行数值计算的类型声明为 Int,即使它永远不可能为负数。
对于 Apple 自己的框架,他们设置了一个白名单用于将 NSUInteger 转换为 Int。
对于开发者而言,决定权在我们自己手里,我们可以⾃⾏选择是否使⽤ NSInteger,但 Apple 的工程师强烈推荐你这么做。
或许在 Objective-C ⾥⾯差距不是很⼤,但在 Swift ⾥⾯很重要!
将字符串类型的常量变得更有条理
下面我们来看看这样一段代码,从某种角度上来说,SKRocketStageCount
这个 API 很容易被滥用,因为只要传一个字符串就行了,但其实我们希望传入的是以 SKRocket
为前缀的常量字符串。
可惜 Swift 无法感知这一切,它能看到的只是函数需要的是字符串⽽已,如果传了其他值的字符串,就会出现不符合预期的情况。
在 Swift ⾥通常会把这些常量变成⼀个具有字符串原始值的枚举或者结构体,然后改变函数的入参类型,使其接受相应的枚举或者结构体类型。
那么我们怎么去改造这个接口呢?我们先说个最简单的方法:
使⽤ typedef 将常量分组,并将涉及此常量的地⽅改为新的类型。而在 Swift 中,typedef 会被转换成 typealias
这已经使得代码发生了一些变化,不过这还不是最终效果!
此时,你只需要在 typedef 后⾯加上 NS_STRING_ENUM
即可, 此时,原有的字符串常量将以结构体的⽅式导⼊到 Swift 中, 而且,你注意到没有,SKRocketStageCount
的⼊参类型彻底的变了!
怎么样,一共就 2 步,就能得到原汁原味的 Swift API,是不是还不错!
这样的使用方式,可以在 Apple 的框架里看到不少实实在在的例子,例如 NSAttributedStringKey,NSCalendarIdentifier,NSNotificationName,NSNotificationUserInfoKey 等。
所以放心使用它吧!
遵守 Objective-C 的约定
关于构造器的相关约定
接下来,我们来看看构造器方面的问题。
下面的代码中,SKAstronaut 有两个初始化构造器,⼀个入参类型为 PersonNameComponents, ⼀个入参类型为字符串,
这就意味着,如果要声明⼀个 SKAstronaut 的⼦类,就需要重写两个⽅法,但 NSPersonNameComponents 本质也代表一个字符串,所以这样的工作显得有点多余。
同时,你还会在使用的时候发现,莫名其妙的多出来一个构造器,它没有任何的入参,我们在源⽂件⾥找不到任何与此相关的定义,但其实它来⾃ superclass。因为 SKAstronaut 继承⾃ NSObject。
虽然有这么一个方法,你也能调⽤,但很可能它⽆法正常⼯作!
如果你深入分析上面的两个问题会发现,它们的内核是一样的。
在 Objective-C 中,有⼀个关于初始化器的约定,它确保开发者知道如何写⼀个总是能被正确初始化的⼦类。
这个约定的大体内容是这样的,将初始化器分为两类,designated 和 convenience。你需要覆盖所有 designated 初始化器,以便安全地继承 convenience 的初始化器。
这个约定和 Swift 里面的构造器约定十分相似,但它们有个本质的区别!
Objective-C 的这种构造器约定不是语⾔级别的强制规则,更多的是⼀个开发者之间的约定,例如 convenience 必须选择⼀个 designated 的接口,但实际上很多 Objective-C 的类并没这么做,这也意味着如果有⼦类的话,如何正确构造它会成为⼀个头⼤的问题!
这是⼀个⾮常⾮常不好的事情,尤其对框架使⽤者⽽⾔,如果想写出⾼质量的代码,就必须阅读源码,或者逆向来观察它的行为,甚至通过猜测的方式, ⽽这都会导致⼦类出现异常的概率变⼤。
如果你忘了重写⼀些必要的构造器,作为框架的维护者是不会收到警告的,⽽使⽤者恰巧使⽤了这个 API,那就意味着这个类的初始化可能出现了问题,使⽤者会感觉很痛苦,为什么写个构造器这么难?
所以作为框架的维护者,我们需要去直面这个问题!
通常 designated 构造器会调⽤ [super init] 这个方法,而 convenience 构造器会调⽤⾃⾝的某个 designated 构造器
所以,我们需要在 designated 构造器后面添加 NS_DESIGNATED_INITIALIZER
, 对于 convenience 类型的构造器,你不需要做任何事情
在添加完 NS_DESIGNATED_INITIALIZER
以后,可能会遇到一些错误提示,它会要求你重写⽗类的 designated 构造器,因为这是一个潜在的 bug,如果有⼈使⽤了⽗类的 designated 构造器,而你没有对此进行处理,对象的构造就可能会出现问题。
所以
如果你想支持这些父类构造器,就去实现它!
如果你不想支持这些父类构造器,就需要完成如下的工作
-
在 .m
文件里重写父类构造器,并调用doesNotRecognizeSelector
方法
-
在 .h
文件里用NS_UNAVAILABLE
声明对应的 API
除此之外,还需要提醒的有两点
-
除了关注父类的 designated 构造器,开发者也需要关注 convenience 类型的构造器。 -
NS_UNAVAILABLE
的 API 不会被继承
通过这些改造,你的 Swift 接口将会变得清晰明了!
关于错误处理的相关约定
让我们再看看下面的代码
在整个文章的开始部分,我们提到这段代码可能会在不该抛出异常的时候抛出异常,至于原因,其实在这个 API 的注释里就已经说明了。
可能有人会问,看起来没什么问题啊?
这其实就是问题本身,可能我们会认为如果⼀个⽅法要发出失败的信号,就意味着必须要返回 false 且为 error 设置⼀个 non-nil 值;如果只返回⼀个 false 并不是真的失败。
但事实上,通常的约定是:返回 false 就是失败!即使 error 是 nil。
Apple 的工程师并不建议把 error 设置为 nil,因为这样的话,使用者就无法感知发⽣了什么,但如果你坚持这样做,也就是返回 false 的同时,返回了一个 error 为 nil 的值,从惯用的约定来看,它仍然代表失败!
所以在 swift ⾥调⽤这种 Objective-C 的⽅法时,它会⾃动导⼊ throws,而且 swift 会认为你遵循刚才提到的约定,所以只要⽅法返回 false 它就会抛出异常!
但是 Swift 又不允许你抛出异常的时候,提供的信息是 nil。如果没有 error,swift 会抛出⼀个基础库里⾮公开的 error 类型,由于这是⼀个⾮公开的类型,你是⽆法 catch 这些信息的,但是你可以在 logs ,debugger 或者 error message ⾥看到相关的信息。
这意味着⼀些 Obective-C 代码即使没有失败也返回了 false,或者失败了但没有告诉你原因。
所以回到一开始的那段代码上,⽂档注释⾥说明了在某些情况下,例如有东西需要保存的时候,会直接返回 false,虽然其实这不能算是失败,但是 Swift 会因为 false 抛出异常。由于⽅法没有设置正确的 Error 信息,就会提到我们前⾯说的情况,抛出⼀个基础库里⾮公开的 error 信息。
所以如何解决它呢?
最简单的方法就是去掉特殊情况,让 false 总是意味着失败,⽽且让该⽅法遵守前⾯提到的约定。
不过这也引入了新的问题,如果你的用户需要判断那种特殊情况的时候,这种⽅法就⾏不通了!
另外一种方式是,在 API 后面标注 NS_SWIFT_NOTHROW
来告诉 Swift 你不想遵守那个约定,这样的话,Swift 会让你⼿动处理错误相关的代码
虽然这解决了问题,但并不是最好的方式,建议将这个 API 废弃,此时你只需要在后面添加 DEPRECATED_ATTRIBUTE
即可
在 Objective-C 里最好的解决方案是重新写一个 API 并返回额外的信息来表示刚才提到的特殊场景。
例如可以添加⼀个布尔输出参数来说明⽂件是否真的被保存了,然后返回值就可以遵守之前的约定了。
目前来看,这是在 Objective-C 里的最佳解决方案了!
如果你还想进一步优化,我们还是有办法的,只是我们需要用 Swift 将其包裹一下并对外保留 Swift 接口,而非原先的 Objective-C 接口。
在 Swift 里,我们可以提供这样的一段代码来优化使用体验
在 Objective-C 里面,我们要小心返回值,因为他的惯用约定会认为返回 false 即是失败,而在 Swift 里,我们则可以忽略这一点,返回值与抛出异常或者失败是完全没有关系的!
所以代码会变成下面的样子!
为了让这个文章的内容更连贯,我将原 Session 里这部分的内容做了精简,此处还提到了以下几个知识点:
在框架的头文件里,也就是 umbrella header 里,不要导入 generated header,也就是系统自动生成的 -Swift.h,这个头文件会声明 swift ⽂件⾥所有被 @objc 标记的内容
不这么做的原因是会产生循环依赖,因为不导⼊ umbrella header ⾥的内容,Swift ⽆法⽣成 -Swift.h,但如果 umbrella header 导⼊了 -Swift.h,那么 swift 就会试图读取这个还没⽣成的⽂件,而这就会造成问题!
在某些平台上,Swift 的 bool 和 Objective-C 的 bool 略有不同,主要是在内存表示方面的问题。
通常,swift 会做⼀层转换,但是现在的代码⾥你操作的是指针,也就是将 Swift 的 bool 指针直接扔给 Objective-C 使⽤,而对于这种 case 来说,Swift 还没覆盖,所以我们需要做⼿动转换,声明⼀个 ObjcBool 类型即可
现在我们的 Swift 使用者终于可以使用到非常舒服的 API 了,不过还有一个问题就是,他在这种场景下,依然能接触到 Objective—C 里面提供的新 API:
-
-(BOOL)saveToURL:(nonnull NSURL *)url wasDirty:(nullable BOOL *)wasDirty error:(NSError **)error
他一定会面临这样的处境,我该用哪个呢?
所以作为框架的维护者,你现在的情况是:
-
希望 Objective—C 的使⽤者用到原先的 API, -
希望 Swift 的使用者用到 Swift 里提供的 API
针对这种情况,你也有相应的解决办法,此时你需要
-
在原有的头⽂件⾥对相应的 Objective—C 的⽅法标记 NS_REFINED_FOR_SWIFT
-
修改 Swift 里的代码实现
可能有人会好奇,NS_REFINED_FOR_SWIFT
到底干了什么?
其实这个标记做的事情很简单,它把对应的 Swift 版本 API 进行了改造,改造的内容就是在其开头增加了两个下划线在开头,
当 Xcode 看到这样的 api 时,会让编辑器将其隐藏起来,例如代码补全的时候,但它不代表你不可以调用,所以在刚才的 Swift 文件里,我们看到了 self.__save(to:url, wasDirty: &wasDirty)
的代码。
此时,我们仍然使⽤了 Objective—C 的实现,⽽且使⽤者也⽆法直接调取那些我们不想让他获取的 API 了!真是一个让人开心的结果!
解决缺少 API 的问题
通常 Swift 编译器会导⼊ Objective—C 头⽂件⾥的⼀切,但如果它⽆法识别如何导⼊的时候,就会忽略这些内容,进而导致某些 API 没有在 Swift 里展示。
这些场景会是如下的这些情况:
回到代码上,我们可以看到 SKCapsuleApolloCSM 这个 API 消失了!
在这里,我们用宏定义了一些字符串,在这里宏本质上只是⼀个⽂本⽚段,你可以在 Objective—C 源码的任何地⽅使⽤它。
同⼀个宏在不同的地⽅可能有不同含义,而 Swift 本⾝是没法搞清楚这些的,所以 Swift 只能识别符合某些特定模式的宏,这种模式主要是⽤来声明常量的。
它允许你为另外⼀个宏命名,或者给宏设置某个值,但是两者同时使用就会出现识别问题,
所以来看第四个宏,它替换了另外⼀个宏,并在原本的内容上增加了 ".csm",这对 Swift 来说有点超出其能力范围了!
有许多⽅法解决这个问题,最简单的就是⽤完整的字符串来表达这个宏,而不是用相对复杂的宏拼接。
但如果你是⽤这些字符串做枚举,建议你把它转成真正的字符串常量,这样就可以像前面的 SKRocket 常量⼀样进⾏字符 串枚举了!那才是好的最佳实践!
改善框架在 Swift 里的使用体验
如何改善 Swift 的 API 名称
Swift 和 Objective—C 的命名风格是有所不同,例如 Swift 的 API 是由基名(previousMissionsFlown)和参数标签(by)组成的,⽽ Objective—C 基本上只有参数标签(previousMissionsFlownByAstronaut),没有单独的基名,所以基名的信息会包含在第⼀个参数标签⾥,这也导致了 Objective—C 的方法名会显得略长一些。
为了解决 API 风格上的问题,Swift 会根据一些规则重命名,通常这个结果还不错,但这毕竟是计算机的审美结果,很难满足开发者的诉求。
例如某些开发者会认为 flown 应该是参数标签⾥的⼀部分,⽽不是 base name,因为这个⽅ 法获取的是以前的任务列表,它们是某个宇航员所执⾏的任务!
当然这不是绝对的,这⾥只是个假设。
所以为了解决这个问题,我们使⽤ NS_SWIFT_NAME
重新命名这个⽅法
好了!这个 API 终于满足你的诉求了!
或许你会说我知道 NS_SWIFT_NAME
能重命名方法名,但 NS_SWIFT_NAME
的能力还有很多施展空间!
例如下面的枚举!
或许,乍一看,这个枚举其实已经写得挺好的了,但其实也有不少改进的空间。
可能你会想到⽤ NS_SWIFT_NAME
删除其前缀 SK,因为在 Swift 里面没有这种做法,但不推荐这么做, ⼤部分 Objective—C 的类都会将框架前缀与⼀个像 query 或者 record 的词组合起来,例如 SKFuleKind ⾥的 SK 和 FuleKind。
所以我们需要用别的方法来优化使用体验,针对目前框架,我们有一个 SKFule 类,此时我们可以让这个枚举和 SKFule 联合使⽤,所以我们将其改为 SKFuel.Kind
除了重命名枚举外,NS_SWIFT_NAME
的另外一个常见使⽤场景是处理那些与 Swift 风格差异过大的 API,这在许多 C 语言的库里十分常见,例如全局函数,全局变量等。
像上面的例子中,SKFuelKindToString 就是一个全局函数,我们不仅可以⽤ NS_SWIFT_NAME
对其重命名,还可以去掉额外的信息,添加⼀个参数标签。
不过刚才处理全局函数的例子只是展示了 NS_SWIFT_NAME
能力的冰山一角!下面我们会再展开几个例子:
⾸先你可以将 global function 转换成 static method,做法是在 NS_SWIFT_NAME
里指明 Objective—C 的类型并在 类型后面使用点语法声明⽅法名
然后,你还可以将其变为实例⽅法!
最后,你也可以将某个⽅法变为⼀个属性,只需要在前⾯增加⼀个 getter,同理 setter
将这些技术应⽤在充满 C 函数的框架⾥,可以很好的重塑 API,如果你用过 Core Graphics 的话,你会深有体会!
下⾯要说说 NS_SWIFT_NAME
的能⼒边界,例如刚才的那个 getter 例子,即使你将⽅法名改为 description,你也⽆法让这个类型遵守 string convertible protocol。
但是我们可以通过在 Swift 文件里添加扩展来使其满足 protocol conformance!
如下图所示,给 SKFuel.Kind 写了⼀个扩展,并使其符合⾃定义字符串转换协议,由于 Objective-C 头文件已经⽤ NS_SWIFT_NAME
提供了相应的属性,所以写成这样,我们的 SKFuel.Kind 已经遵守了相应的协议并满足其使用要求。
为了让这个文章的内容更连贯,我将原 Session 里这部分的内容做了精简,此处还提到了以下几个知识点:
我们应当注意到刚才的 Swift 文件,在那里我们可以写出任何你想提供的 Swift 版本专⽤的 API。例如整合了 UIView 的 SwiftUI 组件,或者将原有 API 的 completion handler 换成 Combine 的 API. 如果对 SwiftUI 和 Combine 感兴趣,可以查看去年的两个相关 Session, Integrating SwiftUI [1] 和 Combine in Practice [2]
如何提升 Error Code 在 Swift 里的使用体验
error code 枚举在许多框架里都能见到,它通常会和 NSError ⼀起使⽤。
通常这类代码会分为两个部分
-
声明⼀个带有特定错误代码的 NS_ENUM
-
为了防⽌错误码与其他框架的错误码发⽣冲突,还会声明了⼀个用于表明作用域的字符串常量。
下⾯的代码在接口层⾯是没有问题的,但在使⽤时,就会出现⽐较明显的问题!
假设我们有这样⼀个场景:我们需要考虑在执⾏某次飞⾏任务的过程中,如果发射终⽌了,我们要确保救援队能去营救宇航员!
所以代码会是如下的样⼦:
首先调⽤发射任务的代码,如果是发射终⽌错误,我们需要对齐进行 catch,然后就是如何 catch 到特定的 case 了, 这里需要 error 中的作用域(domain)和错误码(code)信息
⾸先我们要把它作为⼀个 NSError 来捕获。接下来,我们需要确保错误是 SKError 的作用域,⽽不是其他可能使⽤相同错误代码的作用域。然后我们需要将错误代码从⼀个 Int 转换成⼀个 SKErrorCode 最后我们才可以检查它是否是我们想要的情况
看看上面这一坨代码,这只是为了匹配一个错误而已,真的有点复杂了!
毕竟这在 Swift 里可以通过一行模式匹配就搞定!所以我们有办法解决这个问题么?
答案很简单,将 NS_ENUM
替换成 NS_ERROR_ENUM
, ⽤错误的作用域替(SKErrorDomain)换原始类型(NSInteger)
此时,我们再看 Swift 接口文件的话,我们会发现它已经不再是静态常量,⽽是结构体了!
它里面的具体内容会如下所示,是不是变得很 Swift 了?
除了上⾯的优化,Swift 编译器还做了如下的调整
-
SKError 自动遵循了 Error 协议,使得其使用习惯更贴近 Swift 的⽤法。 -
提供一个 tilde equal 操作符,这就是 case 和 catch 语句匹配时使⽤的匹配操作符,
SKError 的这部分内容在⽣成界⾯是不可见的,但编译器真的会合成他们,并供你使⽤!⽽你要做的就是改⼀⾏代码,不得不说 Swift 编译器真香!
至此,我们终于打造出来一个比较不错的 API 接口了!
总结
现在回到文章最开始的地方,思考⼀个问题,在⽣成的 Swift 接口中,并不是所有的 API 都有明显的缺陷,例如最后一个 Error Code 的案例,可能只有当我们看到 SKErrorCode 被使⽤的时候,才意识到这里有改进的空间。
虽然查看编译器生成的 Swift 头文件是一个好的方法。但⽣ 成的接口并不是全部,真正重要的是使用者在实际使⽤过程中写出的调⽤代码。
所以当我们在思考如何打造一个更适合 Swift 使用的接口是,不光要看看⽣成的接口。也应该考虑实际的使用场景。
总之,在 Session 中,Apple 的工程师提供给了我们很多在 Swift 里改善 Objective-C 框架体验的方法,也着重提醒了我们,相比于关注头文件,我们要更关注实际的使用体验和使用场景。
相信大家一定都有不少的收货,可能有人会问,这就是所有的手段了么?当然,Session 里面并没有展示全部的细节,如果你想进一步了解相关的内容,可以阅读以下内容:
-
Swift Document 里的 “Language Interoperability” 章节, 传送门在此 [3]
如果你对 Swift API 本身的风格还不够了解,建议先阅读下面的文档:
-
Swift API Design Guidelines: 中文传送门 [4], 英文传送门 [5]
当然如果你想了解这种混编过程的细节,建议您观看 WWDC 18 Behind the Scenes of Xcode Build Process[6]
推荐阅读
关注我们
我们是「老司机技术周报」,每周会发布一份关于 iOS 的周报,也会定期分享一些和 iOS 相关的技术。欢迎关注。
支持作者
WWDC 内参 系列是由老司机周报、知识小集合以及 SwiftGG 几个技术组织发起的。已经做了几年了,口碑一直不错。 主要是针对每年的 WWDC 的内容,做一次精选,并号召一群一线互联网的 iOS 开发者,结合自己的实际开发经验、苹果文档和视频内容做二次创作。
参考资料
Integrating SwiftUI: https://developer.apple.com/videos/play/wwdc2019/231/
[2]Combine in Practice: https://developer.apple.com/videos/play/wwdc2019/721/
[3]传送门在此: https://developer.apple.com/documentation/swift#2984901
[4]中文传送门: https://github.com/SketchK/the-swift-api-design-guidelines-in-chinese
[5]英文传送门: https://swift.org/documentation/api-design-guidelines/
[6]WWDC 18 Behind the Scenes of Xcode Build Process: https://developer.apple.com/videos/play/wwdc2018/415/