监控 iOS 崩溃

本文是<> 第十二篇学习笔记.

App 上线后, 导致其崩溃的问题,不仅包括编写代码时的各种小马虎,还包括那些被系统强杀的疑难杂症。

常见编写代码让应用崩溃的小马虎。

  • 数组越界:在取数据索引时越界,App 会发生崩溃。还有一种情况,就是给数组添加了 nil 会崩溃。
  • 多线程问题:在子线程中进行 UI 更新可能会发生崩溃。多个线程进行数据的读取操作,因为处理时机不一致,比如有一个线程在置空数据的同时另一个线程在读取这个数据,可能会出现崩溃情况。
  • 主线程无响应:如果主线程超过系统规定的时间无响应,就会被 Watchdog 杀掉。这时,崩溃问题对应的异常编码是 0x8badf00d。
  • 野指针:指针指向一个已删除的对象,访问内存区域时,会出现野指针崩溃。野指针问题是需要我们重点关注的,因为它是导致 App 崩溃的最常见,也是最难定位的一种情况。

程序崩溃了, App 就不可用了,对用户的伤害也是最大的。因此,每家公司会将崩溃率(也就是一段时间内崩溃次数与启动次数之比)作为优先级最高的技术指标,比如千分位是生死线,万分位是达标线等,去衡量一个 App 的高可用性。

崩溃率等技术指标,一般都是由崩溃监控系统来搜集。同时,崩溃监控系统收集到的堆栈信息,也为解决崩溃问题提供了最重要的信息。
但有些崩溃日志是可以通过信号捕获到的,而很多崩溃日志却是通过信号捕获不到的。

  • KVO 问题、NSNotification 线程问题、数组越界、野指针等崩溃信息,是可以通过信号捕获的。
  • 但后台任务超时、内存被打爆、主线程卡顿超阈值等信息,是无法通过信号捕捉到的。

异常的捕获

对于系统崩溃而引起的程序异常退出,可以通过NSSetUncaughtExceptionHandler机制捕获

在启动appdidFinishLaunchingWithOptions的时候设置

NSSetUncaughtExceptionHandler (&UncaughtExceptionHandlers);//系统异常捕获

回调

void HandleException(NSException *exception)  {

    // 异常的堆栈信息
    NSArray *stackArray = [exception callStackSymbols];

    // 出现异常的原因
    NSString *reason = [exception reason];

    // 异常名称
    NSString *name = [exception name];

    NSString *exceptionInfo = [NSString stringWithFormat:@"Exception reason:%@\nException name:%@\nException stack:%@",name, reason, stackArray];

    NSLog(@"%@", exceptionInfo);
    // 写入本地
    NSString *logPath=[NSString stringWithFormat:@"%@/Documents/error.log",NSHomeDirectory()];
    [exceptionInfo writeToFile:logPath  atomically:YES encoding:NSUTF8StringEncoding error:nil];
}

信号可捕获的崩溃日志收集

收集崩溃日志最简单的方法,就是在提交时选上Upload your app’s symbols to receive symbolicated reports from Apple,以后你就可以直接在 Xcode 的 Archive 里看到符号化后的崩溃日志了

但是这种查看日志的方式,纯手工的操作,而且时效性较差。所以,目前很多公司的崩溃日志监控系统,都是通过PLCrashReporter 这样的第三方开源库捕获崩溃日志,然后上传到自己服务器上进行整体监控的。

如果没有服务端开发能力,或者对数据不敏感,可以使用 Fabric或者Bugly来监控崩溃。

使用Objective-C的异常处理是不能得到signal的,如果要处理它,我们还要利用unix标准的signal机制,注册SIGABRT, SIGBUS, SIGSEGV等信号发生时的处理函数。

该函数中我们可以输出栈信息,版本信息等其他一切我们所想要的。NSSetUncaughtExceptionHandler 用来做异常处理,但功能非常有限. 对引起崩溃的大多数原因如:内存访问错误,重复释放等错误无能为力,因为这种错误它抛出的是Signal,所以必须要专门做Signal处理,

@interface UncaughtExceptionHandler : NSObject  {
    BOOL dismissed;
}
+ (void)InstallUncaughtExceptionHandler;
@end

#import 
#import "UncaughtExceptionHandler.h"
#include 
#include 

NSString * const UncaughtExceptionHandlerSignalExceptionName = @"UncaughtExceptionHandlerSignalExceptionName";
NSString * const UncaughtExceptionHandlerSignalKey = @"UncaughtExceptionHandlerSignalKey";
NSString * const UncaughtExceptionHandlerAddressesKey = @"UncaughtExceptionHandlerAddressesKey";
NSString * const UncaughtExceptionHandlerFileKey = @"UncaughtExceptionHandlerFileKey";

