从 SafeKit 看异常保护及 Method Swizzling 使用分析

因为 SafeKit 的异常保护的原理是在 category 替换系统方法,只需在工程中引用 SafeKit 即可避免 NSArray 数组越界等引发的 crash,并不需要额外操作。所以日常开发中渐渐的并不会怎么在意到 SafeKit 的存在。

最近公司有一份项目需要重构,完全重写的那种,从新建一份空工程开始。之前并没有在意 SafeKit 的存在,所以在最开始并没有在工程中引入 SafeKit,直到一次痛苦的 debug 发现 crash 发生在这样的地方:

1
2
3
4
// cacheId 为 NSNmber 类型
if ([obj1.cacheId isEqualToNumber:obj2.cacheId]) {
// ...
}

报错信息:

1
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[__NSCFNumber compare:]: nil argument'

因为在执行 NSNmberisEqualToNumber: 方法时并没有判断 obj2.cacheId 是否为 nil,苹果的API也没有对异常保护,所以当 obj2.cacheIdnil 时便会 crash。然后才想起 SafeKit

而且以这种写法 Xcode 也不会给出警告,所以在 coding 时很容易忽略为 nil 的情况。

SafeKit 源码

SafeKit 的源码非常少,原理非常简单,就是将 NSNumber, NSArray, NSMutableArray, NSDictionary, NSMutableArray, NSString, NSMutableString 中会因越界、为 nil 等情况发生 crash 的方法替换为自己的方法,在自己的方法中加判断,如果越界、为 nil等 直接 return,否则继续执行。

例如NSArray

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
#import "NSArray+SafeKit.h"
#import "NSObject+swizzle.h"

@implementation NSArray (SafeKit)

- (instancetype)initWithObjects_safe:(id *)objects count:(NSUInteger)cnt {
NSUInteger newCnt = 0;
for (NSUInteger i = 0; i < cnt; i++) {
if (!objects[i]) {
break;
}
newCnt++;
}
self = [self initWithObjects_safe:objects count:newCnt];
return self;
}

- (id)safe_objectAtIndex:(NSUInteger)index {
if (index >= [self count]) {
return nil;
}
return [self safe_objectAtIndex:index];
}

- (NSArray *)safe_arrayByAddingObject:(id)anObject {
if (!anObject) {
return self;
}
return [self safe_arrayByAddingObject:anObject];
}

+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[self safe_swizzleMethod:@selector(initWithObjects_safe:count:) tarClass:@"__NSPlaceholderArray" tarSel:@selector(initWithObjects:count:)];
[self safe_swizzleMethod:@selector(safe_objectAtIndex:) tarClass:@"__NSArrayI" tarSel:@selector(objectAtIndex:)];
[self safe_swizzleMethod:@selector(safe_arrayByAddingObject:) tarClass:@"__NSArrayI" tarSel:@selector(arrayByAddingObject:)];
});
}

@end

safe_arrayByAddingObject: 替换 arrayByAddingObject: 方法,当 anObject 不存在则直接返回self
safe_objectAtIndex: 替换 objectAtIndex: 方法,当数组越界时直接返回 nil

注意,在 class_getInstanceMethod 方法中,要先知道类对应的真实的类名才行,例如 NSArray 其实在 Runtime 中对应着 __NSArrayI

Runtime 中对应
NSNumber __NSCFNumber
NSArray __NSArrayI
NSMutableArray __NSArrayM
NSDictionary __NSDictionaryI
NSMutableDictionary __NSDictionaryM
NSString __NSCFString
NSString __NSCFConstantString

具体对应参考 SafeKit 源码

其中,为了方便 NANumber, NSDictionary 等分类调用,Method Swizzling 操作也被作者在 NSObjectSwizzle 分类中替换成自己的 safe_swizzleMethod 方法:

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
@implementation NSObject(Swizzle)

+ (void)safe_swizzleMethod:(SEL)srcSel tarSel:(SEL)tarSel{
Class clazz = [self class];
[self safe_swizzleMethod:clazz srcSel:srcSel tarClass:clazz tarSel:tarSel];
}

+ (void)safe_swizzleMethod:(SEL)srcSel tarClass:(NSString *)tarClassName tarSel:(SEL)tarSel{
if (!tarClassName) {
return;
}
Class srcClass = [self class];
Class tarClass = NSClassFromString(tarClassName);
[self safe_swizzleMethod:srcClass srcSel:srcSel tarClass:tarClass tarSel:tarSel];
}

+ (void)safe_swizzleMethod:(Class)srcClass srcSel:(SEL)srcSel tarClass:(Class)tarClass tarSel:(SEL)tarSel{
if (!srcClass) {
return;
}
if (!srcSel) {
return;
}
if (!tarClass) {
return;
}
if (!tarSel) {
return;
}
Method srcMethod = class_getInstanceMethod(srcClass,srcSel);
Method tarMethod = class_getInstanceMethod(tarClass,tarSel);
method_exchangeImplementations(srcMethod, tarMethod);
}

@end

需要注意的是:
在 iOS10 及以前,NSArray 的语法糖 array[i] 用法会先调用 - (ObjectType)objectAtIndexedSubscript:(NSUInteger)idx NS_AVAILABLE(10_8, 6_0); 方法,如果没有再调用 - (ObjectType)objectAtIndex:(NSUInteger)index; 方法,所以 SafeKit 可以保证安全。

