iOS 崩潰信息收集
最近項(xiàng)目要求收集應(yīng)用使用過程中的崩潰信息,在網(wǎng)上搜索了一番后,了解目前崩潰信息收集有如下幾種途徑:iTunes Connect導(dǎo)出手機(jī)上傳日志、拿到用戶手機(jī)使用 Xcode 導(dǎo)出、使用第三方崩潰收集服務(wù)(如 Bugly、友盟等)。從及時(shí)性和可定制角度來看上面幾種都不符合項(xiàng)目的需求,基于上述需求背景要求必須學(xué)習(xí)手動(dòng)收集崩潰信息。
導(dǎo)致崩潰的問題
導(dǎo)致應(yīng)用崩潰的問題主要有兩種:
- C++語言層面的錯(cuò)誤,比如野指針、除零、內(nèi)存非法訪問等;
- 未捕獲異常(Uncaught Exception),在 iOS 中最常見的就是通過 @throw 拋出的 NSException(常見的錯(cuò)誤,比如數(shù)組訪問越界)
對(duì)于第一種問題,由于 iOS 和 Android 底層系統(tǒng)都是 Unix 或者類 Unix 系統(tǒng),可以采用信號(hào)機(jī)制來捕獲 signal 或 sigaction,通過設(shè)置的回調(diào)函數(shù)來收集信號(hào)的上下文信息。
第二種問題可以通過 NSSetUncaughtExceptionHandler 設(shè)置異常處理回調(diào)函數(shù)來收集異常的調(diào)用堆棧。
收集崩潰的上下文信息
使用 NSUncaughtExceptionHandler 捕獲 NSException
通過 void NSSetUncaughtExceptionHandler(NSUncaughtExceptionHandler *) 函數(shù)設(shè)置異常發(fā)生時(shí)對(duì)應(yīng)的事件處理函數(shù),NSUncaughtExceptionHandler 是一個(gè)函數(shù)指針 typedef void NSUncaughtExceptionHandler(NSException *exception),該函數(shù)指針的入?yún)⑹?NSException,包含該異常的調(diào)用堆棧:
void InstallUncaughtExceptionHandler(void) {
// Backup original handler
g_previousUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();
NSSetUncaughtExceptionHandler(&HandleException);
}
void MyUncaughtExceptionHandler(NSException *exception) {
// 異常的堆棧信息
NSArray *stackArray = [exception callStackSymbols];
// 出現(xiàn)異常的原因
NSString *reason = [exception reason];
// 異常名稱
NSString *name = [exception name];
NSString *exceptionInfo = [NSString stringWithFormat:@"Exception reason:%@\nException name:%@\nException stack:%@",name, reason, stackArray];
NSLog(@"%@", exceptionInfo);
[UncaughtExceptionHandler saveCreash:exceptionInfo];
if (g_previousUncaughtExceptionHandler != NULL) {
g_previousUncaughtExceptionHandler(exception);
}
}
上面是捕獲異常的簡(jiǎn)單示例。
捕獲 Signal 信號(hào)
Signal 信號(hào)是 Unix 系統(tǒng)中一種用于異步通知的機(jī)制。信號(hào)傳遞給進(jìn)程后,在沒有設(shè)置處理函數(shù)的情況下,程序可以指定三種行為:
- 忽略信號(hào),但 SIGKILL 和 SIGSTOP 信號(hào)不可忽略;
- 使用默認(rèn)的處理函數(shù) SIG_DFL,大多數(shù)信號(hào)的默認(rèn)動(dòng)作是終止進(jìn)程;
- 捕獲信號(hào),執(zhí)行用戶定義的函數(shù)。
這里有兩個(gè)特殊的常量:
- SIG_IGN:向內(nèi)核表示忽略此信號(hào)。對(duì)于不能忽略的兩個(gè)信號(hào)SIGKILL和SIGSTOP,調(diào)用時(shí)會(huì)報(bào)錯(cuò);
- SIG_DFL:執(zhí)行該信號(hào)的系統(tǒng)默認(rèn)動(dòng)作.
常用函數(shù):
-
int kill(pid_t pid, int signo)發(fā)送信號(hào)到指定的進(jìn)程 -
int raise(int signo)發(fā)送信號(hào)給自己
Unix 系統(tǒng)中常見信號(hào)有如下幾種:
SIGABRT--程序中止命令中止信號(hào)
SIGALRM--程序超時(shí)信號(hào)
SIGFPE--程序浮點(diǎn)異常信號(hào)
SIGILL--程序非法指令信號(hào)
SIGHUP--程序終端中止信號(hào)
SIGINT--程序鍵盤中斷信號(hào)
SIGKILL--程序結(jié)束接收中止信號(hào)
SIGTERM--程序kill中止信號(hào)
SIGSTOP--程序鍵盤中止信號(hào)
SIGSEGV--程序無效內(nèi)存中止信號(hào)
SIGBUS--程序內(nèi)存字節(jié)未對(duì)齊中止信號(hào)
SIGPIPE--程序Socket發(fā)送失敗中止信號(hào)
會(huì)導(dǎo)致程序被殺掉的有下面幾種,我們只需收集這幾種信號(hào)的上下文信息,就能找到崩潰發(fā)生原因。
SIGABRT,
SIGBUS,
SIGFPE,
SIGILL,
SIGSEGV,
SIGTRAP,
SIGTERM,
SIGKILL,
信號(hào)處理流程分三步:
- 注冊(cè)信號(hào)處理回調(diào)函數(shù);
- 在回調(diào)函數(shù)中收集調(diào)用堆棧信息;
- 恢復(fù)信號(hào)默認(rèn)處理函數(shù);
1.注冊(cè)信號(hào)處理回調(diào)函數(shù)
static int Beacon_errorSignals[] = {
SIGABRT,
SIGBUS,
SIGFPE,
SIGILL,
SIGSEGV,
SIGTRAP,
SIGTERM,
SIGKILL,
};
for (int i = 0; i < Beacon_errorSignalsNum; i++) {
signal(Beacon_errorSignals[i], &SignalExceptionHandler);
}
2.回調(diào)函數(shù)中收集調(diào)用堆棧信息
void SignalExceptionHandler(int sig) {
NSMutableString *mstr = [[NSMutableString alloc] init];
[mstr appendString:@"Stack:\n"];
void *callstack[128];
int i, frames = backtrace(callstack, 128);
char **strs = backtrace_symbols(callstack, frames);
for (i = 0; i <frames; ++i) {
[mstr appendFormat:@"%s\n", strs[i]];
}
[SignalHandler saveCreash:mstr];
free(strs);
}
3.恢復(fù)信號(hào)默認(rèn)處理函數(shù)
但這里會(huì)將信號(hào)不斷的發(fā)向該處理函數(shù),導(dǎo)致應(yīng)用無法正常崩潰,因?yàn)橐话愕南⑻幚頃?huì)向進(jìn)程終結(jié),但是這里沒有,所以還會(huì)有同樣地信號(hào)不斷的發(fā)過來并被處理.所以處理函數(shù)后要終結(jié)該處理函數(shù)的處理,并將其由系統(tǒng)默認(rèn)處理,即:
signal(sig, SIG_DFL);
測(cè)試
完成異常和信號(hào)處理函數(shù)的設(shè)置后,我們需要測(cè)試設(shè)置是否生效,能否正常捕獲到崩潰的堆棧信息。測(cè)試需要注意:信號(hào)時(shí)不能在 debug 環(huán)境下進(jìn)行,系統(tǒng)的 debug 會(huì)優(yōu)先攔截信號(hào)。正確的測(cè)試姿勢(shì),安裝應(yīng)用后關(guān)閉 debug,直接在模擬器中點(diǎn)擊應(yīng)用制造信號(hào)。Exception 測(cè)試可以在 debug 環(huán)境下進(jìn)行。
- (IBAction)buttonClick:(UIButton *)sender {
//1.信號(hào)量
Test *pTest = {1,2};
free(pTest); //導(dǎo)致SIGABRT的錯(cuò)誤,因?yàn)閮?nèi)存中根本就沒有這個(gè)空間,哪來的free,就在棧中的對(duì)象而已
pTest->a = 5;
}
- (IBAction)buttonOCException:(UIButton *)sender {
//2.ios崩潰
NSArray *array= @[@"tom",@"xxx",@"ooo"];
[array objectAtIndex:5];
}
收集后的清理
傳遞 UncaughtExceptionHandler
如果多方通過 NSSetUncaughtExceptionHandler 注冊(cè)異常處理程序,后注冊(cè)的異常處理程序會(huì)覆蓋前一個(gè)注冊(cè)的 handler,導(dǎo)致之前注冊(cè)的日志收集服務(wù)收不到相應(yīng)的 NSException,丟失崩潰堆棧信息。(iOS 系統(tǒng)自帶的 Crash Reporter 不受影響)。
崩潰后友好退出
而對(duì)于有些時(shí)候,在iOS中,在應(yīng)用崩潰后,保持運(yùn)行狀態(tài)而不退出:
CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFArrayRef allModes = CFRunLoopCopyAllModes(runLoop);
while (!dismissed) {
for (NSString *mode in (__bridge NSArray *)allModes) {
CFRunLoopRunInMode((__bridge CFStringRef)mode, 0.001, false);
}
}
CFRelease(allModes);
應(yīng)用以上代碼,可以做到崩潰時(shí)彈框提示應(yīng)用,以讓用戶還是可以正常操作,讓響應(yīng)更加友好.
存在的問題
使用上述方式收集到的堆棧信息只包含錯(cuò)誤線程,其他線程的調(diào)用堆棧無法獲取。而在一些 Signal 的出錯(cuò)信息僅靠崩潰線程的堆棧無法找到原因,需同時(shí)根據(jù)其他線程調(diào)用堆棧來尋找崩潰原因。
目前成熟的開源崩潰日志收集服務(wù)有很多,如 KSCrash,PLCrashReporter,CrashKit 等,使用一番后覺得 PLCrashReporter 更符合項(xiàng)目要求。PL 收集崩潰日志信息和蘋果官方日志兼容,擴(kuò)展性較好,與已有服務(wù)銜接較為簡(jiǎn)單。
集成 PLCrashReporter
去官網(wǎng)下載最新的 release 包,將iOS Framework/CrashReporter.framework 拖進(jìn)工程。在 application:didFinishLaunchingWithOptions 方法中調(diào)用 initCrashMgr 完成 PLCrashReporter 的初始化。
- (void)initCrashMgr {
PLCrashReporter *crashReporter = [PLCrashReporter sharedReporter];
NSError *error;
// Check if we previously crashed
if ([crashReporter hasPendingCrashReport]) {
[self handleCrashReport];
}
// Enable the Crash Reporter
if (![crashReporter enableCrashReporterAndReturnError: &error]) {
ABLog(@"Warning: Could not enable crash reporter: %@", error);
}
}
- (void)handleCrashReport {
PLCrashReporter *crashReporter = [PLCrashReporter sharedReporter];
NSData *crashData;
NSError *error;
// Try loading the crash report
crashData = [crashReporter loadPendingCrashReportDataAndReturnError:&error];
if (crashData == nil) {
ABLog(@"Could not load crash report: %@", error);
[crashReporter purgePendingCrashReport];
return;
}
// We could send the report from here, but we'll just print out some debugging info instead
PLCrashReport *report = [[PLCrashReport alloc] initWithData:crashData error:&error];
if (report == nil) {
ABLog(@"Could not parse crash report");
[crashReporter purgePendingCrashReport];
return;
}
//TODO:send the report
ABLog(@"Crashed on %@", report.systemInfo.timestamp);
ABLog(@"Crashed with signal %@ (code %@, address=0x%" PRIx64 ")", report.signalInfo.name, report.signalInfo.code, report.signalInfo.address);
NSString *humanReadText = [PLCrashReportTextFormatter stringValueForCrashReport:report withTextFormat:PLCrashReportTextFormatiOS];
// 處理收集到的 crash 信息
[self sendCrashReport:humanReadText];
[crashReporter purgePendingCrashReport];
return;
}
PLCrashReporter 收集的 crash 非常全媲美蘋果的收集的日志,簡(jiǎn)單看了下源碼原理和上述思路一致,但一直沒找到它如何解決其他線程的堆棧收集問題,有時(shí)間繼續(xù)研讀下。
參考文章: