其他分享
首页 > 其他分享> > iOS研发助手DoraemonKit技术实现之Crash查看

iOS研发助手DoraemonKit技术实现之Crash查看

作者:互联网

在日常开发中或者测试过程中,我们的应用可能会出现Crash的问题。对于这类问题我们要抱着零容忍的态度,因为如果线上出现了这类问题,将会严重影响用户的体验。

如果Crash出现的时候恰好是在开发过程中,那么开发者可以根据Xcode的调用堆栈或者控制台输出的信息来定位问题的原因。但是,如果是在测试过程中的话就比较麻烦了。常见的两种解决方案是:

  1. 直接把测试手机拿来连接Xcode查看设备信息中的日志。
  2. 需要测试同学给出Crash的复现路径,然后开发者在调试过程中进行复现。

不过,以上两种方式都不是很方便。那么问题来了,有没有更好的方式查看Crash日志?答案当然是肯定的。DoraemonKit的常用工具集中的Crash查看功能就解决了这个问题,可以直接在APP端查看Crash日志,下面我们来介绍下Crash查看功能的实现。

二、技术实现

在iOS的开发过程中,会出现各种各样的Crash,那如何才能捕获这些不同的Crash呢?其实对于常见的Crash而言,可以分为两类,一类是Objective-C异常,另一类是Mach异常,一些常见的异常如下图所示:

下面,我们就来看下这两类异常应当如何捕获。

2.1 Objective-C异常

顾名思义,Objective-C异常就是指在OC层面(iOS库、第三方库出现错误时)出现的异常。在介绍如何捕获Objective-C异常之前我们先来看下常见的Objective-C异常包括哪些。

2.1.1 常见的Objective-C异常

一般来说,常见的Objective-C异常包括以下几种:

这类异常的主要原因是没有对于参数的合法性进行校验,最常见的就是传入nil作为参数。例如,NSMutableDictionary添加key为nil的对象,测试代码如下:

NSString *key = nil;
NSString *value = @"Hello";
NSMutableDictionary *mDic = [[NSMutableDictionary alloc] init];
[mDic setObject:value forKey:key];

运行后控制台输出日志:

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', 
reason: '*** -[__NSDictionaryM setObject:forKey:]: key cannot be nil'

这类异常的主要原因是没有对于索引进行合法性的检查,导致索引落在集合数据的合法范围之外。例如,索引超出数组的范围从而导致数组越界的问题,测试代码如下:

NSArray *array = @[@0, @1, @2];
    NSUInteger index = 3;
    NSNumber *value = [array objectAtIndex:index];

运行后控制台输出日志:

*** Terminating app due to uncaught exception 'NSRangeException', 
reason: '*** -[__NSArrayI objectAtIndex:]: index 3 beyond bounds [0 .. 2]'

这类异常最容易出现在foreach操作中,主要原因是在遍历过程中进行了元素的修改。例如,在for in循环中如果修改所遍历的数组则会导致该问题,测试代码如下:

NSMutableArray *mArray = [NSMutableArray arrayWithArray:@[@0, @1, @2]];
    for (NSNumber *num in mArray) {
        [mArray addObject:@3];
    }

运行后控制台输出日志:

*** Terminating app due to uncaught exception 'NSGenericException', 
reason: '*** Collection <__NSArrayM: 0x600000c08660> was mutated while being enumerated.'

这类异常的主要原因是无法分配足够的内存空间。例如,分配一块超大的内存空间就会导致此类的异常,测试代码如下:

NSMutableData *mData = [[NSMutableData alloc] initWithCapacity:1];
    NSUInteger len = 1844674407370955161;
    [mData increaseLengthBy:len];

运行后控制台输出日志:

*** Terminating app due to uncaught exception 'NSMallocException', 
reason: 'Failed to grow buffer'

这类异常的主要原因是对文件进行相关操作时产生了异常,如手机没有足够的存储空间,文件读写权限问题等。例如,对于一个只有读权限的文件进行写操作,测试代码如下:

NSString *cacheDir = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
    NSString *filePath = [cacheDir stringByAppendingPathComponent:@"1.txt"];
    if (![[NSFileManager defaultManager] fileExistsAtPath:filePath]) {
        NSString *str1 = @"Hello1";
        NSData *data1 = [str1 dataUsingEncoding:NSUTF8StringEncoding];
        [[NSFileManager defaultManager] createFileAtPath:filePath contents:data1 attributes:nil];
    }
    
    NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingAtPath:filePath];
    [fileHandle seekToEndOfFile];
    NSString *str2 = @"Hello2";
    NSData *data2 = [str2 dataUsingEncoding:NSUTF8StringEncoding];
    [fileHandle writeData:data2];
    [fileHandle closeFile];

运行后控制台输出日志:

*** Terminating app due to uncaught exception 'NSFileHandleOperationException', 
reason: '*** -[NSConcreteFileHandle writeData:]: Bad file descriptor'

以上介绍了几个常见的Objective-C异常,接下来我们来看下如何捕获Objective-C异常。

2.1.2 捕获Objective-C异常

如果是在开发过程中,Objective-C异常导致的Crash会在Xcode的控制台输出异常的类型、原因以及调用堆栈,根据这些信息我们能够迅速定位异常的原因并进行修复。

给大家推荐一个iOS技术交流群:642363427!群内提供数据结构与算法、底层进阶、swift、逆向、底层面试题整合文档等免费资料!希望大家多多交流哦!

那如果不是在开发过程中,我们应当如何捕获这些异常的信息呢?

其实Apple已经给我们提供了捕获Objective-C异常的API,就是 NSSetUncaughtExceptionHandler 。我们先来看下官方文档是怎么描述的:

Sets the top-level error-handling function where you can perform last-minute logging before the program terminates.

意思就是通过这个API设置了异常处理函数之后,就可以在程序终止前的最后一刻进行日志的记录。这个功能正是我们想要的,使用起来也比较简单,代码如下:

+ (void)registerHandler {
    NSSetUncaughtExceptionHandler(&DoraemonUncaughtExceptionHandler);
}

这里的参数 DoraemonUncaughtExceptionHandler 就是异常处理函数,它的定义如下:

// 崩溃时的回调函数
static void DoraemonUncaughtExceptionHandler(NSException * exception) {
    // 异常的堆栈信息
    NSArray * stackArray = [exception callStackSymbols];
    // 出现异常的原因
    NSString * reason = [exception reason];
    // 异常名称
    NSString * name = [exception name];
    
    NSString * exceptionInfo = [NSString stringWithFormat:@"========uncaughtException异常错误报告========\nname:%@\nreason:\n%@\ncallStackSymbols:\n%@", name, reason, [stackArray componentsJoinedByString:@"\n"]];
    
    // 保存崩溃日志到沙盒cache目录
    [DoraemonCrashTool saveCrashLog:exceptionInfo fileName:@"Crash(Uncaught)"];
}

通过上面的代码我们可以看到,在异常发生的时候,异常名称、出现异常的原因以及异常的堆栈信息都可以拿到。拿到这些信息之后,保存到沙盒的cache目录,然后就可以直接查看了。

这里需要注意的是:对于一个APP来说,可能会集成多个Crash收集工具,如果大家都调用了 NSSetUncaughtExceptionHandler 来注册异常处理函数,那么后注册的将会覆盖掉前面注册的,导致前面注册的异常处理函数不能正常工作。

那应当如何解决这种覆盖的问题呢?其实思路很简单,在我们调用 NSSetUncaughtExceptionHandler 注册异常处理函数之前,先拿到已有的异常处理函数并保存下来。然后在我们的处理函数执行之后,再调用之前保存的处理函数就可以了。这样,后面注册的就不会对之前注册的产生影响了。

思路有了,该如何实现呢?通过Apple的文档可以知道,有一个获取之前异常处理函数的API,就是 NSGetUncaughtExceptionHandler ,通过它我们就可以获取之前的异常处理函数了,代码如下:

// 记录之前的崩溃回调函数
static NSUncaughtExceptionHandler *previousUncaughtExceptionHandler = NULL;

+ (void)registerHandler {
    // Backup original handler
    previousUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();
    
    NSSetUncaughtExceptionHandler(&DoraemonUncaughtExceptionHandler);
}

在我们设置自己的异常处理函数之前,先保存已有的异常处理函数。在处理异常的时候,我们自己的异常处理函数处理完毕之后,需要将异常抛给之前保存的异常处理函数,代码如下:

// 崩溃时的回调函数
static void DoraemonUncaughtExceptionHandler(NSException * exception) {
    // 异常的堆栈信息
    NSArray * stackArray = [exception callStackSymbols];
    // 出现异常的原因
    NSString * reason = [exception reason];
    // 异常名称
    NSString * name = [exception name];
    
    NSString * exceptionInfo = [NSString stringWithFormat:@"========uncaughtException异常错误报告========\nname:%@\nreason:\n%@\ncallStackSymbols:\n%@", name, reason, [stackArray componentsJoinedByString:@"\n"]];
    
    // 保存崩溃日志到沙盒cache目录
    [DoraemonCrashTool saveCrashLog:exceptionInfo fileName:@"Crash(Uncaught)"];
    
    // 调用之前崩溃的回调函数
    if (previousUncaughtExceptionHandler) {
        previousUncaughtExceptionHandler(exception);
    }
}

到这里,就基本完成对于Objective-C异常的捕获了。

2.2 Mach异常

上一节介绍了Objective-C异常,本节来介绍下Mach异常,那究竟什么是Mach异常呢?在回答这个问题之前,我们先来看下一些相关的知识。

2.2.1 Mach相关概念

上图来自于Apple的Mac Technology Overview,对于Kernel and Device Drivers 这一层而言,OS X与iOS架构大体上是一致的。其中,内核部分都是XNU,而Mach就是XNU的微内核核心。

Mach的职责主要是进程和线程抽象、虚拟内存管理、任务调度、进程间通信和消息传递机制等。

Mach微内核中有几个基本的概念:

BSD层则在Mach之上,提供一套可靠且更现代的API,提供了POSIX兼容性。

2.2.2 Mach异常与Unix信号

iOS系统自带的 Apple’s Crash Reporter 记录在设备中的Crash日志,Exception Type项通常会包含两个元素:Mach异常和Unix信号。

Mach异常:允许在进程里或进程外处理,处理程序通过Mach RPC调用。 Unix信号:只在进程中处理,处理程序总是在发生错误的线程上调用。

Mach异常是指最底层的内核级异常,被定义在 <mach/exception_types.h> 下 。每个thread,task,host都有一个异常端口数组,Mach的部分API暴露给了用户态,用户态的开发者可以直接通过Mach API设置thread,task,host的异常端口,来捕获Mach异常,抓取Crash事件。

所有Mach异常都在host层被 ux_exception 转换为相应的Unix信号,并通过 threadsignal 将信号投递到出错的线程。iOS中的 POSIX API 就是通过 Mach 之上的 BSD 层实现的。如下图所示:

例如, Exception Type:EXC_BAD_ACCESS (SIGSEGV) 表示的意思是:Mach层的 EXC_BAD_ACCESS 异常,在host层被转换成 SIGSEGV 信号投递到出错的线程。下图展示了从Mach异常转换成Unix信号的过程:

既然最终以信号的方式投递到出错的线程,那么就可以通过注册signalHandler来捕获信号:

signal(SIGSEGV,signalHandler);

捕获Mach异常或者Unix信号都可以抓到Crash事件,这里我们使用了Unix信号方式进行捕获,主要原因如下:

  1. Mach异常没有比较便利的捕获方式,既然它最终会转化成信号,我们也可以通过捕获信号来捕获Crash事件。
  2. 转换Unix信号是为了兼容更为流行的POSIX标准(SUS规范),这样不必了解Mach内核也可以通过Unix信号的方式来兼容开发。

基于以上原因,我们选择了基于Unix信号的方式来捕获异常。

2.2.3 信号释义

Unix信号有很多种,详细的定义可以在 <sys/signal.h> 中找到。下面列举我们所监控的常用信号以及它们的含义:

更多信号的释义可以参考《iOS异常捕获》。

2.2.4 捕获Unix信号

类似上一节中捕获Objective-C异常的思路,先注册一个异常处理函数,用于对信号的监控。代码如下:

