记一次Swizzle touchesBegan:withEvent:事故

曾以为method swizzle是非常安全的行为,因为它所做的事情不过是修改selector和IMP之间映射关系,如下:

QQ20171128-1.png

QQ20171128-2.png

但最近写逻辑时,发现对UIResponder的touches系列方法(譬如touchesBegan:withEvent:)进行swizzle时,会触发doesNotRecognizeSelector:异常,如下代码简单描述了我的swizzle逻辑:

- (void)viewDidLoad {
[super viewDidLoad];
// TestView只是简单继承UIView,啥都没添加
TestView *gestureView = [[TestView alloc] initWithFrame:CGRectMake(100, 300, 100, 30)];
[gestureView addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapped)]];
[self.view addSubview:gestureView];
// 使用RAC监听回调,其本质是RAC在底层swizzle了`touchesBegan:withEvent:`方法
[[gestureView rac_signalForSelector:@selector(touchesBegan:withEvent:)] subscribeNext:^(id x) {
// do stuff
}];
}

运行代码,然后点击TestView,触发doesNotRecognizeSelector:异常,查看调用栈如下:

<_NSCallStackArray 0x604000241b90>(
0 ??? 0x000000011f7b5ecb 0x0 + 4823146187,
1 Test 0x0000000108da40e0 main + 0,
2 UIKit 0x000000010ae92f51 -[UIResponder doesNotRecognizeSelector:] + 295,
3 CoreFoundation 0x0000000109197f78 ___forwarding___ + 1432,
4 CoreFoundation 0x0000000109197958 _CF_forwarding_prep_0 + 120,
5 UIKit 0x000000010ae91fcd forwardTouchMethod + 347,
6 UIKit 0x000000010ae91e61 -[UIResponder touchesBegan:withEvent:] + 49,
7 CoreFoundation 0x000000010919936c __invoking___ + 140,
8 CoreFoundation 0x0000000109199240 -[NSInvocation invoke] + 320,
9 Test 0x0000000108dde32c RACForwardInvocation + 252,
10 Test 0x0000000108dde178 __RACSwizzleForwardInvocation_block_invoke + 88,
11 CoreFoundation 0x0000000109197cd8 ___forwarding___ + 760,
12 CoreFoundation 0x0000000109197958 _CF_forwarding_prep_0 + 120,
13 UIKit 0x000000010acd6562 -[UIWindow _sendTouchesForEvent:] + 2130,
14 UIKit 0x000000010acd7f2a -[UIWindow sendEvent:] + 4124,
15 UIKit 0x000000010ac7b365 -[UIApplication sendEvent:] + 352,
16 UIKit 0x000000010b5c7a1d __dispatchPreprocessedEventFromEventQueue + 2809,
17 UIKit 0x000000010b5ca672 __handleEventQueueInternal + 5957,
18 CoreFoundation 0x00000001091b8101 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 17,
19 CoreFoundation 0x0000000109257f71 __CFRunLoopDoSource0 + 81,
20 CoreFoundation 0x000000010919ca19 __CFRunLoopDoSources0 + 185,
21 CoreFoundation 0x000000010919bfff __CFRunLoopRun + 1279,
22 CoreFoundation 0x000000010919b889 CFRunLoopRunSpecific + 409,
23 GraphicsServices 0x000000010fb089c6 GSEventRunModal + 62,
24 UIKit 0x000000010ac5f5d6 UIApplicationMain + 159,
25 Test 0x0000000108da414f main + 111,
26 libdyld.dylib 0x000000010e5bfd81 start + 1

如何理解这个crash呢?我当时也处于懵逼状态,很难在网上搜索到相关资料,所幸看到了Aspects作者Peter Steinberger的这篇博客:A Story About Swizzling “the Right Way™” and Touch Forwarding

有了大神博客加持,解决问题便不在话下了;本文稍微用中文对大神的博客内容进行复述,并最终给出自己的解决方案。

经过Peter Steinberger的反编译处理,基本确定touchesBegan:withEvent:的底层实现逻辑如下:

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
forwardTouchMethod(self, _cmd, touches, event); // m1
}

问题的关键在于_cmd,先来提一个问题:已知ReactiveCocoa(v2.5)在swizzling method时,会加上rac_alias_前缀,那么代码执行到m1处时,_cmd是啥,@selector(touchesBegan:withEvent:)还是@selector(rac_alias_touchesBegan:withEvent:)

答案是@selector(rac_alias_touchesBegan:withEvent:)m1处逻辑(IMP)原先对应的selector是@selector(touchesBegan:withEvent:),swizzle后,就变成@selector(rac_alias_touchesBegan:withEvent:),所以_cmd对应的是@selector(rac_alias_touchesBegan:withEvent:)

OK,继续分析,回到forwardTouchMethod()上来,它的逻辑如下:

