Mobilization roy marmelstein cover

Objective-C 的运行时以及 Swift 的动态性

自 Swift 推出以来,人们总是不断希望 Swift 能够「更加动态化」。但是「动态化 (dynamic)」又是什么意思呢?为什么 Objective-C 比 Swift 更加动态化呢?如果我们无法使用动态化的语言,那么该如何去构建以往那些依赖于动态性的东西呢?在本次 Mobilization 2016 大会上,Roy Marmelstein 详细阐述了这些问题。


概述 (0:00)

今天我讲演的题目是「Objective-C 的运行时以及 Swift 的动态性」。这里我添加了一条「2016 年的观点」的限定性标注,因为我讲的很多内容都将在未来几年内发生改变。

让我们回到五个月前,大概是五月中旬吧,阳光明媚,春暖花开,著名的 Objective-C 和 iOS 开发者——Brent Simmons 发表了一系列博文。在这些博文当中,他记录了各种开发者们借助 Objective-C 的运行时机制而解决的问题。他试图证明的是,Swift 作为一门编程语言,竟然没有这些问题的原生解决方案。即便时至今日,Swift 仍然捆绑了 Objective-C 的运行时机制,就算未来 Swift 完全将 Objective-C 取而代之,我们仍然不知道该如何解决这些问题。

接下来,便是 Twitter 上的一场宏大的战役,人们开始站队。过去 20 年来一直在为 Mac 开发 Objective-C 应用的人们被视为保守派,而 Swift 开发人员则被视为改革派。一方代表了经验和灵活性,而另一方则代表了理想化和类型安全。网上充斥着各种各样针锋相对的推文和 Medium 文章,最终事态变得无法控制,甚至为此你都能够买相关 T-shirt 来声援自己的阵营。

因此,我觉得是时候应该来谈论一下这个敏感的话题了。今日,我们将一起来探索运行时函数,并且谈论一下所谓的「动态性」到底是什么意思。我们将看一下 Swift 如今所处的位置,探究其缺陷,并展望未来。这些内容可能会有些纷繁复杂,因此我保证最后会放上一只很酷的猫咪 GIF 图作为结束。是不是很值得期待呢?

Objective-C 运行时 (2:06)

在我们开始之前,需要强调一点,Objective-C 是一门基于运行时的编程语言,这意味着所有方法、变量、类之间的链接,都会推迟到应用实际运行的最后一刻才会建立。这将给开发人员极高的灵活性,因为我们可以修改这些链接。而不同的是,Swift 绝大多数时候是一门面向编译时的语言。因此在 Swift 当中,灵活性受到了限制,不过您会因此得到更多的安全性。

这就是本次辩论的核心内容。所以不用多说,让我们开始吧!

Objective-C 的运行时本质上是一个库。它负责了 “Objective” 这个部分,因此您所知、所爱的面向对象编程,都是在这里实现的。如果您想要访问里面的函数的话,只需要导入这个库即可:

#import <objc/runtime.h>

它主要由 C 和汇编编写而成,其实现了诸如类、对象、方法调度、协议等等这些东西。它是完全开源的,并且开源了很长一段时间了。您可以将源码下载下来,查看一下面向对象的这些特性是如何实现的,从而更深、更好地掌握我们用于开发的这门语言。

运行时负责 Objective-C 中的面向对象编程这个部分。让我们从基本的构建模块开始。那么什么是对象呢?对象在 runtime.h 当中是这样定义的:

typedef struct objc_class *Class;

struct objc_object {
	Class isa;
};

对象只与一个类建立引用关联,也就是这个 isa 的意思所在。这也就是 Objective-C 当中的所有对象都需要实现的。那么类又是什么呢?类的定义要稍微复杂一些。

struct objc_class {
	Class isa;
	Class super_class;
	const char *name;
	long version;
	long info;
	long instance_size;
	struct objc_ivar_list *ivars;
	struct objc_method_list **methodLists;
	struct objc_cache *cache;
	struct objc_protocol_list *protocols;
};

Receive news and updates from Realm straight to your inbox

类当中同样有 isa 这个值。它与 super_class 这个值进行关联。除了 NSObject 这个类之外,super_class 的值永远不会为 nil,因为 Objective-C 当中的其余类都是以某种方式继承自 NSObject 的。之后,我们还有 nameversioninfo 之类的值,不过这些并不是我们感兴趣的内容。

对于我们而言,更多的应该是关注变量列表 (ivars)、方法列表 (methodLists) 和这个协议列表 (protocols)。这些就是我们能在运行时修改和读取的。可以看到,对象其实本质上是一个非常简单的结构体,类同样也是。我们可以借助运行时函数,从而在运行时动态创建类。

我们为什么要这么做呢?因为这个函数被大量运用在库提供者制作的框架当中。如果您无法知道用户将会创建什么样的数据,那么您就需要在运行时进行类的创建了。Core Data 就使用了这个功能。此外,如果您愿意的话,它还可以用在 JSON 解析当中。

Class myClass =
objc_allocateClassPair([NSObject
class], "MyClass", 0);

// 在这里添加变量、方法和协议

objc_registerClassPair(myClass);
// 当类注册之后,变量列表将会被锁定

[[myClass alloc] init];

这就是我们要用的 Objective-C 运行时函数:allocateClassPair。我们为其提供一个 isa,在本例当中我们提供了 NSObject,然后为其命名。第三个参数则是额外字节的定义,通常我们都直接赋值 0 即可。随后我们就可以添加变量、方法以及协议了,之后就注册这个 ClassPair。注册之后,我们就无法修改变量列表了,不过其余的内容仍然可以修改。

结束~我们所创建的这个类和其余的 Objective-C 类毫无区别。

类别 (5:34)

如果您想要扩展一个不是自己创建的类,想要向其中添加函数,有一个便捷的方法便是使用 Objective-C 的类别 (Category) 特性。Swift 的扩展与之非常相似。类别的一个问题便在于,它无法添加存储属性。您可以添加一个计算属性,但是存储属性是无法添加的。

运行时的另一个特性便是:我们可以借助 setAssociatedObjectgetAssociatedObject 这两个函数,向既有的类当中添加存储属性。

@implementation NSObject (AssociatedObject)
@dynamic associatedObject;