+ (void)signalRegister {
    DoraemonSignalRegister(SIGABRT);
    DoraemonSignalRegister(SIGBUS);
    DoraemonSignalRegister(SIGFPE);
    DoraemonSignalRegister(SIGILL);
    DoraemonSignalRegister(SIGPIPE);
    DoraemonSignalRegister(SIGSEGV);
    DoraemonSignalRegister(SIGSYS);
    DoraemonSignalRegister(SIGTRAP);
}

static void DoraemonSignalRegister(int signal) {
    // Register Signal
    struct sigaction action;
    action.sa_sigaction = DoraemonSignalHandler;
    action.sa_flags = SA_NODEFER | SA_SIGINFO;
    sigemptyset(&action.sa_mask);
    sigaction(signal, &action, 0);
}

这里的 DoraemonSignalHandler 就是监控信号的异常处理函数,它的定义如下:

static void DoraemonSignalHandler(int signal, siginfo_t* info, void* context) {
    NSMutableString *mstr = [[NSMutableString alloc] init];
    [mstr appendString:@"Signal Exception:\n"];
    [mstr appendString:[NSString stringWithFormat:@"Signal %@ was raised.\n", signalName(signal)]];
    [mstr appendString:@"Call Stack:\n"];
    
    // 这里过滤掉第一行日志
    // 因为注册了信号崩溃回调方法,系统会来调用,将记录在调用堆栈上,因此此行日志需要过滤掉
    for (NSUInteger index = 1; index < NSThread.callStackSymbols.count; index++) {
        NSString *str = [NSThread.callStackSymbols objectAtIndex:index];
        [mstr appendString:[str stringByAppendingString:@"\n"]];
    }
    
    [mstr appendString:@"threadInfo:\n"];
    [mstr appendString:[[NSThread currentThread] description]];
    
    // 保存崩溃日志到沙盒cache目录
    [DoraemonCrashTool saveCrashLog:[NSString stringWithString:mstr] fileName:@"Crash(Signal)"];
    
    DoraemonClearSignalRigister();
}

这里有一点需要注意的是,过滤掉了第一行日志。这是因为注册了信号崩溃的回调方法,系统会来调用,将记录在调用堆栈上,因此为了避免困扰将此行日志过滤掉。

通过上面的代码我们可以看到,在异常发生时,信号名、调用堆栈、线程信息等都可以拿到。拿到这些信息之后,保存到沙盒的cache目录,然后就可以直接查看了。

类似捕获Objective-C异常可能出现的问题,在集成多个Crash收集工具时,如果大家对于相同的信号都注册了异常处理函数,那么后注册的将会覆盖掉前面注册的,导致前面注册的异常处理函数不能正常工作。

参考捕获Objective-C异常时处理覆盖问题的思路,我们也可以先将已有的异常处理函数进行保存,然后在我们的异常处理函数执行之后,再调用之前保存的异常处理函数就可以了。具体实现的代码如下:

static SignalHandler previousABRTSignalHandler = NULL;
static SignalHandler previousBUSSignalHandler  = NULL;
static SignalHandler previousFPESignalHandler  = NULL;
static SignalHandler previousILLSignalHandler  = NULL;
static SignalHandler previousPIPESignalHandler = NULL;
static SignalHandler previousSEGVSignalHandler = NULL;
static SignalHandler previousSYSSignalHandler  = NULL;
static SignalHandler previousTRAPSignalHandler = NULL;