static void forwardTouchMethod(id self, SEL _cmd, NSSet *touches, UIEvent *event)
{
// The responder chain is used to figure out where to send the next touch
UIResponder *nextResponder = [self nextResponder];
if (nextResponder && nextResponder != self) {
// Not all touches are forwarded - so we filter here.
NSMutableSet *filteredTouches = [NSMutableSet set];
[touches enumerateObjectsUsingBlock:^(UITouch *touch, BOOL *stop) {
// Checks every touch for forwarding requirements.
if ([touch _wantsForwardingFromResponder:self toNextResponder:nextResponder withEvent:event]) {
[filteredTouches addObject:touch];
}else {
// This is interesting legacy behavior. Before iOS 5, all touches are forwarded (and this is logged)
if (!_UIApplicationLinkedOnOrAfter(12)) {
[filteredTouches addObject:touch];
// Log old behavior
static BOOL didLog = 0;
if (!didLog) {
NSLog(@"Pre-iOS 5.0 touch delivery method forwarding relied upon. Forwarding -%@ to %@.", NSStringFromSelector(_cmd), nextResponder);
}
}
}
}];
// here we basically call [nextResponder touchesBegan:filteredTouches event:event];
[nextResponder performSelector:_cmd withObject:filteredTouches withObject:event]; // m2
}
}

_cmd(即@selector(rac_alias_touchesBegan:withEvent:))作为参数传入了forwardTouchMethod()函数,后者的实现逻辑进行了responder路由,也就是说,最终响应@selector(rac_alias_touchesBegan:withEvent:)消息的不一定是TestView实例,而是别的UIResponder,可能是某个UIButtonUIViewController;然而,悲催的事情在于,TestView之外的UIResponder是无法响应@selector(rac_alias_touchesBegan:withEvent:)这个selector的,即m2处的逻辑最终会触发doesNotRecognizeSelector:异常。

到了这里,问题应该分析清楚了,那么该如何解决呢?解决问题的方向也很明显,让forwardTouchMethod()的入参_cmd变回@selector(touchesBegan:withEvent:)就可以了,Peter Steinberger在A Story About Swizzling “the Right Way™” and Touch Forwarding里提供的解决方案比较绕,仅符合他自己的应用场景,相较而言,我的solution更具备通用普适性:

static NSMutableDictionary<NSString *, NSMutableSet<NSString *> *> *SwizzledClassMethods()
{
static dispatch_once_t onceToken;
static NSMutableDictionary *swizzledMethods = nil;
dispatch_once(&onceToken, ^{
swizzledMethods = [[NSMutableDictionary alloc] init];
});
return swizzledMethods;
}
static void ImplementTouchMethodsIfNeeded(Class viewClass, SEL aSelector)
{
NSCParameterAssert(viewClass && aSelector);
if (!viewClass || !aSelector) {
return;
}
Class superclass = class_getSuperclass(viewClass);
NSCParameterAssert(superclass);
if (!superclass || !class_getInstanceMethod(superclass, aSelector)) {
return;
}
NSString *className = NSStringFromClass(viewClass);
NSString *methodName = NSStringFromSelector(aSelector);
NSMutableSet<NSString *> *swizzledMethods = [SwizzledClassMethods() objectForKey:className];
if ([swizzledMethods containsObject:methodName]) {
return;
}
IMP defaultIMP = imp_implementationWithBlock(^(id self, NSSet<UITouch *> *touches, UIEvent *event) {
struct objc_super super = {
.receiver = self,
.super_class = superclass
};
void (*touchesEventHandler)(struct objc_super *, SEL, NSSet<UITouch *> *, UIEvent *) = (__typeof__(touchesEventHandler))objc_msgSendSuper;
return touchesEventHandler(&super, aSelector, touches, event);
});
Method method = class_getInstanceMethod(superclass, aSelector);
class_addMethod(viewClass, aSelector, defaultIMP, method_getTypeEncoding(method));
if(swizzledMethods == nil) {
swizzledMethods = [[NSMutableSet alloc] init];
[SwizzledClassMethods() setObject:swizzledMethods forKey:className];
}
[swizzledMethods addObject:methodName];
}

具体的做法是在swizzle view的touches系列方法之前调用如上的ImplementTouchMethodsIfNeeded()函数,如下:

ImplementTouchMethodsIfNeeded(newHitTestView.class, @selector(touchesBegan:withEvent:));
ImplementTouchMethodsIfNeeded(newHitTestView.class, @selector(touchesCancelled:withEvent:));
ImplementTouchMethodsIfNeeded(newHitTestView.class, @selector(touchesEnded:withEvent:));
[[newHitTestView rac_signalForSelector:@selector(touchesBegan:withEvent:)] subscribeNext:^(RACTuple *_) {
// touches began block
}];
[[newHitTestView rac_signalForSelector:@selector(touchesEnded:withEvent:)] subscribeNext:^(RACTuple *_) {
// touches ended block
}];
[[newHitTestView rac_signalForSelector:@selector(touchesCancelled:withEvent:)] subscribeNext:^(RACTuple *_) {
// touches ended block
}];

代码看起来很长,其实核心逻辑非常简单,即如果被swizzle的实例对应的类没有override touches系列方法,则为它提供默认实现,实现逻辑无非是进行super invoke,即如下这般:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event
{
// f1
[super touchesBegan:touches withEvent:event]; // f2
// f3
}

为啥TestView对touchesBegan:withEvent:方法进行override就可以解决问题呢?

因为,即便代码走到f1处,_cmd变成了@selector(rac_alias_touchesBegan:withEvent:)f2处的显式调用也能确保逻辑走到UIRespondertouchesBegan:withEvent:时,_cmd恢复成@selector(touchesBegan:withEvent:)