- (void)setAssociatedObject:(id)object {
	objc_setAssociatedObject(self,
@selector(associatedObject), object,
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (id)associatedObject {
	return objc_getAssociatedObject(self,
@selector(associatedObject));
}

对于不是自己创建的类而言,使用这个方法进行扩展无疑是非常好用的。

接下来我们要介绍的,便是判别这个类能执行何种操作。这就是所谓的「内省 (introspection)」机制。通常,我们所使用的往往是最基础的内省功能。

[myObject isMemberOfClass:NSObject.class];

[myObject respondsToSelector:@selector(doStuff:)];

// isa == class

class_respondsToSelector(myObject.class, @selector(doStuff:));

首先是这个 isMemberOfClass,这是 Foundation 当中的一部分,这里我们查看 myObject 是否是 NSObject 的子类。接下来是这个 respondsToSelector:,当我们使用了一个带有可选方法的协议时,为了避免崩溃发生,可以借助这个函数来判断这个对象是否可以调用此可选方法。在运行时层面,isMemberOfClass 对比两者的 isa 是否相同。respondsToSelector" 则封装了一个 Objective-C 运行时函数:respondsToSelector,其接受 Selector 和类为参数。

如果您写过单元测试的话,您就会知道当我们在编写 XCTestCase 的时候,需要完成 setUptearDown 的设定,随后才能编写相关的 test 函数。当测试运行的时候,系统会自行遍历所有的测试函数,并自动运行。这个功能是借助 Objective-C 的运行时机制实现的。

unsigned int count;
Method *methods = class_copyMethodList(myObject.class,
&count);
//Ivar *list = class_copyIvarList(myObject.class,
&count);

for(unsigned i = 0; i < count; i++) {
		SEL selector = method_getName(methods[i]);
		NSString *selectorString =
NSStringFromSelector(selector);
	if ([selectorString containsString:@"test"]) {
		[myObject performSelector:selector];
	}
}
free(methods);

我们可以复制方法列表,如果需要的话,还可以复制变量列表。可以获取方法名,然后将其转换为字符串,检查其是否包含有 “test”,如果有便可以运行。现在我们便搭建好了 XCTest 的最简单版本!

那么变量和方法是由什么组成的呢?

struct objc_ivar {
	char *ivar_name;
	char *ivar_type;
	int ivar_offset;
}

struct objc_method {
	SEL method_name;
	char *method_types;
	IMP method_imp;
}

变量的组成与我们实际在代码当中所定义差别不大。其中包含了变量类型和变量名称。偏移量 (offset) 则是内存管理方面的内容。

Objective-C 方法的名称则是通过 Selector 来表示的,这也就是我们在 performSelector 当中所匹配的内容。同样,方法还用编码字符串来表示其类型。之后便是方法的实现,它使用了一种特定的表示方式,对此我们不必去深究。

因此,方法是非常简单的,我们同样可以在运行时向对象当中添加方法。

Method doStuff = class_getInstanceMethod(self.class, @selector(doStuff));

IMP doStuffImplementation = method_getImplementation(doStuff);

const char *types = method_getTypeEncoding(doStuff); //“v@:@"

class_addMethod(myClass.class, @selector(doStuff:), doStuffImplementation, types);

实现这个功能,我们需要用到 class_addMethod 这个函数。它所需的参数全都是我们之前所说的,方法结构体当中的那三个值:Selector、方法实现和方法类型。具体的方法实现部分我们取了个巧,因为我们使用了既有的 doStuff 方法,因此能够很简单地获取其方法实现和方法类型,不过我们还可以用其他方法来完成。

当然,我们添加了方法目的就是要使用它们。我们可以使用 [self doStuff] 或者 [self performSelector:@selector(doStuff)] 来进行调用,实际上在运行时级别,它们都是借助 objc_msgSend 向对象发送了一个消息。

[self doStuff];
[self performSelector:@selector(doStuff)];

objc_msgSend(self, @selector(message));

但是如果调用方法所在的对象为 nil 的时候,我们就会得到一个异常,应用便会崩溃。但事实证明,在崩溃之前会预留几个步骤,从而允许我们对某个不存在的函数进行一些操作。

方法转发 (9:24)

我们可以将方法转发 (forward) 给其余目标。当我们试图桥接两个不同的框架的时候,这个功能便非常有用。当我们调用某个未实现的方法时,这便是会发生的操作。

// 1
+(BOOL)resolveInstanceMethod:(SEL)sel{
	// 添加实例方法并返回 YES 的一次机会,它随后会再次尝试发送消息
}

// 2
- (id)forwardingTargetForSelector:(SEL)aSelector{
	// 返回可以处理 Selector 的对象
}

// 3
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector{
	// 您需要实现它来创建 NSInvocation
}

- (void)forwardInvocation:(NSInvocation *)invocation {
	// 在您所选择的目标上调用 Selector
	[invocation invokeWithTarget:target];
}

当您调用了某个不存在的方法时,运行时首先会调用一个名为 resolveInstanceMethod 的类方法,如果所调用的方法是类方法的话,则为调用 resolveClassMethod。这时候我们便有机会来添加方法了,步骤的话我们之前就已经展示过了。如果我们返回了 YES,就意味着原始方法将会再次被调用。

如果您不想创建新方法的话,我们还有 forwardingTargetForSelector。您可以直接返回需要调用方法的目标对象即可,之后这个对象就会调用 Selector。

此外还有一个略为复杂的 forwardInvocation。所有的调用过程都被封装到 NSInvocation 对象当中,之后您便可以使用特定的对象进行调用了。如果您需要这么做,那么还需要实现 methodSignatureForSelector

因此,我们便可以将方法转发给其他对象,但是您也可以替换或者交换方法的实现。您可以使用运行时当中最著名的动态特性:方法混淆 (swizzling)。混淆的基本方法如下所示:

+ (void)load {
	static dispatch_once_t onceToken;
	dispatch_once(&onceToken, ^{
		Class class = [self class];

		SEL originalSelector = @selector(doSomething);
		SEL swizzledSelector = @selector(mo_doSomething);

		Method originalMethod = class_getInstanceMethod(class,
originalSelector);
		Method swizzledMethod = class_getInstanceMethod(class,
swizzledSelector);

		BOOL didAddMethod = class_addMethod(class, originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));

		if (didAddMethod) {
			class_replaceMethod(class,
								swizzledSelector,
								method_getImplementation(originalMethod),
								method_getTypeEncoding(originalMethod));
		} else {
			method_exchangeImplementations(originalMethod, swizzledMethod);
		}
	});
}