+ (void)backupOriginalHandler {
    struct sigaction old_action_abrt;
    sigaction(SIGABRT, NULL, &old_action_abrt);
    if (old_action_abrt.sa_sigaction) {
        previousABRTSignalHandler = old_action_abrt.sa_sigaction;
    }
    
    struct sigaction old_action_bus;
    sigaction(SIGBUS, NULL, &old_action_bus);
    if (old_action_bus.sa_sigaction) {
        previousBUSSignalHandler = old_action_bus.sa_sigaction;
    }
    
    struct sigaction old_action_fpe;
    sigaction(SIGFPE, NULL, &old_action_fpe);
    if (old_action_fpe.sa_sigaction) {
        previousFPESignalHandler = old_action_fpe.sa_sigaction;
    }
    
    struct sigaction old_action_ill;
    sigaction(SIGILL, NULL, &old_action_ill);
    if (old_action_ill.sa_sigaction) {
        previousILLSignalHandler = old_action_ill.sa_sigaction;
    }
    
    struct sigaction old_action_pipe;
    sigaction(SIGPIPE, NULL, &old_action_pipe);
    if (old_action_pipe.sa_sigaction) {
        previousPIPESignalHandler = old_action_pipe.sa_sigaction;
    }
    
    struct sigaction old_action_segv;
    sigaction(SIGSEGV, NULL, &old_action_segv);
    if (old_action_segv.sa_sigaction) {
        previousSEGVSignalHandler = old_action_segv.sa_sigaction;
    }
    
    struct sigaction old_action_sys;
    sigaction(SIGSYS, NULL, &old_action_sys);
    if (old_action_sys.sa_sigaction) {
        previousSYSSignalHandler = old_action_sys.sa_sigaction;
    }
    
    struct sigaction old_action_trap;
    sigaction(SIGTRAP, NULL, &old_action_trap);
    if (old_action_trap.sa_sigaction) {
        previousTRAPSignalHandler = old_action_trap.sa_sigaction;
    }
}

这里需要注意的一点是,对于我们监听的信号都要保存之前的异常处理函数。

在处理异常的时候,我们自己的异常处理函数处理完毕之后,需要将异常抛给之前保存的异常处理函数,代码如下:

static void DoraemonSignalHandler(int signal, siginfo_t* info, void* context) {
    NSMutableString *mstr = [[NSMutableString alloc] init];
    [mstr appendString:@"Signal Exception:\n"];
    [mstr appendString:[NSString stringWithFormat:@"Signal %@ was raised.\n", signalName(signal)]];
    [mstr appendString:@"Call Stack:\n"];
   
    // 这里过滤掉第一行日志
    // 因为注册了信号崩溃回调方法,系统会来调用,将记录在调用堆栈上,因此此行日志需要过滤掉
    for (NSUInteger index = 1; index < NSThread.callStackSymbols.count; index++) {
        NSString *str = [NSThread.callStackSymbols objectAtIndex:index];
        [mstr appendString:[str stringByAppendingString:@"\n"]];
    }
    
    [mstr appendString:@"threadInfo:\n"];
    [mstr appendString:[[NSThread currentThread] description]];
    
    // 保存崩溃日志到沙盒cache目录
    [DoraemonCrashTool saveCrashLog:[NSString stringWithString:mstr] fileName:@"Crash(Signal)"];
    
    DoraemonClearSignalRigister();
    
    // 调用之前崩溃的回调函数
    previousSignalHandler(signal, info, context);
}

到这里,就基本完成对于Unix信号的捕获了。

2.3 小结

通过前面的介绍,相信大家对如何捕获Crash有了一定的了解,下面引用《Mach异常》中的一张图对之前的内容做一个总结,如下所示:

三、 踩过的坑

上面两节分别介绍了如何捕获Objective-C异常和Mach异常,本节主要是总结一下实现的过程中,遇到的一些问题。

3.1 通过Unix信号捕获Objective-C异常的问题

可能大家会觉得既然Unix信号可以捕获底层的Mach异常,那为什么不能捕获Objective-C异常呢?其实是可以捕获的,只是对于这种应用级的异常,你会发现调用堆栈里并没有你的代码,无法定位问题。例如,数组越界这种Objective-C异常的代码如下:

NSArray *array = @[@0, @1, @2];
    NSUInteger index = 3;
    NSNumber *value = [array objectAtIndex:index];

如果我们使用Unix信号进行捕获,得到的Crash日志如下:

Signal Exception:
Signal SIGABRT was raised.
Call Stack:
1   libsystem_platform.dylib            0x00000001a6df0a20 <redacted> + 56
2   libsystem_pthread.dylib             0x00000001a6df6070 <redacted> + 380
3   libsystem_c.dylib                   0x00000001a6cd2d78 abort + 140
4   libc++abi.dylib                     0x00000001a639cf78 __cxa_bad_cast + 0
5   libc++abi.dylib                     0x00000001a639d120 <redacted> + 0
6   libobjc.A.dylib                     0x00000001a63b5e48 <redacted> + 124
7   libc++abi.dylib                     0x00000001a63a90fc <redacted> + 16
8   libc++abi.dylib                     0x00000001a63a8cec __cxa_rethrow + 144
9   libobjc.A.dylib                     0x00000001a63b5c10 objc_exception_rethrow + 44
10  CoreFoundation                      0x00000001a716e238 CFRunLoopRunSpecific + 544
11  GraphicsServices                    0x00000001a93e5584 GSEventRunModal + 100
12  UIKitCore                           0x00000001d4269054 UIApplicationMain + 212
13  DoraemonKitDemo                     0x00000001024babf0 main + 124
14  libdyld.dylib                       0x00000001a6c2ebb4 <redacted> + 4
threadInfo:
<NSThread: 0x280f01400>{number = 1, name = main}