volatile int32_t UncaughtExceptionCount = 0;
const int32_t UncaughtExceptionMaximum = 10;
const NSInteger UncaughtExceptionHandlerSkipAddressCount = 4;
const NSInteger UncaughtExceptionHandlerReportAddressCount = 5;

void MySignalHandler(int signal);


@implementation UncaughtExceptionHandler

+ (void)InstallUncaughtExceptionHandler {
    NSSetUncaughtExceptionHandler (&UncaughtExceptionHandlers);////系统异常捕获(越界)
    //信号量截断
    signal(SIGABRT, MySignalHandler);
    signal(SIGILL, MySignalHandler);
    signal(SIGSEGV, MySignalHandler);
    signal(SIGFPE, MySignalHandler);
    signal(SIGBUS, MySignalHandler);
    signal(SIGPIPE, MySignalHandler);
}

//获取函数堆栈信息
+ (NSArray *)backtrace {

    void* callstack[128];
    int frames = backtrace(callstack, 128);//用于获取当前线程的函数调用堆栈,返回实际获取的指针个数
    char **strs = backtrace_symbols(callstack, frames);//从backtrace函数获取的信息转化为一个字符串数组
    int i;
    NSMutableArray *backtrace = [NSMutableArray arrayWithCapacity:frames];
    for (i = UncaughtExceptionHandlerSkipAddressCount;
     i < UncaughtExceptionHandlerSkipAddressCount+UncaughtExceptionHandlerReportAddressCount;
     i++)  {
        [backtrace addObject:[NSString stringWithUTF8String:strs[i]]];
    }
    free(strs);
    return backtrace;
}

- (void)saveCreash:(NSException *)exception file:(NSString *)file {
    NSArray *stackArray = [exception callStackSymbols];// 异常的堆栈信息
    NSString *reason = [exception reason];// 出现异常的原因
    NSString *name = [exception name];// 异常名称

    //或者直接用代码,输入这个崩溃信息,以便在console中进一步分析错误原因
    NSLog(@"CRASH: %@", exception);
    NSLog(@"Stack Trace: %@", [exception callStackSymbols]);


    NSString * _libPath  = [[NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) objectAtIndex:0] stringByAppendingPathComponent:file];

//    NSString *_libPath=[NSHomeDirectory() stringByAppendingPathComponent:file];

    if (![[NSFileManager defaultManager] fileExistsAtPath:_libPath]) {
        [[NSFileManager defaultManager] createDirectoryAtPath:_libPath withIntermediateDirectories:YES attributes:nil error:nil];
    }

    NSDate* dat = [NSDate dateWithTimeIntervalSinceNow:0];
    NSTimeInterval a=[dat timeIntervalSince1970];
    NSString *timeString = [NSString stringWithFormat:@"%f", a];

    NSString * savePath = [_libPath stringByAppendingFormat:@"/error%@.log",timeString];

    NSString *exceptionInfo = [NSString stringWithFormat:@"Exception reason:%@\nException name:%@\nException stack:%@",name, reason, stackArray];

    BOOL sucess = [exceptionInfo writeToFile:savePath atomically:YES encoding:NSUTF8StringEncoding error:nil];

    NSLog(@"保存崩溃日志 sucess:%d,%@",sucess,savePath);
}

//异常处理方法
- (void)handleException:(NSException *)exception {
    NSDictionary *userInfo=[exception userInfo];
    [self saveCreash:exception file:[userInfo objectForKey:UncaughtExceptionHandlerFileKey]];    

    NSSetUncaughtExceptionHandler(NULL);
    signal(SIGABRT, SIG_DFL);
    signal(SIGILL, SIG_DFL);
    signal(SIGSEGV, SIG_DFL);
    signal(SIGFPE, SIG_DFL);
    signal(SIGBUS, SIG_DFL);
    signal(SIGPIPE, SIG_DFL);
    if ([[exception name] isEqual:UncaughtExceptionHandlerSignalExceptionName]) 
        kill(getpid(), [[[exception userInfo] objectForKey:UncaughtExceptionHandlerSignalKey] intValue]);
    } else {
        [exception raise];
    }
}    

