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

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

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

typedefstructobjc_class*Class;structobjc_object{Classisa;};

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

structobjc_class{Classisa;Classsuper_class;constchar*name;longversion;longinfo;longinstance_size;structobjc_ivar_list*ivars;structobjc_method_list**methodLists;structobjc_cache*cache;structobjc_protocol_list*protocols;};

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

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

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

ClassmyClass=objc_allocateClassPair([NSObjectclass],"MyClass",0);// 在这里添加变量、方法和协议objc_registerClassPair(myClass);// 当类注册之后,变量列表将会被锁定[[myClassalloc]init];

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

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

类别(5:34)

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

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

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

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

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

[myObjectisMemberOfClass:NSObject.class];[myObjectrespondsToSelector:@selector(doStuff:)];// isa == classclass_respondsToSelector(myObject.class,@selector(doStuff:));

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

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

unsignedintcount;Method*methods=class_copyMethodList(myObject.class,&count);//Ivar *list = class_copyIvarList(myObject.class,&count);for(unsignedi=0;i

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

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

structobjc_ivar{char*ivar_name;char*ivar_type;intivar_offset;}structobjc_method{SELmethod_name;char*method_types;IMPmethod_imp;}

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

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

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

MethoddoStuff=class_getInstanceMethod(self.class,@selector(doStuff));IMPdoStuffImplementation=method_getImplementation(doStuff);constchar*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向对象发送了一个消息。

[selfdoStuff];[selfperformSelector:@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[invocationinvokeWithTarget:target];}

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

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

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

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

+(void)load{staticdispatch_once_tonceToken;dispatch_once(&onceToken,^{Classclass=[selfclass];SELoriginalSelector=@selector(doSomething);SELswizzledSelector=@selector(mo_doSomething);MethodoriginalMethod=class_getInstanceMethod(class,originalSelector);MethodswizzledMethod=class_getInstanceMethod(class,swizzledSelector);BOOLdidAddMethod=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;[myClassvalueForKey:@"number"];[myClasssetValue:@(4)forKey:@"number"];

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

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

[myClassaddObserver:selfforKeyPath:@"number"options:NSKeyValueObservingOptionInitial|NSKeyValueObservingOptionNewcontext:nil];-(void)observeValueForKeyPath:(NSString*)keyPathofObject:(id)objectchange:(NSDictionary*)changecontext:(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 当中这些动态特性是什么样的。假设我们需要使用内省机制、转发方法、替换和绑定方法。方法的转发实际上变化不大:

// 1overrideclassfuncresolveInstanceMethod(_sel:Selector!)->Bool{// 添加实例方法并返回 true 的一次机会,它随后会再次尝试发送消息}// 2overridefuncforwardingTarget(foraSelector: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 当中了。事情变得略为复杂。虽然对于特定类型的函数而言,我们仍然可以将其定义为动态函数,但是它会消除大部分混淆的功能。

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

ifselfisMyClass{// YAY}letmyString="myString";letmirror=Mirror(reflecting:myString)print(mirror.subjectType)// “String"letstring=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 图在这里,我觉得以它作为结尾是很好的想法。感谢大家!

Roy Marmelstein

Creator of PhoneNumberKit, Interpolate and Localize.

Twitter

Edited by Billy Leet

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

推荐阅读更多精彩内容