8282066da499695871344bcf75eb9c94
WWDC20 10680 - 让 Objective-C 框架与 Swift 友好共存的秘籍

引子

每一年的 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_BEGINNS_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 构造器,而你没有对此进行处理,对象的构造就可能会出现问题。

top Created with Sketch.