前段时间APP项目上线之后,会有一些闪退的情况,而这些Crash大多数是属于内存越界、键值为空以及字符串的问题,因为现在用的Crash收集是友盟SDK,会出现很多无法定位的Crash(Application received signal SIGABRT)。
所以我就写了这个阻止Crash的方法,基本的原理就是用MethodSwizzle,通过替换原有类的方法,换成自己写的方法,然后在自己的方法里面处理异常情况。
这是项目的github地址:https://github.com/WalkingToTheDistant/CrashManager,这篇文章只是概述一些关键点。
使用方法
首先,从Github上下载代码压缩文件并解压,会得到CrashManager-master文件夹,把整个文件夹拖动添加到工程中。
要启用这个功能,需要调用 CrashPublic.h 里面的 openCrashHandleFunction 方法
CrashPublic.h
/** 开启防止Crash的功能,可以在application:didFinishLaunchingWithOptions 方法里面调用 */
+ (void) openCrashHandleFunction;
在 openCrashHandleFunction 方法,会调用各个分类Category的初始化方法,这里也许会有人奇怪,为什么各个Category还要显式写一个方法进行初始化,而不是在Category的load或者initialize 里面执行?
在这里说明一下这几个方法的区别
- initialize:首先 initialize 方法是只有在类方法第一次被使用的才会被调用,而且子类的initialize会触发父类的initialize(不管这个方法是子类还是父类的,父类的initialize不会触发子类)。调用顺序:父类 -> 子类,而在类别Category触发initialize也是这个顺序,(注意:类别的initialize会“覆盖”原类的initialize,而load不会,这就是为什么不在initialize实现方法替换的原因,避免父类的initialize被category覆盖而不被调用)
- load:load是只要类所在文件被引用就会被调用,而调用的顺序是:父类 -> 子类 -> 父类的类别 -> 子类的类别,类别中重写load不会覆盖原类的方法。
关于load和initialize 的区别,这篇文章讲的蛮好的:传送入门。
那么重点来了,为什么不在这几个方法里面执行交换呢?
首先是 + (void) load,load的执行顺序中,类别是最后才被执行,而很多系统库的API都是用类别实现的,比如NSMutableString (NSMutableStringExtensionMethods)。而类别的load顺序并不是固定的(跟Compile Sources里面的文件顺序有关,传送入口),那么就会出现一种情况,在load执行MethodSwizzle时,也许要替换的方法还没有加载到方法列表里面(找不到该方法指针),而该类的方法是在自己的load调用之前才会加载到原类的方法列表里面去。
再然后是 + (void) initialize,因为我们在类别中重新写了这个方法,所以这个会覆盖原类的方法,那么就有一个问题了,如果在原类或者其子类中触发了initialize,那么该类别的initialize就会被调用。例如在子类的load中调用了其类方法,那么触发父类和子类的initialize,而这个时候,有可能类别的方法也就没加载到类方法列表了。
所以就在CrashPublic显示写一个初始化方法openCrashHandleFunction,这样也可以在不需要使用CrashHandle的时候,直接屏蔽这句方法可以了。
另外还有一个方法可以考虑,那就是消息监听:
- NSNotificationCenter:监听UIApplicationDidFinishLaunchingNotification,是在application:didFinishLaunchingWithOptions执行结束之后会触发监听
#import <UIKit/UIKit.h>
[[NSNotificationCenter defaultCenter]addObserver:self selector:@selector(handleObserver:) name:UIApplicationDidFinishLaunchingNotification object:nil];
类簇
先简单看下关于NSArray类的处理
void MethodSwizzle(Class cls , SEL oriSelector, SEL dstSelector)
{
if(cls == nil){ return; }
Method oriMethod = class_getInstanceMethod(cls, oriSelector);
Method dstMethod = class_getInstanceMethod(cls, dstSelector);
BOOL isAdd = class_addMethod(cls, oriSelector, method_getImplementation(dstMethod), method_getTypeEncoding(dstMethod));
if(isAdd == YES
&& oriMethod != nil){
class_replaceMethod(cls, dstSelector, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
} else {
method_exchangeImplementations(oriMethod, dstMethod);
}
}
+ (void)initCrashCategory
{
static dispatch_once_t once;
dispatch_once(&once, ^{
//__NSSingleObjectArrayI, __NSArray0
MethodSwizzle(objc_getClass("__NSArrayI"), @selector(objectAtIndex:), @selector(arrayI_crashSafe_objectAtIndex:));
MethodSwizzle(objc_getClass("__NSArray0"), @selector(objectAtIndex:), @selector(array0_crashSafe_objectAtIndex:));
MethodSwizzle(objc_getClass("__NSSingleObjectArrayI"), @selector(objectAtIndex:), @selector(singleObjectArrayI_crashSafe_objectAtIndex:));
});
}
为什么不是直接对[NSArray class]进行处理,而是需要处理__NSArrayI、__NSArray0和__NSSingleObjectArrayI,你可以试试把上面的代码替换成下面这种,然后执行调试(可以在 crashSafe_objectAtIndex 里面增加一个断点)。
+ (void)initCrashCategory
{
static dispatch_once_t once;
dispatch_once(&once, ^{
//__NSSingleObjectArrayI, __NSArray0
MethodSwizzle([NSArray class], @selector(objectAtIndex:), @selector(crashSafe_objectAtIndex:));
});
}
如果你尝试之后,那么会发现,category里面的crashSafe_objectAtIndex的方法都不会被调用。
可能你对OC的类簇还比较陌生,简单说,我们平时代码里面用的NSArray、NSDictionary、NSString类,其实都是抽象类,在代码编译执行之后,这些类对象都会根据情况编译成 __NSArrayI、__NSArray0 这样的类实体对象(调试时在lldb输出对象的class就明白了),可以简单从类名称就知道其含义:__NSSingleObjectArrayI是Array只有一个元素的数组对象,__NSArray0则是没有元素的数组对象,__NSArrayI就是我们平时用的多元素数组。
下面这是各个类的类簇列表:
抽象类 | 类簇 |
---|---|
NSArray | __NSArray0、__NSSingleObjectArrayI、__NSArrayI |
NSMutableArray | __NSArrayM |
NSDictionary | __NSDictionary0、__NSSingleEntryDictionaryI、__NSDictionaryI |
NSMutableDictionary | __NSDictionaryM |
NSString | __NSCFString、NSTaggedPointerString、 __NSCFConstantString |
异常处理
当数组越界时,这是我在代码里面的处理
NSArray+Crash.h
- (id) arrayI_crashSafe_objectAtIndex:(NSUInteger)index
{
if(index >= self.count){ // 超出索引
return indexCrashHandle([self class]); // 然后处理消息转发
}
return [self arrayI_crashSafe_objectAtIndex:index]; // 这个方法已经被替换成原先的objectAtIndex
}
CrashPublic.h
id indexCrashHandle(Class cls)
{
return [CrashManager new];
}
CrashManager是的NSObject的自定义子类,也许有人会问了,既然数组索引超出了范围,那为什么不返回一个nil呢,为何返回一个CrashManager对象,CrashManager又是干嘛用的?
首先先回答为什么返回一个类对象,那是因为如果返回nil,很多代码后续也没有对象判断是否为空的操作,那么也许又有疑问了,OC本身用空指针调用方法也不会出错呀!这个是没错,可是如果把空指针当做参数调用呢?就像下面这句代码
NSString *strObj = dicTemp1[@"key"]; // 这个key不存在,返回nil
NSString *strTemp = @"tempStr";
strTemp = [string stringByAppendingString:strObj]; // 这句会闪退
/* Crash信息: Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[__NSCFConstantString stringByAppendingString:]: nil argument' */
当然,如果单单返回一个CrashManager类对象,而没有在CrashManager里面做任何处理,上面的代码依然会报错,只是报错的信息变成了unrecognized selector sent to instance,这时候这就需要我们对CrashManager做 消息转发 的处理了!
CrashManager里面的消息转发比较简单,在捕获到方法找不到的异常之后,手动添加一个返回0的空方法,并告诉系统调用这个方法,这样代码就可以继续执行。
/** 处理Crash的方法 */
int handleInstanceMethodCrashMethod()
{
return 0; // 通用返回值
}
/** 找不到方法,触发消息转发 */
+ (BOOL) resolveInstanceMethod:(SEL)sel
{
class_addMethod(self, sel, (IMP)handleInstanceMethodCrashMethod, "v@:"); // 手动添加handleInstanceMethodCrashMethod方法作为sel的执行者
return [super resolveInstanceMethod:sel]; // 返回YES则是告诉系统再次尝试执行这个sel
}
接下来我们用测试代码试试这个功能
测试用例1:
NSString *strObj = dicTemp1[@"key"]; // 这个key不存在,返回nil
NSString *strTemp = @"tempStr";
strTemp = [string stringByAppendingString:strObj];
运行会触发 resolveInstanceMethod 方法,输出sel: (SEL) sel = "length",那么如果 handleInstanceMethodCrashMethod 返回 0,那就告诉执行方法说这个strObj字符串长度为0,那么系统也不会继续执行添加strObj的操作,返回"tempStr" 的结果。
测试用例2:
NSArray *aryObj = dicTemp1[nil];
NSLog(@"%@", aryObj[1]);
触发resolveInstanceMethod ,输出sel:(SEL) sel = "objectAtIndexedSubscript:",那么handleInstanceMethodCrashMethod 返回0
,那么Log会输出:
2017-07-03 15:01:18.191 TempPro[9111:1441321] (null)
依然没有Crash,问题解决了!
结语
目前这个CrashHandle仅仅用于一个项目,还没有大量使用,所以可能里面还存在一些问题,我会继续更新Github上的代码。
另外这个Crash仅仅解决了内存越界,线上APP还存在有野指针的Crash问题,目前我还没有想到比较好的解决方案,如果想出来了,会继续更新文章。