说起 Objective-C runtime 在实际项目中的应用,可能很多人第一时间联想到的是黑魔法 method swizzling 、 associated objects 、 KVC / KVO 以及各种灵活的 runtime api 。这几种技术在开发过程中或多或少都会涉及到 ,也的确为开发者立下了汗马功劳,尤其在解决一些棘手问题时,屡试不爽。不过同样是 runtime 重要组成部分的消息转发却较少听人提及,这篇文章就来扒一扒它在不同应用场景中的精彩表现。
简说消息转发
在开始之前,先简单温习下消息转发是怎么一回事。
举一个不恰当的例子:1
2id o = [NSObject new];
[o lastObject];
执行上面代码,程序会崩溃并抛出以下异常:
1 | [NSObject lastObject]: unrecognized selector sent to instance 0x100200160 |
错误显而易见,实例对象 o
无法响应 lastObject
方法。 那么问题来了, Objetive-C 作为一门动态语言,更有强大的 runtime 大佬在背后撑腰,它会让程序没有任何预警地直接狗带么?当然不会,Object-C 的 runtime 不但提供了挽救机制,而且还是三部曲:
1、Lazy method resolution
2、Fast forwarding path
3、Normal forwarding path
上述程序崩溃的根本原因在于没有找到方法的实现,也就是通常所说的 IMP 不存在。结合以下源码,可以知道消息转发三部曲是由 _objc_msgForward 函数发起的。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16IMP class_getMethodImplementation(Class cls, SEL sel)
{
IMP imp;
if (!cls || !sel) return nil;
imp = lookUpImpOrNil(cls, sel, nil,
YES/*initialize*/, YES/*cache*/, YES/*resolver*/);
// Translate forwarding function to C-callable external version
if (!imp) {
return _objc_msgForward;
}
return imp;
}
Lazy method resolution
在这一步, _objc_msgForward
直接或间接调用了以下方法:
1 | /// 针对类方法 |
由于形参中传入了无法找到对应 IMP 的 SEL ,我们就可以在这个方法中动态添加 SEL 的实现,并返回 YES 重新启动一次消息发送动作。如果方法返回 NO ,那么就进行消息转发的下个流程 Fast forwarding path 。
这种方式能够方便地实现 @dynamic
属性, CoreData 中模型定义中就广泛使用到了 @dynamic
属性。
Fast forwarding path
在这一步, _objc_msgForward
直接或间接调用了以下方法:
1 | - (id)forwardingTargetForSelector:(SEL)aSelector; |
这个方法还是只附带了无法找到对应 IMP 的 SEL,我们可以根据这个 SEL ,判断是否有其它对象可以响应它,然后选择将消息转发给这个对象。如果返回除 nil / self 之外的对象,那么会重启一次消息发送动作给返回的对象,否则进入下个流程 Normal forwarding path。
Normal forwarding path
在这一步, _objc_msgForward
直接或间接调用了以下方法:
1 | - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector; |
这个消息转发的最后一步,首先会调用的是 -methodSignatureForSelector:
方法,这个方法返回一个方法签名,用以构造 NSInvocation 并作为实参传入 -forwardInvocation:
方法中。如果 -methodSignatureForSelector:
返回 nil ,将会抛出 unrecognized selector 异常。
由于在 -forwardInvocation:
方法中可以获取到 NSInvocation ,而 NSInvocation 包含了参数、发送目标以及 SEL 等信息,尤其是参数信息,所以这一步也是可操作性最强的一步。我们可以选择直接执行传入的 NSInvocation 对象,也可以通过 -invokeWithTarget:
指定新的发送目标。
一般来说,既然走到这一步,这个对象都是没有 SEL 对应的 IMP 的,所以通常来说都必须要重写 -methodSignatureForSelector:
方法以返回有效的方法签名,否则就会抛出异常。不过有种例外,当对象实现了相应的方法,但还是走到了 Normal forwarding path 这一步时,就可以不重写 -methodSignatureForSelector:
方法。
理解这种操作需要知晓 method swizzling 技术中的一个知识点,替换 IMP 是不会影响到 SEL 和 参数信息的。所以当把某个方法的实现替换成 _objc_msgForward
/ _objc_msgForward_stret
以启动消息转发时,即使不重写 -methodSignatureForSelector:
,这个方法依旧能返回有效的方法签名信息。举个例子:
1 | NSArray *arr = [NSArray new]; |
上面程序输出如下:
1 | old type: @24@0:8Q16, imp: 0x7fffb5fc31e0 |
可以看到,更改的只有方法实现 IMP 。并且从源码层面看,method swizzling 在方法已存在的情况下,只是设置了对应的 Method 的 IMP,当方法不存在时,才会设置额外的一些属性:
1 | IMP |
消息转发流程大体如此,如果想了解具体的转发原理、_objc_msgForward
内部是如何实现的,可以阅读玉令天下写的 Objective-C 消息发送与转发机制原理,文章会以反汇编地角度剖析消息转发的实现,能捋清不少疑惑。
聊完消息转发的基本流程,再来说说它的一些应用场景。
Week Proxy
NSTimer、CADisplayLink 是实际项目中常用的计时器类,它们都使用 target - action 机制设置目标对象以及回调方法。相信很多人都遇到过 NSTimer 或者 CADisplayLink 对象造成的循环引用问题。实际上,这两个对象是强引用 target 的,如果使用者管理不当,轻则造成 target 对象的延迟释放,重则导致与 target 对象的循环引用。
假如有个 UIViewController 引用了一个 repeat 的 NSTimer 对象 (先不论强弱引用) ,正确的管理方式是在控制器退出回调中手动 invalidate 并释放对 NSTimer 对象的引用 :
1 | - (void)popViewController { |
不过正所谓“人有失手,马有失蹄”,这种分散的管理方式,总会让使用者在某些场景下忘记了停止 _timer
,特别是使用者希望在 UIViewController 对象的 dealloc
方法中停止定时器时,很容易掉进这个坑里。有没有更加优雅的管理机制呢?下面就来看看 FLAnimatedImage 是如何管理 CADisplayLink 对象的。
FLAnimatedImage 创建了以下弱引用代理:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53@interface FLWeakProxy : NSProxy
+ (instancetype)weakProxyForObject:(id)targetObject;
@end
@interface FLWeakProxy ()
@property (nonatomic, weak) id target;
@end
@implementation FLWeakProxy
#pragma mark Life Cycle
// This is the designated creation method of an `FLWeakProxy` and
// as a subclass of `NSProxy` it doesn't respond to or need `-init`.
+ (instancetype)weakProxyForObject:(id)targetObject
{
FLWeakProxy *weakProxy = [FLWeakProxy alloc];
weakProxy.target = targetObject;
return weakProxy;
}
#pragma mark Forwarding Messages
- (id)forwardingTargetForSelector:(SEL)selector
{
// Keep it lightweight: access the ivar directly
return _target;
}
#pragma mark - NSWeakProxy Method Overrides
#pragma mark Handling Unimplemented Methods
- (void)forwardInvocation:(NSInvocation *)invocation
{
// Fallback for when target is nil. Don't do anything, just return 0/NULL/nil.
// The method signature we've received to get here is just a dummy to keep `doesNotRecognizeSelector:` from firing.
// We can't really handle struct return types here because we don't know the length.
void *nullPointer = NULL;
[invocation setReturnValue:&nullPointer];
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector
{
// We only get here if `forwardingTargetForSelector:` returns nil.
// In that case, our weak target has been reclaimed. Return a dummy method signature to keep `doesNotRecognizeSelector:` from firing.
// We'll emulate the Obj-c messaging nil behavior by setting the return value to nil in `forwardInvocation:`, but we'll assume that the return value is `sizeof(void *)`.
// Other libraries handle this situation by making use of a global method signature cache, but that seems heavier than necessary and has issues as well.
// See https://www.mikeash.com/pyblog/friday-qa-2010-02-26-futures.html and https://github.com/steipete/PSTDelegateProxy/issues/1 for examples of using a method signature cache.
return [NSObject instanceMethodSignatureForSelector:@selector(init)];
}
@end
通过上面代码,可以看出 FLWeakProxy 是弱引用 target 的,而且它在消息转发的第二步,将所有的消息都转发给了 target 对象。以下是调用方使用此弱引用代理的代码:
1 | @interface FLAnimatedImageView () |
其对象间的引用关系可以用下图表示:
1 | ---> 强引用 ~~~> 弱引用 |
这样一来, displayLink
间接弱引用了 FLAnimatedImageView 对象,使得 FLAnimatedImageView 对象得以正常释放。而且由于 weakProxy
将消息全部转发给了 FLAnimatedImageView 对象,-displayDidRefresh:
也得以正确地回调。
事实上,以上问题也可以通过 block 回调的方式解决,具体实现就是让创建的定时器对象持有 NSTimer 类对象,并且在类回调方法中,执行经 userInfo 传过来的 block 回调。
此外,苹果私有库 MIME.framework 中就有这种机制的应用 —- MFWeakProxy ;YYKit 的 YYAnimatedImageView 也使用了相同的机制管理 CADisplayLink,其对应类为 YYWeakProxy 。
Delegate Proxy
Delegate Proxy 主要实现部分代理方法的转发,顾名思义,就是封装者使用了被封装对象代理的一部分方法,然后将剩余的方法通过新的代理转发给调用者。这种机制在二次封装第三方框架或者原生控件时,能减少不少胶水代码。
接下来,我会以 IGListKit 中的 IGListAdapterProxy 为例,描述如何利用这种机制来简化代码。在开始之前先了解下与 IGListAdapterProxy 直接相关的 IGListAdapter 。 IGListAdapter 是 UICollectionView 的数据源和代理实现者,以下是它与本主题相关联的两个属性:
1 | @interface IGListAdapter : NSObject |
使用者可以成为 IGListAdapter 的代理,获得和 UICollectionView 原生代理一致的编写体验。实际上, IGListAdapter 只是使用并实现了部分代理方法,那么它又是如何编写有关这两个属性的代码,让使用者实现的代理方法能正确地执行呢?可能有些人会这样写:
1 | #pragma mark - UICollectionViewDelegateFlowLayout |
当代理方法较少的时候,这种写法是可以接受的。不过随着代理方法的增多,编写这种胶水代码就有些烦人了,侵入性的修改方式也不符合开放闭合原则。我们来看下 IGListKit 是如何利用 IGListAdapterProxy 解决这个问题的:
1 | @interface IGListAdapterProxy : NSProxy |
这个类总共有三个自定义属性,分别是用来支持外界代理方法回调的 _collectionViewTarget
、 _scrollViewTarget
,以及用以支持 AOP 的拦截者 _interceptor
(IGListAdapter 在调用外界实现的代理方法前,插入了自己的实现,所以可视为拦截者)。 isInterceptedSelector
函数表明拦截者使用到了哪些代理方法,而 -respondsToSelector:
和 -forwardingTargetForSelector:
则根据这个函数的返回值决定是否能响应方法,以及应该把消息转发给拦截者还是外部代理。 事实上,外部代理就是本小节开头所说的使用者可以访问的属性:
1 | @implementation IGListAdapter |
通过这种转发机制,即使后续有新的代理方法,也不用手动添加胶水代码了。一些流行的开源库中也可以看到这种做法的身影,比如 AsyncDisplayKit 就有对应的 _ASCollectionViewProxy
来转发未实现的代理方法。
Multicast Delegate
通知和代理是解耦对象间消息传递的两种重要方式,其中通知主要针对一对多的单向通信,而代理则主要提供一对一的双向通信。
通常来说, IM 应用在底层模块接受到新消息后,都会进行一次广播处理,让各模块能根据新消息来更新状态。当接收模块不需要向发送模块反馈任何信息时,使用 NSNotificationCenter 就可以实现上述需求。但是一旦发送模块需要根据接收模块返回的信息做一些额外处理,也就是实现一对多的双向通信, NSNotificationCenter 就不满足要求了。
最直接的解决方案是,针对这个业务场景自定义一个消息转发中心,让遵守特定协议的外围模块主动注册成为消息接收者。不过既然涉及到了特定协议,就注定了这个消息转发中心缺少通用性。这时候就可以参考下业界现成的方案了,让我们来看看 XMPPFramework 是如何解决这个问题的。
从文档中可以看出,作者希望 XMPPFramework 具备以下几个特性 :
1、 将事件广播给多个监听者
2、 易于扩展
3、 选择的机制要支持返回值
4、 选择的机制要易于编写线程安全代码
但是代理或者通知机制都不能很好地满足上述需求,所以 GCDMulticastDelegate 类应运而生。 使用这个类时,广播类需要初始化 GCDMulticastDelegate 对象:
1 |
|
并且添加增删代理的方法:
1 | - (void)addDelegate:(id)delegate delegateQueue:(dispatch_queue_t)delegateQueue |
当广播对象需要向所有注册的代理发送消息时,可以用以下方式调用:
1 | [multicastDelegate worker:self didFinishSubTask:subtask inDuration:elapsed]; |
只要注册的代理实现了这个方法,就可以接收到发送的信息。
接下来看下 GCDMulticastDelegate 的实现原理 。首先, GCDMulticastDelegate 会在外界添加代理时,创建 GCDMulticastDelegateNode 对象封装传入的代理以及回调执行队列,然后保存在 delegateNodes
数组中。当外界向 GCDMulticastDelegate 对象发送无法响应的消息时,它会针对此消息启动转发机制,并在 Normal forwarding path
这一步转发给所有能响应此消息的注册代理。以下是消息转发相关的源码:
1 | - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector |
可以看到, -methodSignatureForSelector:
方法遍历了 delegateNodes
,并返回首个有效的方法签名。当没有找到有效的方法签名时,会返回 -doNothing
方法的签名,以规避未知方法导致的崩溃。在得到方法签名并构造 NSInvocation 对象后, -forwardInvocation:
同样遍历了 delegateNodes
,并在特定的任务队列中执行代理回调。如果发现已被销毁的代理,则删除它对应的 GCDMulticastDelegateNode 对象。
Record Message Call
NSUndoManager 是 Foundation 框架中,一个基于命令模式设计的撤消栈管理类。通过这个类可以很方便地实现撤消、重做功能,比如以下苹果官方 Demo :
1 | - (void)setMyObjectWidth:(CGFloat)newWidth height:(CGFloat)newHeight{ |
通过调用代码块中 NSUndoManager 对象的 undo
, 可以“撤销”以上方法对 myObject 相关属性的设置。其中需要关注的是, NSUndoManager 是如何记录目标对象接收发生改变的信息:
1 | [[undoManager prepareWithInvocationTarget:self] setMyObjectWidth:currentWidth height:currentHeight] |
NSUndoManager 是如何通过这种方式存储调用 -setMyObjectWidth:height:
这一动作呢?背后的关键在于 -prepareWithInvocationTarget:
所返回的对象,也就是 NSUndoManagerProxy 。 NSUndoManagerProxy 是 NSProxy 的子类,而 NSProxy 除了重载消息转发机制外,基本上就没有其他用法了。结合苹果官方文档, NSUndoManagerProxy 重载了 -forwardInvocation:
来帮助 NSUndoManager 获取目标的方法调用信息。到目前为止,这个应用场景并不难理解,不过为了能切合 NSUndoManagerProxy 的实际实现,这里还是结合 Foundation 框架反汇编出的代码,简单地实现这个功能。
首先,创建 TBVUndoProxy , 重写它的消息转发机制:
1 | @interface TBVUndoProxy : NSProxy |
结合 LLDB 中的调试信息, TBVUndoProxy 只是简单地把信息传送给了 TBVUndoManager 。再来看下将原生逻辑简化后的 TBVUndoManager 的实现:
1 | @interface TBVUndoManager : NSObject { |
TBVUndoManager 通过 -prepareWithInvocationTarget:
方法将发送消息对象保存为 _target
成员变量,然后创建了代理类 TBVUndoProxy 并返回给方法调用者。当外部调用者用这个返回值作为消息发送对象时, TBVUndoProxy 并没有对应的方法实现,于是就触发了消息转发机制, TBVUndoManager 则利用保存的 _target
返回有效的方法签名,并且保存重组了 TBVUndoProxy 回传的 NSInvocation。最终,当外界调用 undo
时,执行的就是保有 _target
和 -prepareWithInvocationTarget:
信息的 NSInvocation 。(原生代码将 NSInvocation 包装成 _NSUndoInvocation
、 _NSUndoObject
压入 _NSUndoStack
栈中)
Intercept Any Message Call
Aspects 是一个提供面向切片编程的库,它可以让开发者以无侵入的方式添加额外的功能。它提供了两个简单易用的入口,用于 hook 特定类或者特定对象的方法:
1 | /// Adds a block of code before/instead/after the current `selector` for a specific class. |
开发者可以用以下方式 hook 所有 UIViewController 实例对象的 -viewWillAppear:
方法 :
1 | [UIViewController aspect_hookSelector:@selector(viewWillAppear:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo, BOOL animated) { |
因为不知道使用者会 hook 什么方法,所以就无法像传统的 swizzling method 一样,预先编写对应的 IMP 去替换传入的方法。这时就需要内部实现一个统一调用机制,这个机制需要满足以下两点:
1、 为了能进行切片操作,需要让所有被 hook 方法的调用都通过一个统一的入口完成。
2、 为了给原始实现和切片操作提供参数/返回值信息,这个入口要能获取被 hook 方法完整的签名信息。
综合上述两点以及 Normal forwarding path 的执行过程,可以比较轻松地联想到 -forwardInvocation:
方法非常适合作为这个入口。结合 Aspects 源码,来看下其实现中,和消息转发相关的两个步骤:
1 | static void aspect_prepareClassAndHookSelector(NSObject *self, SEL selector, NSError **error) { |
这里在忽略掉 Aspects 创建子类等操作后,可以看出以上代码总共做了两件事:
1、对原始 -forwardInvocation:
方法执行 swizzling method ,将实现替换成 __ASPECTS_ARE_BEING_CALLED__
,以便在 __ASPECTS_ARE_BEING_CALLED__
函数中执行了额外的切片操作。
2、对被 hook 的方法执行 swizzling method ,将实现替换成 _objc_msgForward
/ _objc_msgForward_stret
,以便触发被 hook 方法的消息转发机制,然后在步骤 1 的 __ASPECTS_ARE_BEING_CALLED__
函数中,进行切片操作。
值得一提的是, JSPatch 也是利用相似的机制,实现用 defineClass
接口任意替换一个类的方法的功能,不同的是 JSPatch 在它的 __ASPECTS_ARE_BEING_CALLED__
函数中,直接把参数传给了 JavaScript 的实现。
小结
消息转发有三步,分别是 Lazy method resolution (动态添加方法)、 Fast forwarding path (转发至可响应对象)、 Normal forwarding path (获取 NSInvocation 信息)。关于消息转发的应用,本文主要摘录了以下几个例子:
- Week Proxy
- Delegate Proxy
- Multicast Delegate
- Record Message Call
- Intercept Any Message Call
可以看出,在这些例子中,都创建了一个代理类,并且这个代理类几乎没有实现自定义方法,或者直接是 NSProxy 的子类。这样,基本上所有的发送给代理类对象的消息,都会触发消息转发机制,而这个代理类就可以对拦截的消息做额外处理。
其中大部分应用场景都涉及到消息转发的第二三步,即 Fast forwarding path、Normal forwarding path 。特别是 Normal forwarding path ,配合 _objc_msgForward
/ _objc_msgForward_stret
函数强行进行消息转发,可以获取携带完整调用信息的 NSInvocation 。借助于 NSInvocation 的灵活性,开发者就可以完成一些非常有意思的事情了。
参考
MulticastDelegate
Smart Proxy Delegation
Objective-C Message Forwarding
Objective-C 中的消息与消息转发
Objective-C 消息发送与转发机制原理
AOP. Delivered
面向切面编程之 Aspects 源码解析及应用
JSPatch 实现原理详解