Crash拦截器 - NSArray等类簇易崩溃接口拦截

在本文中,我们将了解到如下内容:

  1. 类簇(cù)
  2. 关于方法交换的几个接口
  3. 拦截易崩溃方法的方案

前言

在iOS的开发过程中,NSArrayNSDictionaryNSStringFoundation框架的一些类是我们非常常用的类型,同时这些类也为我们提供了很多非常好用且实用的接口(例如NSArrayobjectAtIndex:方法)。在我们享受着这些接口为我们提供的便利的同时,我们还需要承担传参错误导致崩溃的风险(比如objectAtIndex:接口常出现的数组越界问题)。
本着规避类似问题并方便开发的目的,在本篇文章以NSArray为例,将对这些易出现崩溃的接口进行拦截,并做安全处理。

类簇

想要拦截一个类的某个方法,我们会马上想到方法交换(Method Swizzing),因为方法交换真的很适合做这样的工作。但是当我们尝试对NSArray的方法进行交换时,发现交换成功了但是根本没有任何效果。这是因为为我们工作的真实类并不是NSArray,而是NSArray类簇
Foundation中,NSArray其实是一个抽象类(一个只负责定义方法而不负责实现方法的类)。真正实现了NSArray中定义的方法的其实是它的各个实现类。
如果我们为NSArray添加一个分类,并重写initialize如下:

@implementation NSArray (HMXSafe)

+ (void)initialize {
    [super initialize];
    
    printf("class name is:%s \n", class_getName(self.class));
}

@end

会得到如下的打印结果:

class name is:NSArray 
class name is:NSMutableArray 
class name is:__NSPlaceholderArray 
class name is:__NSSingleObjectArrayI 
class name is:__NSArrayM 
class name is:__NSArray0 
class name is:__NSCFArray 
class name is:__NSArrayI 
class name is:__NSArrayI_Transfer 
class name is:__NSFrozenArrayM 
class name is:NSConstantArray 
class name is:CALayerArray 
class name is:__NSOrderedSetArrayProxy 

除了NSArrayNSMutableArray,其它的类我们有些见过,有些没见过,但是这些类才是我们平时使用的真正的类。要证实这一点,我们可以在代码中打断点查看变量的类型,如下:

NSArray *arr0 = @[]; // __NSArray0
NSArray *arrSI = @[@"123"]; // __NSSingleObjectArrayI
NSArray *arrI = @[@"123", @"234", @"345"]; // __NSArrayI
NSArray *arrM = @[@"123"].mutableCopy; // __NSArrayM

正因为NSArray是以类簇的形式而存在的,所以我们只是交换NSArray的方法是没有用的。
Foundation框架中还有很多其它的类簇,例如NSStringNSDictionaryNSNumberNSAttributedString,或者还有更多的类簇,笔者没有做详细的统计。
既然是类簇,那么是不是意味着方法交换的方式不能达成我们的需求了呢?并不是。本篇文章依然是使用方法交换来进行拦截的。

关于方法交换的几个接口

在说明实现方案之前,我们先对方法交换的几个接口做一个清晰地了解。不感兴趣的小伙伴可以直接跳过,不影响对实现方案的理解。

// 获取cls的实例方法。如果当前类没有,则会向上追溯到根类。
// 需要注意的是它并不仅仅是获取常规意义上的实例方法,如果cls是元类则会获取这个元类的类方法。
Method _Nullable class_getInstanceMethod(Class _Nullable cls, SEL _Nonnull name)
// 获取cls的类方法。如果当前类没有,则会向上追溯到根类。
Method _Nullable class_getClassMethod(Class _Nullable cls, SEL _Nonnull name)
// 给cls新增方法,需要提供结构体的三个成员。
// 新增成功返回YES,否则返回NO(例如cls已经存在了该方法)
BOOL class_addMethod(Class _Nullable cls, SEL _Nonnull name, IMP _Nonnull imp, const char * _Nullable types)

