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

原文 : 与佳期的个人博客(gonghonglou.com)

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

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

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

报错信息:

*** 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

#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 方法:

@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 的最佳实践 一文中给出的最佳实践:

@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 标准定义,避免命名冲突:

@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 标准定义应该是如下的样子:

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342

推荐阅读更多精彩内容