iOS研發助手DoraemonKit技術實現之Crash檢視

WQ_UESTC發表於2019-01-23

一、前言

在日常開發中或者測試過程中,我們的應用可能會出現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異常包括以下幾種:

  • NSInvalidArgumentException(非法引數異常)

這類異常的主要原因是沒有對於引數的合法性進行校驗,最常見的就是傳入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'
複製程式碼
  • NSRangeException(越界異常)

這類異常的主要原因是沒有對於索引進行合法性的檢查,導致索引落在集合資料的合法範圍之外。例如,索引超出陣列的範圍從而導致陣列越界的問題,測試程式碼如下:

    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]'
複製程式碼
  • NSGenericException(通用異常)

這類異常最容易出現在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.'
複製程式碼
  • NSMallocException(記憶體分配異常)

這類異常的主要原因是無法分配足夠的記憶體空間。例如,分配一塊超大的記憶體空間就會導致此類的異常,測試程式碼如下:

    NSMutableData *mData = [[NSMutableData alloc] initWithCapacity:1];
    NSUInteger len = 1844674407370955161;
    [mData increaseLengthBy:len];
複製程式碼

執行後控制檯輸出日誌:

*** Terminating app due to uncaught exception 'NSMallocException', 
reason: 'Failed to grow buffer'
複製程式碼
  • NSFileHandleOperationException(檔案處理異常)

這類異常的主要原因是對檔案進行相關操作時產生了異常,如手機沒有足夠的儲存空間,檔案讀寫許可權問題等。例如,對於一個只有讀許可權的檔案進行寫操作,測試程式碼如下:

    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的控制檯輸出異常的型別、原因以及呼叫堆疊,根據這些資訊我們能夠迅速定位異常的原因並進行修復。

那如果不是在開發過程中,我們應當如何捕獲這些異常的資訊呢?

其實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相關概念

osx_architecture-kernels_drivers
上圖來自於Apple的Mac Technology Overview,對於Kernel and Device Drivers 這一層而言,OS X與iOS架構大體上是一致的。其中,核心部分都是XNU,而Mach就是XNU的微核心核心。

Mach的職責主要是程式和執行緒抽象、虛擬記憶體管理、任務排程、程式間通訊和訊息傳遞機制等。

Mach微核心中有幾個基本的概念:

  • task:擁有一組系統資源的物件,允許thread在其中執行。
  • thread:執行的基本單位,擁有task的上下文,並共享其資源。
  • port:task之間通訊的一組受保護的訊息佇列,task可對任何port傳送/接收資料。
  • message:有型別的資料物件集合,只可以傳送到port。

BSD層則在Mach之上,提供一套可靠且更現代的API,提供了POSIX相容性。

2.2.2 Mach異常與Unix訊號

在瞭解到Mach一些相關概念之後,我們來看下什麼是Mach異常?這裡引用《漫談iOS Crash收集框架》中對於Mach異常的解釋。

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 層實現的。如下圖所示:

iOS研發助手DoraemonKit技術實現之Crash檢視

例如,Exception Type:EXC_BAD_ACCESS (SIGSEGV)表示的意思是:Mach層的EXC_BAD_ACCESS異常,在host層被轉換成SIGSEGV訊號投遞到出錯的執行緒。下圖展示了從Mach異常轉換成Unix訊號的過程:

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>中找到。下面列舉我們所監控的常用訊號以及它們的含義:

  • SIGABRT:呼叫abort函式生成的訊號。
  • SIGBUS:非法地址,包括記憶體地址對齊(alignment)出錯。比如訪問一個四個字長的整數,但其地址不是4的倍數。它與SIGSEGV的區別在於後者是由於對合法儲存地址的非法訪問觸發的(如訪問不屬於自己儲存空間或只讀儲存空間)。
  • SIGFPE:在發生致命的算術運算錯誤時發出。不僅包括浮點運算錯誤,還包括溢位及除數為0等其它所有的算術的錯誤。
  • SIGILL:執行了非法指令。通常是因為可執行檔案本身出現錯誤,或者試圖執行資料段。堆疊溢位時也有可能產生這個訊號。
  • SIGPIPE:管道破裂。這個訊號通常在程式間通訊產生,比如採用FIFO(管道)通訊的兩個程式,讀管道沒開啟或者意外終止就往管道寫,寫程式會收到SIGPIPE訊號。此外用Socket通訊的兩個程式,寫程式在寫Socket的時候,讀程式已經終止。
  • SIGSEGV:試圖訪問未分配給自己的記憶體,或試圖往沒有寫許可權的記憶體地址寫資料。
  • SIGSYS:非法的系統呼叫。
  • SIGTRAP:由斷點指令或其它trap指令產生,由debugger使用。

更多訊號的釋義可以參考《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異常》中的一張圖對之前的內容做一個總結,如下所示:

iOS研發助手DoraemonKit技術實現之Crash檢視

三、 踩過的坑

上面兩節分別介紹瞭如何捕獲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,該訊號是用來立即結束程式的執行,不能被阻塞、處理和忽略。因此,無法對此訊號進行捕獲。 針對記憶體洩露,推薦一款iOS記憶體洩露檢測工具MLeaksFinder:MLeaksFinder

還有一些Crash雖然可以收集,但是日誌中沒有自己的程式碼,定位十分困難。野指標正是如此,針對這種情況,推薦參考《如何定位Obj-C野指標隨機Crash》系列文章:

《如何定位Obj-C野指標隨機Crash(一):先提高野指標Crash率》

《如何定位Obj-C野指標隨機Crash(二):讓非必現Crash變成必現》

《如何定位Obj-C野指標隨機Crash(三):如何讓Crash自報家門》

四、總結

寫這篇文章主要是為了能夠讓大家對於DoraemonKit中Crash檢視工具有一個快速的瞭解。由於時間倉促,個人水平有限,如有錯誤之處歡迎大家批評指正。

目前的Crash檢視只是實現了最基本的功能,後續還需要不斷完善。大家如果有什麼好的想法,或者發現我們的這個專案有bug,歡迎大家去github上提Issues或者直接Pull requests,我們會第一時間處理,也可以加入我們的qq交流群進行交流,也希望我們這個工具集合能在大家的一起努力下,做得更加完善。

如果大家覺得我們這個專案還可以的話,點上一顆star吧。

DoraemonKit專案地址:github.com/didi/Doraem…

相關文章:

iOS研發助手DoraemonKit技術實現(一)

iOS研發助手DoraemonKit技術實現(二)

五、參考文獻

《漫談iOS Crash收集框架》

《iOS異常捕獲》

《iOS內功篇:淺談Crash》

《iOS Mach異常和signal訊號》

《淺談Mach Exceptions》

《iOS監控程式設計之崩潰監控》

《Mach異常》

六、交流群

QQ交流群

相關文章