可以看到,通过上述调用堆栈我们无法定位问题。因此,我们需要拿到导致Crash的NSException,从中获取异常的名称、原因和调用堆栈,这样才能准确定位问题。

所以,在DoraemonKit中我们采用了 NSSetUncaughtExceptionHandler 对于Objective-C异常进行捕获。

3.2 两种异常共存的问题

由于我们既捕获了Objective-C异常,又捕获了Mach异常,那么当发生Objective-C异常的时候就会出现两份Crash日志。

一份是通过 NSSetUncaughtExceptionHandler 设置异常处理函数生成的日志,另一份是通过捕获Unix信号产生的日志。这两份日志中,通过Unix信号捕获的日志是无法定位问题的,因此我们只需要 NSSetUncaughtExceptionHandler 中异常处理函数生成的日志即可。

那该怎么做才能阻止生成捕获Unix信号的日志呢?在DoraemonKit中采取的方式是在Objective-C异常捕获到Crash之后,主动调用 exit(0) 或者 kill(getpid(), SIGKILL) 等方式让程序退出。

3.3 调试的问题

在捕获Objective-C异常时,使用Xcode进行调试可以清晰地看到调用流程。先调用了导致Crash的测试代码,然后进入异常处理函数捕获Crash日志。

但是,在调试Unix信号的捕获时会发现没有进入异常处理函数。这是怎么回事呢?难道是我们对于Unix信号的捕获没有生效么?其实并不是这样的。主要是由于Xcode调试器的优先级会高于我们对于Unix信号的捕获,系统抛出的信号被Xcode调试器给捕获了,就不会再往上抛给我们的异常处理函数了。

因此,如果我们要调试Unix信号的捕获时,不能直接在Xcode调试器里进行调试,一般使用的调试方式是:

  1. 通过Xcode查看设备的Device Logs,从中得到我们打印的日志。
  2. 直接将Crash保存到沙盒中,然后进行查看。

在DoraemonKit中,我们直接将Crash保存到沙盒的cache目录中,然后进行查看。

3.4 多个Crash收集工具共存的问题

正如之前所述,在同一个APP中集成多个Crash收集工具可能会存在强行覆盖的问题,即后注册的异常处理函数会覆盖掉之前注册的异常处理函数。

为了使得DoraemonKit不影响其他Crash收集工具,这里在注册异常处理函数之前会先保存之前已经注册的异常处理函数。然后在我们的处理函数执行之后,再调用之前保存的处理函数。这样,DoraemonKit就不会对之前注册的Crash收集工具产生影响了。

3.5 一些特殊的Crash

即使捕获Crash的过程没有问题,还是会存在一些捕获不到的情况。例如,短时间内内存急剧上升,这个时候APP会被系统kill掉。但是,此时的Unix信号是SIGKILL,该信号是用来立即结束程序的运行,不能被阻塞、处理和忽略。因此,无法对此信号进行捕获。

四、总结

写这篇文章主要是为了能够让大家对于DoraemonKit中Crash查看工具有一个快速的了解。由于时间仓促,个人水平有限,如有错误之处欢迎大家批评指正。

最后有什么疑惑问题这有个iOS交流群:642363427有一个共同的圈子很重要,结识人脉!里面都是iOS开发,全栈发展,欢迎入驻,共同进步!(群内会免费提供一些群主收藏的免费学习书籍资料以及整理好的几百道面试题和答案文档!)

 

标签:Crash,sigaction,捕获,iOS,DoraemonKit,action,异常,处理函数
来源: https://www.cnblogs.com/qfww/p/13906023.html