因为 SafeKit 的异常保护的原理是在 category 替换系统方法,只需在工程中引用 SafeKit 即可避免 NSArray 数组越界等引发的 crash,并不需要额外操作。所以日常开发中渐渐的并不会怎么在意到 SafeKit 的存在。
最近公司有一份项目需要重构,完全重写的那种,从新建一份空工程开始。之前并没有在意 SafeKit 的存在,所以在最开始并没有在工程中引入 SafeKit,直到一次痛苦的 debug 发现 crash 发生在这样的地方:
1 | // cacheId 为 NSNmber 类型 |
报错信息:
1 | *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[__NSCFNumber compare:]: nil argument' |
因为在执行 NSNmber
的 isEqualToNumber:
方法时并没有判断 obj2.cacheId
是否为 nil
,苹果的API也没有对异常保护,所以当 obj2.cacheId
为 nil
时便会 crash。然后才想起 SafeKit
而且以这种写法 Xcode 也不会给出警告,所以在 coding 时很容易忽略为 nil
的情况。
SafeKit 源码
SafeKit 的源码非常少,原理非常简单,就是将 NSNumber
, NSArray
, NSMutableArray
, NSDictionary
, NSMutableArray
, NSString
, NSMutableString
中会因越界、为 nil
等情况发生 crash 的方法替换为自己的方法,在自己的方法中加判断,如果越界、为 nil
等 直接 return,否则继续执行。
例如NSArray
1 |
|
以 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 操作也被作者在 NSObject
的 Swizzle
分类中替换成自己的 safe_swizzleMethod
方法:
1 | @implementation NSObject(Swizzle) |
需要注意的是:
在 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 | @interface UIViewController (MRCUMAnalytics) |
- 主类本身有实现需要替换的方法,也就是
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 | @implementation NSView (MyViewAdditions) |
虽然上面的代码看上去不是OC(因为使用了函数指针),但是这种做法确实有效的防止了命名冲突的问题。原则上来说,其实上述做法更加符合标准化的Swizzling。这种做法可能和人们使用方法不同,但是这种做法更好。Swizzling Method 标准定义应该是如下的样子:
1 | typedef IMP *IMPPointer; |