// 替换cls中的SEL为name的方法的实现为imp。
// 如果name不存在则直接新增方法,如果存在则进行替换
IMP _Nullable class_replaceMethod(Class _Nullable cls, SEL _Nonnull name, IMP _Nonnull imp, const char * _Nullable types)
// 交换两个method的imp
// IMP imp1 = method_getImplementation(m1);
// IMP imp2 = method_getImplementation(m2);
// method_setImplementation(m1, imp2);
// method_setImplementation(m2, imp1);
method_exchangeImplementations(Method _Nonnull m1, Method _Nonnull m2);
// cls是类对象则获取实例方法列表、cls是元类对象则获取类方法列表。
// 需要注意的是返回值中不包含父类的方法
Method _Nonnull * _Nullable class_copyMethodList(Class _Nullable cls, unsigned int * _Nullable outCount)
// 获取cls的类方法列表。
// 需要注意的是返回值中不包含父类的方法
Class _Nonnull * _Nullable objc_copyClassList(unsigned int * _Nullable outCount)
// 注册一个名为str的SEL,如果这个名字的SEL已经存在,则直接返回该SEL
SEL _Nonnull sel_registerName(const char * _Nonnull str)

拦截易崩溃方法的方案

通常的方法有两种:

  1. NSArray添加分类,在分类中添加safe方法,然后规定开发人员使用分类中的安全方法,从而达到规避崩溃的目的。
  2. 手动找到NSArray类簇中的所有实现类,在代码中对指定的实现类进行方法替换,从而达到目的。

对于方法1而言,笔者是不太喜欢的,因为要在调用的地方手动引入NSArray分类的头文件,然后调用指定的安全方法。首先是不喜欢引入非必要的头文件,其次是希望能调用系统方法,以无感知的方式来达到目的。
对于方法2而言,其实已经基本上达到了目的,但是一来是否找全了需要处理的实现类,二者多少有些不够智能化的感觉。

我们的目标是不在代码中硬编码类簇中实现类的类名,并且能够替换所有实现类的相关方法。
鉴于initialize方法是在每个类第一次被使用时调用,并且会调用[super initialize](每个实现类都会调用NSArrayinitialize方法),所以我们选择在initialize方法进行方法交换的操作。
直接贴出NSArray分类的实现:

@implementation NSArray (HMXSafe)

+ (void)initialize {
    [super initialize];
    
    [self exchangeSafeMethod];
}

+ (void)exchangeSafeMethod {
    [self safe_sizzleSelector:@selector(initWithObjects:count:) toSelector:@selector(safe_initWithObjects:count:)];
    [self safe_sizzleSelector:@selector(objectAtIndex:) toSelector:@selector(safe_objectAtIndex:)];
    [self safe_sizzleSelector:@selector(getObjects:range:) toSelector:@selector(safe_getObjects:range:)];
    [self safe_sizzleSelector:@selector(subarrayWithRange:) toSelector:@selector(safe_subarrayWithRange:)];
}

- (instancetype)safe_initWithObjects:(id  _Nonnull const [])objects count:(NSUInteger)cnt {
    id __unsafe_unretained newObjects[cnt];
    NSUInteger index = 0;
    for (int i = 0; i < cnt; i++) {
        if (objects[i] == nil) {
            continue;
        }
        newObjects[index++] = objects[i];
    }
    return [self safe_initWithObjects:newObjects count:index];
}


- (id)safe_objectAtIndex:(NSUInteger)index {
    if (index >= [(NSArray *)self count]) {
        NSLog(@"out of array range  %@", NSStringFromClass(self.class));
        return nil;
    }
    
    id obj = [self safe_objectAtIndex:index];
    return obj;
}

- (void)safe_getObjects:(__unsafe_unretained id  _Nonnull *)objects range:(NSRange)range {
    if (range.location + range.length > self.count) {
        return;
    }
    [self safe_getObjects:objects range:range];
}

- (NSArray *)safe_subarrayWithRange:(NSRange)range {
    if (range.location + range.length > self.count) {
        NSLog(@"error subarrayWithRange -- %@", self.class);
        return @[];
    }
    return [self safe_subarrayWithRange:range];
}

@end

我们替换NSArrayexchangeSafeMethod方法中列出的4个方法。代码的逻辑简单且清晰,关键代码是safe_sizzleSelector:toSelector:,下面贴出这个方法的代码:

