写在前面
还记得初学 Objective-C 时,书中介绍 Objective-C 是面向对象语言,是 C 语言的超集,是一门动态语言。当时不知道“动态”所谓何意,直到理解了 Runtime 这一概念才明白动态的意思。本文试图对 Runtime 相关概念进行梳理并介绍其应用,来真正发现 Objective-C 这门语言的魅力。
Objective-C 的简介及其工作原理(Runtime)
C 语言是面向过程语言,Objective-C 是面向对象语言。前者以方法为核心,后者是以数据(对象)为核心。“对象”(object)就是基本构造单元,我们通过对象来存储和传递数据。对象之间专递数据并执行任务的过程就叫做“消息传递”(Messaging)。所以想要真正了解 Objective-C 这门语言,就要理解“对象”(object)和“消息传递”(Messaging)这两个概念。稍后会重点介绍这两个概念。
相对于其他面向对象语言,Objective-C 的动态性是如何实现的呢?先用 C++ 举例,C++ 语言是硬编码,编译后就知道的哪些对象调用哪些方法。而 Objective-C 对象类型并非在编译器就绑定好了,而是要在运行期查找。这里说的运行时就是 runtime,它是一套用 C 和汇编实现的底层库,是一套 API <objc/runtime.h>,它提供了一系列方法使得 Objective-C 具有动态性,方便程序员们可以利用这些方法实现强大的操作。常见的比如:关联对象(Associated Object)、方法调配(Method Swizzling)和消息转发机制,这些技术稍后也会做出详细介绍并给出 Demo。
研究方法
Tip:学习 runtime 有两条途径,一是分析 runtime 源码,二是分析 NSObject 类对象。第一种是底层到应用层自底向上的研究方法,第二种反之。这里借助一条命令,将 Objective-C 源代码变换为 C++ 的源代码。
clang -rewrite-objc 源代码文件名
理解“类对象”
Objective-C 中几乎所有的对象都继承自 NSObject,Objective-C 对象的本质是指向某块内存数据的指针,其包含了一个Class
结构体类型的isa
成员变量。
@interface NSObject <NSObject> {
Class isa;
}
点开Class
,typedef struct objc_class *Class
中可以看到,Class
是一个objc_class
类型的结构体。看一下 objc_class
的结构:
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;
}
1.isa
元类(metaclass);
2.super_class
父类;
3.name
类名;
4.verson
类的版本信息,默认为0,可以通过runtime函数class_setVersion或者class_getVersion进行修改、读取;
5.info
类信息,供运行时期使用的一些位标识,如CLS_CLASS (0x1L) 表示该类为普通 class,其中包含实例方法和变量;CLS_META (0x2L) 表示该类为 metaclass,其中包含类方法;
6.instance_size
该类的实例变量大小(包括从父类继承下来的实例变量);
7.ivars
该类的成员变量地址列表;
8.methodLists
方法地址列表,与 info 的一些标志位有关,如CLS_CLASS (0x1L),则存储实例方法,如CLS_META (0x2L),则存储类方法;
9.cache
缓存最近使用的方法地址,用于提升效率;
10.protocols
存储该类声明遵守的协议的列表。
此结构体存放类的“元数据” (metadata),例如类的的实例实现了几个方法,具有多少个实例变量等信息。此结构体的首个变量也是 isa
指针,这说明 Class
本身也是 Objective-C 对象。结构体里还有一个变量叫做 super_class
,它定义了本类的超类。类对象所属的类型(也就是 isa
指针所指向的类型)是另外一个类,叫做“元类” (metaclass), 用来表述类对象本身所具备的元数据。“类方法”就定义于此处,因为这些方法可以理解成类对象的实例方法。每个类仅有一个“类对象”,而每个“类对象”仅有一个与之相关的“元类”。
super_class
指针确立了继承关系,而 isa
指针描述了实例所属的类。通过这张布局关系图即可执行“类型信息查询”。我们可以查出对象是否能响应某个方法,是否遵从某项协议,并且能看出此对象位于“类继承体系”(class hierarchy)的哪一部分。
理解消息传递 objc_msgSend 方法
创建工程添加一个 Person
类,在 main
中初始化并向 Person
类的实例发送消息,如下:
int main(int argc, const char * argv[]) {
@autoreleasepool {
Person *xiaoming = [Person new];
[xiaoming sayHello];
}
return 0;
}
下面用之前提到的 clang -rewrite-objc main.m
命令对 main.m 文件进行转换,运行命令结束生成了一个 main.cpp 文件。打开该文件,定位到关键代码,上面源代码转换后如下:
int main(int argc, const char * argv[]) {
/* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool;
Person *xiaoming = ((Person *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("Person"), sel_registerName("new"));
((void (*)(id, SEL))(void *)objc_msgSend)((id)xiaoming, sel_registerName("sayHello"));
}
return 0;
}
在对象上调用方法是 Objective-C 中最常用的功能,用 Objective-C 的术语来说,这叫做“传递消息”(pass a message)。消息有“名称”(name)或“选择子”(selector),可以接受参数,可能还有返回值。上文说了 Objective-C 是一门动态语言,因为向某个对象传递消息,会用到“动态绑定”(dynamic binding)机制来决定需要调用的方法。在底层,所有方法都是普通的 C 语言函数,然而对象收到消息之后,究竟该调用哪个方法完全于运行期决定,甚至可以在程序运行时改变。下面来看这句代码:
[xiaoming sayHello];
在本例中,xiaoming 叫做“接收者”(receiver),sayHello 叫做“选择子”(selector)。选择子与参数合起来称为“消息”(message)。编译器看到此消息后,将其转换为一条标准的 C 语言调用,所用函数是消息传递机制中的核心函数,叫做 objc_msgSend,其原型如下:
void objc_msgSend(id target, SEL cmd, ...)
这是个“可变参数函数”,能接受两个或两个以上参数。第一个参数代表接收者,第二个参数代表选择子,后续参数即消息中的那些参数,顺序不变。因此,编译器会把上个例子转换如下函数:
objc_msgSend(xiaoming, @selector(sayHello));
objc_msgSend 函数会根据接收者和选择子调用适当的方法。该函数会在接收者所属类中查询其“方法列表”(list of methods),如果能找到与选择子相符的方法,就跳转至其实现代码。若是找不到,就沿着继承体系向上查找,找到合适的方法之后跳转。如果最终还是找不到相符的方法,那就执行“消息转发”(message forwarding)操作。如下图:
具体步骤说明:
- 检测当前
selector
是不是要忽略的。比如 Mac OS X 开发,有了垃圾回收就不理会retain
,release
这些函数了。 - 检测当前
target
是不是nil
对象。ObjC 的特性是允许对一个nil
对象执行任何一个方法不会 Crash,因为会被忽略掉。 - 如果上面两个都过了,那就开始查找这个类的
IMP
,先从cache
里面找,完了找得到就跳到对应的函数去执行。 - 如果
cache
找不到就找一下方法分发表。 - 如果分发表找不到就到超类的分发表去找,一直找,直到找到 NSObject 类为止。
- 如果还找不到就要开始执行消息转发了,后面会提到。
补充:前面讲的是部分调用过程,还有其他“边界情况”(edge case)则需要 runtime 中其他函数来处理:
objc_msgSend_stret
如果待发送的消息要返回结构体,可交由此函数处理。
objc_msgSend_fpret
如果消息返回浮点数,可交由此函数处理。
objc_msgSendSuper
如果要给超类发送消息,可交由此函数处理。
消息转发
利用上述工程,为 Person
类添加一个 coding
方法,但是不实现该方法。运行程序,结果程序报错,控制台提示错误原因: reason: '-[Person coding]: unrecognized selector sent to instance 0x100448ad0'
。
若想令类能理解某条消息,我们必须实现对应方法。但是,在编译期向类发送了其无法理解的消息并不会报错,因为运行期可以继续向类中添加方法,所以编译器在编译时还无法确定类中到底会不会有某个方法实现。当对象接收到无法理解的消息后,就会启动“消息转发”(message forwarding)机制。
其实我们已经见过消息转发流程所处理的消息了。例如向一个 NSNumber
对象发送 NSString
对象方法,控制台中输出的如下信息:
-[_NSCFNumber lowercaseString]: unrecognized selector sent to instance 0x1005002d0
上面这段异常信息是由 NSObject
的“doesNotRecognizeSelector:
”方法所抛出的,此异常表明:消息接收者的类型是 Person
,接收者无法理解的选择子名称为 coding
。
消息转发分为两个阶段。第一阶段先征询接受者所属的类,看其是否能动态添加方法,以处理当前这个“未知选择子”(unknown selector),这叫做“动态方法解析”(dynamic method resolution)。第二个阶段涉及“完整的转发机制”(full forwarding mechanism)。如果运行系统已经把第一个阶段执行完了,那么接收者自己就无法再以动态新增方法的手段来响应包含该选择子的消息了。此时,运行期系统会请求接收者一其他手段来处理与消息相关的方法调用。这又分为两步。首先,请接收者看看有没有其他对象能处理这条消息。若有,则运行期系统会把消息转给那个对象,于是消息转发过程结束,一切如常。若没有“备援的接收者”(replacement receiver),则启动完整的消息转发机制,运行期系统会把与消息有关的全部细节都封装到 NSInvocation 对象中,再给接收者最后一次机会,令其解决当前还未处理的这条消息。
- 第一步:通过
resolveInstanceMethod:
方法决定是否动态添加方法以处理当前这个“未知选择子”(unknown selector),这叫做“动态方法解析”(dynamic method resolution)。如果返回 Yes 则通过class_addMethod
动态添加方法,消息得到处理,结束;如果返回 No,则进入下一步; - 第二步:这步会进入
forwardingTargetForSelector:
方法,用于指定备选对象响应这个selector
,不能指定为self
。如果返回某个对象则会调用对象的方法,结束。如果返回nil,则进入第三步; - 第三步:这步我们要通过
methodSignatureForSelector:
方法签名,如果返回nil
,则消息无法处理。如果返回methodSignature
,则进入下一步; - 第四步:这步调用
forwardInvocation:
方法,我们可以通过anInvocation
对象做很多处理,比如修改实现方法,修改响应对象等,如果方法调用成功,则结束。如果失败,则进入doesNotRecognizeSelector
方法,若我们没有实现这个方法,那么就会crash。
关联对象(Associated Object)
当我们想对一个类进行拓展,可以通过继承,在子类中添加方法和属性。有开发经验的知道,从设计模式角度看,继承虽然提供了便利但不是一种友好的设计方式,因为它破坏了封装。设计模式里有一个原则,开闭原则:对扩展开放,对修改关闭。所以推荐使用组合少用继承。Objective-C 中还有另一个方法是在分类(category)中添加方法,但是分类中不能添加成员变量。如果想在分类中添加成员变量就用到了关联对象(Associated Object)技术。
关联对象用到下面三个方法:
void objc_setAssociatedObject(id object, void* key, id value, objc_AssociationPolicy policy)
此方法以给定的键和策略为某对象设置关联对象值。
id objc_getAssociatedObject(id object, void* key)
此方法根据给定的键从某对象中获取相应的关联对象值。
void objc_removeAssociatedObjects(id object)
此方法移除指定对象的全部关联对象。
举例:之前做一个项目为了实现某个需求,需要给 UIButton
添加一个 NSIndexPath
类型的属性,代码实现如下:
#import <UIKit/UIKit.h>
@interface UIButton (IndexPath)
@property (nonatomic, strong) NSIndexPath *indexPath;
@end
#import "UIButton+IndexPath.h"
#import <objc/runtime.h>
@implementation UIButton (IndexPath)
- (void)setIndexPath:(NSIndexPath *)indexPath
{
objc_setAssociatedObject(self, @selector(indexPath), indexPath, OBJC_ASSOCIATION_COPY);
}
- (NSIndexPath *)indexPath
{
return objc_getAssociatedObject(self, _cmd);
}
@end
注意:使用关联对象添加属性时,需要指明“存储策略”(storage policy),用以维护相应的“内存管理语义”。存储策略由 objc_AssociationPolicy
的枚举所定义。
typedef OBJC_ENUM(uintptr_t, objc_AssociationPolicy) {
OBJC_ASSOCIATION_ASSIGN = 0, /**< Specifies a weak reference to the associated object. */
OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1, /**< Specifies a strong reference to the associated object.
* The association is not made atomically. */
OBJC_ASSOCIATION_COPY_NONATOMIC = 3, /**< Specifies that the associated object is copied.
* The association is not made atomically. */
OBJC_ASSOCIATION_RETAIN = 01401, /**< Specifies a strong reference to the associated object.
* The association is made atomically. */
OBJC_ASSOCIATION_COPY = 01403 /**< Specifies that the associated object is copied.
* The association is made atomically. */
};
关联类型对应的属性内存管理语义:
关联类型 | 等效的 @property 属性 |
---|---|
OBJC_ASSOCIATION_ASSIGN | assign |
OBJC_ASSOCIATION_RETAIN_NONATOMIC | nonatomic,retain |
OBJC_ASSOCIATION_COPY_NONATOMIC | nonatomic,copy |
OBJC_ASSOCIATION_RETAIN | retain |
OBJC_ASSOCIATION_COPY | copy |
Method Swizzling
上文提过 Objective-C 对象收到消息后,究竟调用哪个方法需要在运行时才能解析出来。那么,与给定的选择子名称对应的方法是不是也可以在运行期改变呢?没错,就是这样。因此,我们既不需要源代码,也不需要继承子类重写方法就能改变这个类的功能。Method Swizzling 就是改变一个 selector
的实际实现的技术。通过这一技术,我们可以在运行时通过修改类的分发表中 selector
对应的函数,来修改方法的实现。
每个类都维护着一张选择子表(selector table),其实就是方法表。这张表包括一个或多个选择子 SEL
,且 SEL
和 IMP
是一一映射关系。
这里涉及两个概念:SEL
和 IMP
SEL
是 selector
在 Objective-C 中的表示类型。selector
可以理解为区别方法的ID。
typedef struct objc_selector *SEL;
objc_selector
的定义如下:
struct objc_selector {
char *name; OBJC2_UNAVAILABLE;// 名称
char *types; OBJC2_UNAVAILABLE;// 类型
}; // name和types都是char类型。
IMP
是“implementation”的缩写,它是由编译器生成的一个函数指针。当你发起一个消息后(下文介绍),这个函数指针决定了最终执行哪段代码。
typedef id (*IMP)(id, SEL, ...);
例如:NSString
类可以响应 lowercaseString
、 uppercaseString
、 capitalizedString
等选择子。这些选择子都映射到不同的 IMP
上。
接下来介绍如何实现交换两个方法的实现。通过此操作可以为已有方法添加新功能。在讲解添加新功能之前,先来介绍两个函数:
void method_exchangeImplementations(Method m1, Method m2)
此函数可以交换两个方法实现,而方法的实现可以通过下面这个函数获得:
Method class_getInstanceMethod(Class aClass, SEL aSelector)
此函数分局给定的选择从类中取出与之对应的方法。
例如,我们想跟踪在程序中每一个view controller 展示给用户的次数:当然,我们可以在每个 view controller 的 viewDidAppear
中添加跟踪代码;但是这太过麻烦,需要在每个view controller中写重复的代码。创建一个子类可能是一种实现方式,但需要同时创建UIViewController
, UITableViewController
, UINavigationController
及其它UIKit中view controller的子类,这同样会产生许多重复的代码。
这种情况下,我们就可以使用 Method Swizzling,如在代码所示:
#import <objc/runtime.h>
@implementation UIViewController (Tracking)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class class = [self class];
// When swizzling a class method, use the following:
// Class class = object_getClass((id)self);
SEL originalSelector = @selector(viewWillAppear:);
SEL swizzledSelector = @selector(xxx_viewWillAppear:);
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);
}
});
}
#pragma mark - Method Swizzling
- (void)xxx_viewWillAppear:(BOOL)animated {
[self xxx_viewWillAppear:animated];
NSLog(@"viewWillAppear: %@", self);
}
@end
在这里,我们通过Method Swizzling修改了UIViewController
的 @selector(viewWillAppear:)
对应的函数指针,使其实现指向了我们自定义的 xxx_viewWillAppear
的实现。这样,当 UIViewController
及其子类的对象调用 viewWillAppear
时,都会打印一条日志信息。
我们回过头来看看前面新的方法的实现代码:
- (void)xxx_viewWillAppear:(BOOL)animated {
[self xxx_viewWillAppear:animated];
NSLog(@"viewWillAppear: %@", NSStringFromClass([self class]));
}
咋看上去是会导致无限循环的。但令人惊奇的是,并没有出现这种情况。在 swizzling 的过程中,方法中的 [self xxx_viewWillAppear:animated]
已经被重新指定到 UIViewController
类的 -viewWillAppear:
中。在这种情况下,不会产生无限循环。不过如果我们调用的是 [self viewWillAppear:animated]
,则会产生无限循环,因为这个方法的实现在运行时已经被重新指定为 xxx_viewWillAppear:
了。
上面的例子很好地展示了使用 Method Swizzling 来一个类中注入一些我们新的操作。当然,还有许多场景可以使用Method Swizzling
,在此不多举例。在此我们说说使用 Method Swizzling 需要注意的一些问题:
Swizzling 应该总是在 +load
中执行
在 Objective-C 中,运行时会自动调用每个类的两个方法。+load
会在类初始加载时调用,+initialize
会在第一次调用类的类方法或实例方法之前被调用。这两个方法是可选的,且只有在实现了它们时才会被调用。由于Method Swizzling 会影响到类的全局状态,因此要尽量避免在并发处理中出现竞争的情况。+load
能保证在类的初始化过程中被加载,并保证这种改变应用级别的行为的一致性。相比之下,+initialize
在其执行时不提供这种保证—事实上,如果在应用中没为给这个类发送消息,则它可能永远不会被调用。
Swizzling 应该总是在 dispatch_once
中执行
与上面相同,因为 swizzling 会改变全局状态,所以我们需要在运行时采取一些预防措施。原子性就是这样一种措施,它确保代码只被执行一次,不管有多少个线程。GCD 的 dispatch_once
可以确保这种行为,我们应该将其作为 Method Swizzling 的最佳实践。
Method Swizzling 注意事项总结
- 方法交换应该保证唯一性和原子性
唯一性:应该尽可能在+load
方法中实现,这样可以保证方法一定会调用且不会出现异常。
原子性:使用dispatch_once
来执行方法交换,这样可以保证只运行一次。 - 一定要调用原始实现
由于 iOS 的内部实现对我们来说是不可见的,使用方法交换可能会导致其代码结构改变,而对系统产生其他影响,因此应该调用原始实现来保证内部操作的正常运行。 - 方法名必须不能产生冲突
这个是常识,避免跟其他库产生冲突。 - 做好记录
记录好被影响过的方法,不然时间长了或者其他人 debug 代码时候可能会对一些输出信息感到困惑。 - 如果非迫不得已,尽量少用方法交换
虽然方法交换可以让我们高效地解决问题,但是如果处理不好,可能会导致一些莫名其妙的 bug。