//获取应用信息
NSString* getAppInfo() {
    NSString *appInfo = [NSString stringWithFormat:@"App : %@ %@(%@)\nDevice : %@\nOS Version : %@ %@\n",
                     [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"],
                     [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"],
                     [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"],
                     [UIDevice currentDevice].model,
                     [UIDevice currentDevice].systemName,
                     [UIDevice currentDevice].systemVersion];
//                         [UIDevice currentDevice].uniqueIdentifier];
    NSLog(@"Crash!!!! %@", appInfo);
    return appInfo;
}

//NSSetUncaughtExceptionHandler捕获异常的调用方法
//利用 NSSetUncaughtExceptionHandler,当程序异常退出的时候,可以先进行处理,然后做一些自定义的动作
void UncaughtExceptionHandlers (NSException *exception) {

    int32_t exceptionCount = OSAtomicIncrement32(&UncaughtExceptionCount);
    if (exceptionCount > UncaughtExceptionMaximum)
    {
        return;
    }

    NSArray *callStack = [UncaughtExceptionHandler backtrace];
    NSMutableDictionary *userInfo =
    [NSMutableDictionary dictionaryWithDictionary:[exception userInfo]];
    [userInfo setObject:callStack forKey:UncaughtExceptionHandlerAddressesKey];
    [userInfo setObject:@"OCCrash" forKey:UncaughtExceptionHandlerFileKey];


    [[[UncaughtExceptionHandler alloc] init] performSelectorOnMainThread:@selector(handleException:) 
                                                              withObject:[NSException exceptionWithName:[exception name]
                                                                  reason:[exception reason] userInfo:userInfo] 
                                                           waitUntilDone:YES]; 
}

//Signal处理方法
void MySignalHandler(int signal) {
    int32_t exceptionCount = OSAtomicIncrement32(&UncaughtExceptionCount);//自动增加一个32位的值
    if (exceptionCount > UncaughtExceptionMaximum)
    {
        return;
    }

    NSMutableDictionary *userInfo = [NSMutableDictionary dictionaryWithObject:[NSNumber numberWithInt:signal] forKey:UncaughtExceptionHandlerSignalKey];
    NSArray *callStack = [UncaughtExceptionHandler backtrace];
    [userInfo setObject:callStack forKey:UncaughtExceptionHandlerAddressesKey];
    [userInfo setObject:@"SigCrash" forKey:UncaughtExceptionHandlerFileKey];

    [[[UncaughtExceptionHandler alloc] init] performSelectorOnMainThread:@selector(handleException:) 
                                                              withObject:[NSException exceptionWithName:UncaughtExceptionHandlerSignalExceptionName 
                                                                  reason:[NSString stringWithFormat:NSLocalizedString(@"Signal %d was raised.\n" @"%@", nil), signal, getAppInfo()] userInfo:userInfo]
                                                           waitUntilDone:YES]; 
}
@end

代码对各种信号都进行了注册,捕获到异常信号后,在处理方法 handleSignalException 里通过 backtrace_symbols 方法就能获取到当前的堆栈信息。
堆栈信息可以先保存在本地,下次启动时再上传到崩溃监控服务器就可以了。

先将捕获到的堆栈信息保存在本地,是为了实现堆栈信息数据的持久化存储。

这是因为,在保存完这些堆栈信息以后,App 就崩溃了,崩溃后内存里的数据也就都没有了。而将数据保存在本地磁盘中,就可以在 App 下次启动时能够很方便地读取到这些信息。

信号捕获不到的崩溃信息收集?

App 退到后台后,即使代码逻辑没有问题也很容易出现崩溃。而且,这些崩溃往往是因为系统强制杀掉了某些进程导致的,而系统强杀抛出的信号还由于系统限制无法被捕获到。

一般,在退后台时你都会把关键业务数据保存在内存中,如果保存过程中出现了崩溃就会丢失或损坏关键数据,进而数据损坏又会导致应用不可用。这种关键数据的损坏会给用户带来巨大的损失。

后台容易崩溃的原因

先介绍下 iOS 后台保活的 5 种方式:Background Mode、Background Fetch、Silent Push、PushKit、Background Task。

  • 使用 Background Mode 方式的话,App Store 在审核时会提高对 App 的要求。通常情况下,只有那些地图、音乐播放、VoIP 类的 App 才能通过审核。
  • Background Fetch 方式的唤醒时间不稳定,而且用户可以在系统里设置关闭这种方式,导致它的使用场景很少。
  • Silent Push 是推送的一种,会在后台唤起 App 30 秒。它的优先级很低,会调用 application:didReceiveRemoteNotifiacation:fetchCompletionHandler:这个 delegate,和普通的 remote push notification 推送调用的 delegate 是一样的。
  • PushKit 后台唤醒 App 后能够保活 30 秒。它主要用于提升 VoIP 应用的体验。
  • Background Task 方式,是使用最多的。App 退后台后,默认都会使用这种方式。
Background Task

在程序退到后台以后,只有几秒钟的时间可以执行代码,接下来就会被系统挂起。

进程挂起后所有线程都会暂停,不管这个线程是文件读写还是内存读写都会被暂停。但是,数据读写过程无法暂停只能被中断,中断时数据读写异常而且容易损坏文件,所以系统会选择主动杀掉 App 进程。

而 Background Task ,就是系统提供了 beginBackgroundTaskWithExpirationHandler 方法来延长后台执行时间,退后台后还可以处理一些需求。

- (void)applicationDidEnterBackground:(UIApplication *)application {
    self.backgroundTaskIdentifier = [application beginBackgroundTaskWithExpirationHandler:^( void) {
        [self yourTask];
    }];
}

在这段代码中,yourTask 任务会再执行 一段时间,之后你的 App 就会挂起。 如果 yourTask 没有执行完的话,系统会强制杀掉进程,从而造成崩溃,这就是为什么 App 退后台容易出现崩溃的原因。后台崩溃造成的影响是未知的。持久化存储的数据出现了问题,就会造成你的 App 无法正常使用。

如何避免后台崩溃呢?

App 退后台后,如果执行时间过长就会导致被系统杀掉。如果我们要想避免这种崩溃, 需要严格控制后台数据的读写操作。比如,你可以先判断需要处理的数据的大小,如果数据过大,也就是在后台限制时间内或延长后台执行时间后也处理不完的话,可以考虑在程序下次启动或后台唤醒时再进行处理。

同时,App 退后台后,这种由于在规定时间内没有处理完而被系统强制杀掉的崩溃,是无法通过信号被捕获到的。这也说明了,随着团队规模扩大,要想保证 App 高可用的话,后台崩溃的监控就尤为重要了。

收集退后台后超过保活阈值而导致信号捕获不到的那些崩溃信息

采用 Background Task 方式时,我们可以根据 beginBackgroundTaskWithExpirationHandler 会让后台保活 3 分钟这个阈值(iOS13之后,时间没有这么久了),先设置一个计时器,在接近 3 分钟时判断后台程序是否还在执行。如果还在执行的话,我们就可以判断该程序即将后台崩溃,进行上报、记录,以达到监控的效果。

其他无法被信号捕获到的崩溃情况

其他捕获不到的崩溃情况还有很多,主要就是内存打爆和主线程卡顿时间超过阈值被 watchdog 杀掉这两种情况。

监控这两类崩溃的思路和监控后台崩溃类似,先要找到它们的阈值,然后在临近阈值时还在执行的后台程序,判断为将要崩溃,收集信息并上报。

对于内存打爆信息的收集,你可以采用内存映射(mmap)的方式来保存现场。主线程卡顿时间超过阈值这种情况,你只要收集当前线程的堆栈信息就可以了。

分析崩溃信息并解决崩溃问题

我们采集到的崩溃日志,主要包含的信息为:进程信息、基本信息、异常信息、线程回溯。

  • 进程信息:崩溃进程的相关信息,比如崩溃报告唯一标识符、唯一键值、设备标识;
  • 基本信息:崩溃发生的日期、iOS 版本;
  • 异常信息:异常类型、异常编码、异常的线程;
  • 线程回溯:崩溃时的方法调用栈。

通常情况下,我们分析崩溃日志时最先看的是异常信息,分析出问题的是哪个线程,在线程回溯里找到那个线程;然后,分析方法调用栈,符号化后的方法调用栈可以完整地看到方法调用的过程,从而知道问题发生在哪个方法的调用上。

一些被系统杀掉的情况,可以通过异常编码来分析。完整的异常编码。常见的就是如下三种:

  • 0x8badf00d,表示 App 在一定时间内无响应而被 watchdog 杀掉的情况。
  • 0xdeadfa11,表示 App 被用户强制退出。
  • 0xc00010ff,表示 App 因为运行造成设备温度太高而被杀掉。

0x8badf00d 这种情况是出现最多的。当出现被 watchdog 杀掉的情况时,我们就可以把范围控制在主线程被卡的情况。

0xdeadfa11 的情况,是用户的主动行为,我们不用太关注。

0xc00010ff 这种情况,就要对每个线程 CPU 进行针对性的检查和优化。

参考链接:

https://www.jianshu.com/p/59927211b745

https://time.geekbang.org/column/article/88600

你可能感兴趣的:(监控 iOS 崩溃)