+ (void)safe_sizzleSelector:(SEL)selector toSelector:(SEL)toSelector {
    if (selector == NULL || toSelector == NULL) {
        return;
    }
    
    // 找到拥有该selector的类cls
    Class cls = [self findWhoHasSelector:selector];
    if (cls == NULL) {
        return;
    }
    
    // 查找backupSelector
    // 如果没有则认为是第一次替换方法,此时需要拷贝toSelector到backupSelector
    const char *prefix = "backup_";
    const char *toName = sel_getName(toSelector);
    char *backupName = (char *)malloc(strlen(prefix) + strlen(toName) + 1);
    strcpy(backupName, prefix);
    strcat(backupName, toName);
    SEL backupSelector = sel_registerName(backupName);
    Method backupMethod = class_getInstanceMethod(self, backupSelector);
    if (backupMethod == NULL) {
        Class rootCls = [self findWhoHasSelector:toSelector];
        if (rootCls == NULL) {
            return;
        }
        
        class_addMethod(rootCls, backupSelector, class_getMethodImplementation(rootCls, toSelector), method_getTypeEncoding(backupMethod));
        
        // 为backupMethod赋值
        backupMethod = class_getInstanceMethod(rootCls, backupSelector);
    }
    
    // 检查selector的imp与backupSelector的imp是否相同,如果相同则认为已经替换过了,不再进行替换操作
    if (class_getMethodImplementation(cls, selector) == class_getMethodImplementation(cls, backupSelector)) {
        return;
    }
    
    // 如果cls没有有toSelector,则为cls添加name为toSelector,imp为backupSelector的方法
    class_addMethod(cls, toSelector, method_getImplementation(backupMethod), method_getTypeEncoding(backupMethod));
    
    [cls exchangeSelector:selector toSelector:toSelector];
}

其中使用到的几个自定义方法如下:

/// 检查当前Class是否有指定的selector
/// @param selector 指定的selector
+ (BOOL)checkExistSelector:(SEL)selector {
    if (selector == NULL) {
        return NO;
    }
    
    unsigned int count = 0;
    Method *methodList = class_copyMethodList(self, &count);
    for (int i = 0; i < count; i++) {
        Method method = methodList[i];
        SEL methodSel = method_getName(method);
        if (methodSel == selector) {
            return YES;
        }
    }
    return NO;
}

/// 找到当前Class中寻找selector,如果没有则在其父类中寻找,直到在Root Class上寻找不到时,返回NULL
/// @param selector 指定的selector
+ (Class)findWhoHasSelector:(SEL)selector {
    Class cls = self;
    while (cls) {
        if ([cls checkExistSelector:selector]) {
            break;
        }
        cls = class_getSuperclass(cls);
    }
    return cls;
}

/// 交换当前Class上的两个方法的实现
+ (void)exchangeSelector:(SEL)selector toSelector:(SEL)toSelector {
    Method originalMethod = class_getInstanceMethod(self, selector);
    Method customMethod = class_getInstanceMethod(self, toSelector);
    
    method_exchangeImplementations(originalMethod, customMethod);
}
  1. 首先我们找到拥有要替换的selector的类cls,如果没找到则直接返回。
    因为是类簇,我们不知道有几层继承关系,也不知道是哪个类实现了这个方法,所以通过回溯父类的方式找到实现这个方法的类。
  2. 查找backupSelector
    backupSelector是对safe方法toSelector的一个备份。因为可能会对同一个selector进行复数次的交换请求,所以我们需要有一个机制来进行判定,笔者想到的方法就是判断selectorimpsafe方法的imp是否相等,如果相等,则认为已经进行了替换。我们在开始方法交换之前,在NSArray中将toSelectorimp备份到backupSelector,保证每次进行判定的时候,都能找到safe方法的imp
  3. 如果没有backupSelector则认为是第一次进行该方法的交换,这个时候我们需要备份toSelector
    找到toSelector所在的类,这里其实就是NSArray。为NSArray添加一个Method,其SELbackupSelectorimp则是toSelectorimp
    在我们后续需要进行判定时,只需要对比selectorbackupSelectorimp,如果两者一样,则认定已经做了方法交换。
  4. 判定是否做了方法交换,如果已经交换过,则直接返回。
  5. 调用class_addMethodcls添加方法toSelector
    class_addMethod在目标类没有该方法时才进行添加操作。
    添加的方法的SELtoSelector,而impbackupSelectorimp。这样做的原因是有可能cls的父类已经交换了方法,这时候找到的toSelector就是交换后的方法,其imp指向的会是父类的方法。我们使用backupSelectorimp则不会出现imp指向错误的问题。
  6. 交换clsselectortoSelector的实现。
  7. NSArrayinitialize方法中发起方法交换的请求。

这个方案有如下缺点:

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

推荐阅读更多精彩内容