当类加载之后,会调用一个名为 load 的类函数。由于我们只打算混淆一次,因此我们需要使用 dispatch_once。接着我们便可以得到该方法,然后使用 class_replaceMethod 或者 method_exchangeImplementations 来替换方法。之所以想要混淆,是因为它可以用于日志记录和 Mock 测试。

Foundation (11:15)

从运行时的层面,我们往上一层,便来到了 Foundation 框架。Foundation 框架实现了基于运行时的一个特性:键值编码 (key-value-coding, KVC) 以及键值观察 (key-value observing, KVO)。KVC 和 KVO 允许我们将 UI 和数据进行绑定。这也是 Rx 以及其他响应式框架实现的基础,这个基本的功能是内含在 Foundation 当中的。KVC 的工作方式如下所示:

@property (nonatomic, strong) NSNumber *number;

[myClass valueForKey:@"number"];
[myClass setValue:@(4) forKey:@"number"];

例如,假设我们有这个 number 属性,您可以将属性名称作为键,来获取属性值或者设置属性值。这个功能可以用在此前我们所看到的获取变量列表、协议列表,以及危险的混淆功能当中。

接下来是 KVO,您可以对状态的变化进行注册。

[myClass addObserver:self
    forKeyPath:@"number"
    options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew
    context:nil];

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary<NSKeyValueChangeKey,id> *)change
                       context:(void *)context{
	// Respond to observation.
}

在观察的值发生变更之后,KVO 会调用此方法立即通知观察者。通过这个方法,我们便可以按需更新 UI。

我们通常所说的 Objective-C 「动态性」,往往都是指 KVO。虽然还有其余的函数,但是这些是最常见、最常用的。这也就是人们所说的,Swift 缺失的部分。

话说回来,所有的这些操作都存有隐患。比方说 KVO,特别是当我们对某个不是自己所创建的类进行观察时,往往会发现有出乎意料的变化发生。通常而言,这些问题是非常难以调试的,也很难去理解为什么出错。在实际产品当中,我并不建议使用它们,尽管它们非常好用。但是在实际产品当中,我会很谨慎地去使用这些功能。

Apple 也是如此认为的,因此它们在视图控制器当中添加了这个私有方法,可以使用 class-dump 来查看。

+ (void)                   attentionClassDumpUser:
                                    yesItsUsAgain:
althoughSwizzlingAndOverridingPrivateMethodsIsFun:
          itWasntMuchFunWhenYourAppStoppedWorking:
 pleaseRefrainFromDoingSoInTheFutureOkayThanksBye:

的确,很让人抓狂。

Swift (13:29)

现在让我们来谈谈 Swift 吧。Swift 是一种强类型语言。类型静态,也就是说 Swift 的默认类型是非常安全的。如果需要的话,不安全类型也是存在的,但是 Swift 仍然是尽力推动我们使用安全的静态类型。Swift 中的动态性可以通过 Objective-C 运行时来获得。

本来这是很好的,但是 Swift 开源并迁移到 Linux 之后,由于 Linux 上的 Swift 并不提供 Objective-C 运行时,事情就大条了。社区的关键点在于,让 Swift 未来能够自己配备动态性,而不是依赖于 Apple。

也就是说,Swift 当中存在有这两个修饰符 @objc@dynamic,此外我们同样还可以访问 NSObject@objc 将您的 Swift API 暴露给 Objective-C 运行时,但是它仍然不能保证编译器会尝试对其进行优化。如果您真的想使用动态功能的话,就需要使用 @dynamic。一旦您使用了 @dynamic 修饰符之后,就不需要添加 @objc 了,因为它已经隐含在其中。

