如何去衡量一款應(yīng)用的質(zhì)量好壞?為了回答這一問題,APM
這一目的性極強(qiáng)的工具向開發(fā)順應(yīng)而生。最早的APM
開發(fā)只關(guān)注于crash
、cpu
這類的硬性指標(biāo)。而隨著移動(dòng)開發(fā)市場(chǎng)的成熟,越來越多的數(shù)據(jù)指標(biāo)也被加入到了APM
的采集范疇中,包括感官體驗(yàn)相關(guān)的數(shù)據(jù)和使用習(xí)慣等。
然而,無論APM
最終如何發(fā)展,其最核心的采集指標(biāo)一定是crash
數(shù)據(jù)。一套完善的crash
監(jiān)控方案可以快速的發(fā)現(xiàn)并協(xié)助完成問題定位,從而能夠及時(shí)止損,避免更多的損失。而反過來說,如果crash
不能及時(shí)被發(fā)現(xiàn),又或者因?yàn)椴杉溨谐霈F(xiàn)異常導(dǎo)致了數(shù)據(jù)丟失,對(duì)于開發(fā)者和公司來說,這都會(huì)是一個(gè)噩夢(mèng)。
crash采集
細(xì)分之下,crash
分別存在mach exception
、signal
以及NSException
三種類型,每一種類型表示不同分層上的crash
,也擁有各自的捕獲方式。
-
mach exception
mach異常
由處理器陷阱引發(fā),在異常發(fā)生后會(huì)被異常處理程序轉(zhuǎn)換成Mach消息
,接著依次投遞到thread
、task
和host
端口。如果沒有一個(gè)端口處理這個(gè)異常并返回KERN_SUCCESS
,那么應(yīng)用將被終止。每個(gè)端口擁有一個(gè)異常端口數(shù)組
,系統(tǒng)暴露了后綴為_set_exception_ports
的多個(gè)API
讓我們注冊(cè)對(duì)應(yīng)的異常處理到端口中。mach異常
即便注冊(cè)了對(duì)應(yīng)的處理,也不會(huì)導(dǎo)致影響原有的投遞流程。此外,即便不去注冊(cè)mach異常
的處理,最終經(jīng)過一系列的處理,mach異常
會(huì)被轉(zhuǎn)換成對(duì)應(yīng)的UNIX信號(hào)
,一種mach異常
對(duì)應(yīng)了一個(gè)或者多個(gè)信號(hào)類型。因此在捕獲crash
要提防二次采集的可能。 -
NSException
NSException
發(fā)生在CoreFoundation
以及更高抽象層,在CoreFoundation
層操作發(fā)生異常時(shí),會(huì)通過__cxa_throw
函數(shù)拋出異常。在通過NSSetUncaughtExceptionHandler
注冊(cè)NSException
的捕獲函數(shù)之后,崩潰發(fā)生時(shí)會(huì)調(diào)用這個(gè)捕獲函數(shù)。但如果沒有任何函數(shù)去捕獲這個(gè)異常如果在捕獲函數(shù)中沒有進(jìn)行操作終止應(yīng)用,最終異常會(huì)通過abort()
來拋出一個(gè)SIGABRT
信號(hào)。由于
NSException
的抽象層次足夠高,相比較其他的crash
類型,NSException
是可以被人為的阻止crash
的。比如@try-catch
機(jī)制能夠捕獲塊中發(fā)生的異常,避免應(yīng)用被殺死。但由于try-catch
的開銷和回報(bào)不成正比,往往不會(huì)使用這種機(jī)制。其二是crash防護(hù)
,這一手段通過hook
掉上層接口來規(guī)避crash
風(fēng)險(xiǎn),但是只建議用于線上防護(hù),而且hook
未必不會(huì)導(dǎo)致其他的問題。 -
signal
signa
會(huì)導(dǎo)致crash
,這是多數(shù)iOS
開發(fā)者對(duì)于信號(hào)的印象。傳遞crash
信息其實(shí)只是信號(hào)的一部分功能,信號(hào)是一套基于POSIX標(biāo)準(zhǔn)
開發(fā)的通信機(jī)制,具體可以閱讀Signal-wikipedia。在signal.h
中聲明了32
種異常信號(hào),下面列出一部分的信號(hào)異常對(duì):信號(hào) 異常 SIGILL 執(zhí)行了非法指令,一般是可執(zhí)行文件出現(xiàn)了錯(cuò)誤 SIGTRAP 斷點(diǎn)指令或者其他trap指令產(chǎn)生 SIGABRT 調(diào)用abort產(chǎn)生 SIGBUS 非法地址。比如錯(cuò)誤的內(nèi)存類型訪問、內(nèi)存地址對(duì)齊等 SIGSEGV 非法地址。訪問未分配內(nèi)存、寫入沒有寫權(quán)限的內(nèi)存等 SIGFPE 致命的算術(shù)運(yùn)算。比如數(shù)值溢出、NaN數(shù)值等 雖然存在三種
crash
,但由于mach exception
會(huì)在BSD
層被轉(zhuǎn)換成UNIX信號(hào)
,NSException
在未被捕獲的情況下會(huì)調(diào)用abort
拋出信號(hào),因此即便是我們只注冊(cè)了signal
的處理,只要注冊(cè)的signal
足夠多,理論上也是能捕獲到全部的crash
。
采集沖突
由于crash
的捕獲機(jī)制只會(huì)保存最后一個(gè)注冊(cè)的handle
,因此如果項(xiàng)目中殘留或者存在另外的第三方框架采集crash
信息時(shí),經(jīng)常性的會(huì)存在沖突。解決沖突的做法是在注冊(cè)自己的handle
之前保存已注冊(cè)的處理函數(shù),便于發(fā)生崩潰后能將crash
信息連續(xù)的傳遞下去。
struct sigaction my_action;
static struct sigaction registered_action;
static NSUncaughtExceptionHandler *previousHandle;
void signal_handler(int signal) {
......
}
void exception_handler(NSException *exception) {
......
}
void registerCrashHandle() {
previousHandle = NSGetUncaughtExceptionHandler();
NSSetUncaughtExceptionHandler(&exception_handler);
myAction.sa_handler = &signal_handler;
sigemptyset(&my_action.sa_mask);
sigaction(SIGABRT, &my_action, ®istered_action);
}
一般來說,一個(gè)經(jīng)驗(yàn)豐富的開發(fā)者在注冊(cè)crash
回調(diào)時(shí)都會(huì)主動(dòng)的去保存其他函數(shù),避免因?yàn)闆_突導(dǎo)致別人的數(shù)據(jù)丟失。但是即便按照這樣的方式來注冊(cè)你的回調(diào),也不代表我們的處理函數(shù)是安全的。最重要的原因在于完成回調(diào)的注冊(cè)之后,我們無法保證后續(xù)會(huì)不會(huì)有其他人繼續(xù)注冊(cè),如果有就會(huì)存在被替換掉的風(fēng)險(xiǎn)
解決方案
按照正常方式的做法,能保證先于我們注冊(cè)的crash
回調(diào)不會(huì)被我們攔截導(dǎo)致失敗,但如果在我們后方存在另外的注冊(cè),我們需要一個(gè)有效的機(jī)制來保護(hù)我們的采集數(shù)據(jù)。解決問題的收益是不變的,所以解決方案理當(dāng)盡可能的低開銷和低風(fēng)險(xiǎn)。
如何去判斷我們的handle
是否安全?這要求我們對(duì)已注冊(cè)的handle
進(jìn)行檢測(cè)。首先檢測(cè)時(shí)機(jī)要選擇在哪?由于crash
是可能發(fā)生在應(yīng)用啟動(dòng)階段的,因此crash
采集一般也是發(fā)生在didLaunch
這個(gè)時(shí)間,下圖是我繪制的應(yīng)用啟動(dòng)到完全啟動(dòng)的幾個(gè)重要階段:
applicationActive
這個(gè)階段基本上是能保證crash
相關(guān)的注冊(cè)都完成的,因此沖突檢測(cè)可以放到這個(gè)階段進(jìn)行。
周期性檢測(cè)
利用已有的周期性機(jī)制或者使用定時(shí)器來進(jìn)行handle
沖突檢測(cè)。可以分別使用通知
和定時(shí)器
兩個(gè)機(jī)制來完成周期性檢測(cè)方案
-
監(jiān)聽?wèi)?yīng)用狀態(tài)
監(jiān)聽
UIApplicationDidBecomeActiveNotification
在應(yīng)用進(jìn)入活躍狀態(tài)時(shí)做檢測(cè):- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { ...... [[NSNotificationCenter defaultCenter] addObserver: [SignalHandler sharedHandler] selector: @selector(checkRegisterCrashHandler) name: UIApplicationDidBecomeActiveNotification object: nil]; ...... } static struct sigaction existActions[32]; static int fatal_signals[] = { SIGILL, SIGBUS, SIGABRT, SIGPIPE, }; - (void)checkRegisterCrashHandler { struct sigaction oldAction; for (int idx = 0; idx < sizeof(fatal_signals) / sizeof(int); idx++) { sigaction(fatal_signals[idx], NULL, &oldAction); if (oldAction.sa_handler != &signal_handler) { existActions[fatal_signals[idx]] = oldAction; struct sigaction myAction; myAction.sa_handler = &signal_handler; sigemptyset(&myAction.sa_mask); sigaction(SIGABRT, &myAction, NULL); } } }
-
定時(shí)器檢測(cè)
創(chuàng)建定時(shí)器來進(jìn)行周期性的檢測(cè),相比通知的機(jī)制,可以控制檢測(cè)間隔:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { ...... NSTimer *timer = [[NSTimer alloc] initWithFireDate: [NSDate date] interval: 30 target: [SignalHandler sharedHandler] selector: @selector(checkRegisterCrashHandler) userInfo: nil repeats: YES]; [[NSRunLoop currentRunLoop] addTimer: timer forMode: NSRunLoopCommonModes]; [timer fire]; ...... }
hook注冊(cè)函數(shù)
通過hook
調(diào)用注冊(cè)handle
的對(duì)應(yīng)函數(shù),建立一個(gè)回調(diào)數(shù)組來保存非exception_handle
的所有回調(diào),后續(xù)處理完我們的采集,再逐個(gè)調(diào)起。由于捕獲函數(shù)都是基于C
接口的,因此我們需要fishhook來提供相應(yīng)的hook
功能。
struct SignalHandler {
void (*signal_handler)(int);
struct SignalHandler *next;
}
struct SignalHandler *previousHandlers[32];
void append(struct SignalHandler *handlers, struct SignalHandler *node) {
......
}
static int (*origin_sigaction)(int, const struct sigaction *__restrict, struct sigaction * __restrict) = NULL;
int custom_sigaction(int signal, const struct sigaction *__restrict new_action, struct sigaction * __restrict old_action) {
if (new_action.sa_handler != signal_handler) {
append(previousHandlers[signal], new_action);
return origin_sigaction(signal, NULL, old_action);
} else {
return origin_sigaction(signal, new_action, old_action);
}
}
風(fēng)險(xiǎn)
在周期性檢測(cè)的方案下,假設(shè)存在handle
注冊(cè)鏈(依次從左到右):
previous
<- exception_handle
<- other
在檢測(cè)時(shí)發(fā)現(xiàn)當(dāng)前回調(diào)是other
,于是重新注冊(cè)我們的回調(diào),保存other
。但是假如other
也保存了我們的回調(diào),這樣可能會(huì)導(dǎo)致崩潰發(fā)生的時(shí)候,調(diào)用順序變成一個(gè)死循環(huán)。
hook
方案則是因?yàn)樵谡{(diào)用origin_sigaction
時(shí)會(huì)傳入old_action
,可能導(dǎo)致另外的注冊(cè)者保存了我們的exception_handle
,并在最后處理的時(shí)候出現(xiàn)同樣的循環(huán)調(diào)用問題。對(duì)于hook
方案來說,解決方法要簡單很多,只需要在非我們的注冊(cè)調(diào)用origin_sigaction
時(shí)不傳入old_action
就能保證其他注冊(cè)者無法獲取到我們的回調(diào):
int custom_sigaction(int signal, const struct sigaction *__restrict new_action, struct sigaction * __restrict old_action) {
if (new_action.sa_handler != signal_handler) {
append(previousHandlers[signal], new_action);
return origin_sigaction(signal, NULL, NULL);
} else {
return origin_sigaction(signal, new_action, old_action);
}
}
而使用周期性監(jiān)測(cè),就需要考慮是否放棄other
的回調(diào),最終只保證exception_handle
和previous
和更早之前的注冊(cè)能夠被順利調(diào)起。
另外,hook
還存在一個(gè)風(fēng)險(xiǎn)是假如第三方同樣做了hook
掉注冊(cè)函數(shù)的處理,并且做了篩選處理,最終導(dǎo)致的結(jié)果是沒辦法完成任何一個(gè)注冊(cè)。兩害相較取其輕,個(gè)人的建議是使用周期性檢測(cè)方案。
最簡單的方式
上述的兩套方案都存在風(fēng)險(xiǎn)點(diǎn),而且這些風(fēng)險(xiǎn)點(diǎn)對(duì)于應(yīng)用來說都算是致命的。那么有沒有幾乎沒有風(fēng)險(xiǎn)又能解決問題的辦法呢?答案是肯定的,那就是不要用有潛在風(fēng)險(xiǎn)的第三方,或者和第三方開發(fā)者商量提供一個(gè)無需crash
采集的版本。
在應(yīng)用發(fā)生崩潰的時(shí)候,此時(shí)的崩潰所在線程
是極不穩(wěn)定的,不穩(wěn)定性包括幾點(diǎn):
-
內(nèi)存不穩(wěn)定
如果是內(nèi)存相關(guān)錯(cuò)誤引發(fā)的
crash
,比如內(nèi)存過載、野指針等,此時(shí)線程的內(nèi)存是危險(xiǎn)狀態(tài)。如果這時(shí)候在handle
中再次分配內(nèi)存,極有可能導(dǎo)致二次crash
-
死鎖
大多數(shù)底層的的核心
API
會(huì)涉及到加鎖處理,這一情況在signal
錯(cuò)誤中出現(xiàn)的較多。而作為上層調(diào)用方的我們是不自知的,此時(shí)錯(cuò)誤的操作可能導(dǎo)致線程陷入死鎖狀態(tài)
理論上當(dāng)我們攔截了一個(gè)signal
的時(shí)候,此時(shí)的應(yīng)用會(huì)陷入內(nèi)核并停止工作,應(yīng)用頁面卡死,這時(shí)候我們可執(zhí)行時(shí)長是無限的。如果處理鏈過長,耗時(shí)過多或者陷入某種循環(huán),會(huì)造成一種應(yīng)用卡死而非崩潰的錯(cuò)覺,而經(jīng)過我廠大量的統(tǒng)計(jì),應(yīng)用卡死
要比應(yīng)用崩潰
更讓人難以接受。此外,過多的處理鏈會(huì)增加回調(diào)流程上的風(fēng)險(xiǎn)點(diǎn)。如果鏈條上的某個(gè)點(diǎn)發(fā)生了二次崩潰,會(huì)導(dǎo)致后續(xù)的處理都無法執(zhí)行。因此,不用第三方或者讓第三方去除crash
采集,是一種可行且高效的手段。
其他
文中提到過一次現(xiàn)在比較流行的crash防護(hù)
手段,這里還是想說兩句。在開發(fā)中,crash防護(hù)
會(huì)造成依賴心理,降低對(duì)風(fēng)險(xiǎn)的敏感。而在線上,這種方案可能屏蔽了大量的低級(jí)錯(cuò)誤,也是讓我不能容忍的,當(dāng)然循環(huán)引用的防護(hù)屬于例外。最后安利一波寒神的XXShield,除了容器類的防crash
都值得學(xué)習(xí),尤其是正確的method swizzling
姿勢(shì)。