但是在 iOS11 beta 版中, array[i] 语法糖会直接调用 - (ObjectType)objectAtIndexedSubscript:(NSUInteger)idx NS_AVAILABLE(10_8, 6_0); 方法,如果没有则直接报错,所以为了适配 iOS11 ,在 SafeKit 的 NSArray+SafeKit 分类中还应该替换掉 - (ObjectType)objectAtIndexedSubscript:(NSUInteger)idx NS_AVAILABLE(10_8, 6_0); 方法。

Method Swizzling 使用分析

Method Swizzling 大概是 Runtime 中最常用的一个黑魔法了,它本质上就是对 IMP 和 SEL 进行交换。

Method Swizzling 应该在 +load 方法中执行

+load 方法是当类或分类被添加到 Objective-C runtime 时被调用的;
+initialize 方法是在类或它的子类收到第一条消息之前被调用的,这里所指的消息包括实例方法和类方法的调用。也就是说 +initialize 方法是以懒加载的方式被调用的,如果程序一直没有给某个类或它的子类发送消息,那么这个类的 +initialize 方法是永远不会被调用的。

所以 Method Swizzling 应该在 +load 方法中执行,避免 Method Swizzling 不会被执行到的情况

使用 dispatch_once 保证执行次数

Method Swizzling 本质上就是对 IMP 和 SEL 进行交换,如果被执行偶数次那么调换就会失效,相当于没有调换。比如同时调换 NSArray 和 NSMutableArray 中的 objectAtIndex:,如果不用 dispatch_once 保证执行,就可能导致调换方法失效。

也正因为这个原因,在 load 方法中执行 Method Swizzling 时不可调用 [super load] 方法,否则同样会导致调换方法失效。

参考

Objective-C Method Swizzling 的最佳实践 一文中给出的最佳实践:

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
@interface UIViewController (MRCUMAnalytics)

@end

@implementation UIViewController (MRCUMAnalytics)

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

SEL originalSelector = @selector(viewWillAppear:);
SEL swizzledSelector = @selector(mrc_viewWillAppear:);

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

BOOL success = class_addMethod(class, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod));
if (success) {
class_replaceMethod(class, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
});
}

#pragma mark - Method Swizzling

- (void)mrc_viewWillAppear:(BOOL)animated {
[self mrc_viewWillAppear:animated];
[MobClick beginLogPageView:NSStringFromClass([self class])];
}

@end

  • 主类本身有实现需要替换的方法,也就是 class_addMethod 方法返回 NO 。这种情况的处理比较简单,直接交换两个方法的实现。
  • 主类本身没有实现需要替换的方法,而是继承了父类的实现,即 class_addMethod 方法返回 YES 。这时使用 class_getInstanceMethod 函数获取到的 originalSelector 指向的就是父类的方法,我们再通过执行 class_replaceMethod(class, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod)); 将父类的实现替换到我们自定义的 mrc_viewWillAppear 方法中。这样就达到了在 mrc_viewWillAppear 方法的实现中调用父类实现的目的。
  • mrc_viewWillAppear:方法的定义看似是递归调用引发死循环,其实不会。因为 [self mrc_viewWillAppear:animated] 消息会动态找到mrc_viewWillAppear: 方法的实现,而它的实现已经被我们与 viewWillAppear: 方法实现进行了互换,所以这段代码不仅不会死循环,如果把 [self mrc_viewWillAppear:animated] 换成 [self viewWillAppear:animated] 反而会引发死循环。

神经病院Objective-C Runtime出院第三天——如何正确使用Runtime 一文中给出的Swizzling Method 标准定义,避免命名冲突:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@implementation NSView (MyViewAdditions)

static void MySetFrame(id self, SEL _cmd, NSRect frame);
static void (*SetFrameIMP)(id self, SEL _cmd, NSRect frame);

static void MySetFrame(id self, SEL _cmd, NSRect frame) {
// do custom work
SetFrameIMP(self, _cmd, frame);
}

+ (void)load {
[self swizzle:@selector(setFrame:) with:(IMP)MySetFrame store:(IMP *)&SetFrameIMP];
}

@end

虽然上面的代码看上去不是OC(因为使用了函数指针),但是这种做法确实有效的防止了命名冲突的问题。原则上来说,其实上述做法更加符合标准化的Swizzling。这种做法可能和人们使用方法不同,但是这种做法更好。Swizzling Method 标准定义应该是如下的样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef IMP *IMPPointer;

BOOL class_swizzleMethodAndStore(Class class, SEL original, IMP replacement, IMPPointer store) {
IMP imp = NULL;
Method method = class_getInstanceMethod(class, original);
if (method) {
const char *type = method_getTypeEncoding(method);
imp = class_replaceMethod(class, original, replacement, type);
if (!imp) {
imp = method_getImplementation(method);
}
}
if (imp && store) { *store = imp; }
return (imp != NULL);
}

@implementation NSObject (FRRuntimeAdditions)
+ (BOOL)swizzle:(SEL)original with:(IMP)replacement store:(IMPPointer)store {
return class_swizzleMethodAndStore(self, original, replacement, store);
}
@end

后记

Reference