回到我们的动态特性当中,让我们来看一看 Swift 当中这些动态特性是什么样的。假设我们需要使用内省机制、转发方法、替换和绑定方法。方法的转发实际上变化不大:

// 1
override class func resolveInstanceMethod(_ sel: Selector!)
-> Bool {
	// 添加实例方法并返回 true 的一次机会,它随后会再次尝试发送消息
}

// 2
override func forwardingTarget(for aSelector: Selector!) ->
Any? {
	// 返回可以处理 Selector 的对象
}

// 3 - Swift 不支持 NSInvocation

resolveInstanceMethod 同样会被调用,forwardingTarget 看起来似乎更贴近于 Swift 3 风格的 API。但是 NSInvocation 并不能在 Swift 当中使用。我们同样可以实现方法转发,因此看起来也不算太坏。

方法混淆变得有些困难。load 在 Swift 不再会被调用,因此我们需要在 initialize 中进行混淆。在 Objective-C 当中,我们使用 dispatch_once,但是自 Swift 3 之后,dispatch_once 便不复存在于 Swift 当中了。事情变得略为复杂。虽然对于特定类型的函数而言,我们仍然可以将其定义为动态函数,但是它会消除大部分混淆的功能。

对于内省而言,我们有了一些新的东西。

if self is MyClass {
	// YAY
}

let myString = "myString";
let mirror = Mirror(reflecting: myString)
print(mirror.subjectType) // “String"
let string = String(reflecting: type(of:
myString)) // Swift.String

// No native method introspection

is 替代了 isMemberOfClass,它同样也可以对 Swift 值类型使用。我们可以对结构体、枚举以及其他 Swift 当中的新类型使用 is。此外还有一个新的映射 API,它主要针对于管道 (pipe) 和数据。

目前,我们没有原生的办法来实现内省。这也预示着这个功能未来可能会出现,但是目前我们还无法实现。这很令人沮丧,特别是当您想到我们此前所实现的 XCTestCase。如果您打算为 Linux 编写单元测试的时候,就无法自动遍历所有的函数。您必须实现 static var allTests,然后手动列出所有的测试函数。这很糟糕。

那么 KVO 和 KVC 呢?KVO 的魅力在于,您可以在不是自己所创建的类当中使用它,也可以只对您想要监听变化的类使用。KVO 和 KVC 在 Swift 被极大地削弱了。您所观察的对象必须要继承自 NSObject,并且使用一个 Objective-C 类型。您所观察的变量必须要生命为 dynamic。您必须要对想要观察的事务了如指掌。

问题是 Swift 并没有很好的替代方案。您可以使用 Rx 或者基于协议来观察对象。但是语言自身是没有原生的解决方案的。

Swift 是一个让人兴奋的语言,此外也有一个好消息。最近,在 Swift 邮件列表中,Chris Lattner 认为为 Swift 添加动态功能是非常重要的。他还说,即便人们不同意「动态性」的功能是什么,我们的关键在于要找一个原生的、流畅的、符合 Swift 风格的方法来解决这些问题。

总结 (18:11)

如今我们所知道的是,更丰富的动态性被安排在了 Swift 4 的第二阶段。我们目前正处于 Swift 4 的第一阶段,他们的重点是保证 API 的稳定性。他们会在 iOS 11 之前尽量完成,这是核心团队的重点之一,他们可能会首先考虑引入内省开始。

还有一件事,也就是我所启动的一个开源项目。目前我正在开发一个名为 ObjectiveKit 的开源库。我的想法是用一些符合 Swift 风格的方法来暴露一些运行时函数,我觉得这将是一件很有趣的事。

总而言之,Objective-C 的动态性无疑是非常强大的、极其有用,虽然也存在危险性。Swift 目前没有足够的替代方案来解决这些问题,但是可以预见在不久的将来 Swift 的动态性将会出现,这是值得我们期待的。我一开始所承诺的猫咪 GIF 图在这里,我觉得以它作为结尾是很好的想法。感谢大家!

About the content

This talk was delivered live in October 2016 at Mobilization. The video was transcribed by Realm and is published here with the permission of the conference organizers.

Roy Marmelstein

Creator of PhoneNumberKit, Interpolate and Localize.

4 design patterns for a RESTless mobile integration »

close