題外話:近來工作閑暇之余把以前看的網(wǎng)易大神寫的crash防護手動實現(xiàn)了。紙上得來終覺淺,絕知此事要躬行。記錄一下思路,大部分還是參考大神的經(jīng)驗??蚣軆?nèi)部可接入日志上報系統(tǒng),結(jié)合服務(wù)端進行收集。
Baymax:網(wǎng)易iOS App運行時Crash自動防護實踐
自己實現(xiàn)的OCShield
代碼捕獲crash
Crash一般產(chǎn)生自 iOS 的微內(nèi)核 Mach,然后在 BSD 層轉(zhuǎn)換成 UNIX SIGABRT 信號,以標(biāo)準(zhǔn) POSIX 信號的形式提供給用戶。NSException 是使用者在處理 App 邏輯時,用編程的方法拋出。

crash的捕獲的方式
1.Mach 異常與 Unix 信號
Mach 異常捕獲?;贛ach內(nèi)核編程,需要對內(nèi)核有一定了解。
Unix 信號捕獲。對于Mach 異常,操作系統(tǒng)會將其轉(zhuǎn)換為對應(yīng)的 Unix信號,可以通過注冊signalHandler的方式來做信號異常。
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x041a6f3
Mach 異常是什么?它又是如何與 Unix 信號建立聯(lián)系的?
Mach 是一個 XNU 的微內(nèi)核核心,Mach 異常是指最底層的內(nèi)核級異常,被定義在 <mach/exception_types.h>下 。每個 thread,task,host 都有一個異常端口數(shù)組,Mach 的部分 API 暴露給了用戶態(tài),用戶態(tài)的開發(fā)者可以直接通過 Mach API 設(shè)置 thread,task,host 的異常端口,來捕獲 Mach 異常,抓取 Crash 事件。
所有 Mach 異常都在 host 層被ux_exception轉(zhuǎn)換為相應(yīng)的 Unix 信號,并通過threadsignal將信號投遞到出錯的線程。iOS 中的 POSIX API 就是通過 Mach 之上的 BSD 層實現(xiàn)的。
因此,EXC_BAD_ACCESS (SIGSEGV)表示的意思是:Mach 層的EXC_BAD_ACCESS異常,在 host 層被轉(zhuǎn)換成 SIGSEGV 信號投遞到出錯的線程。既然最終以信號的方式投遞到出錯的線程,那么就可以通過注冊 signalHandler 來捕獲信號:
signal(SIGSEGV,signalHandler);
捕獲 Mach 異?;蛘?Unix 信號都可以抓到 crash 事件,這兩種方式哪個更好呢?優(yōu)選 Mach 異常,因為 Mach 異常處理會先于 Unix 信號處理發(fā)生,如果 Mach 異常的 handler 讓程序 exit 了,那么 Unix 信號就永遠不會到達這個進程了。轉(zhuǎn)換 Unix 信號是為了兼容更為流行的 POSIX 標(biāo)準(zhǔn) (SUS 規(guī)范),這樣不必了解 Mach 內(nèi)核也可以通過 Unix 信號的方式來兼容開發(fā)。
因為硬件產(chǎn)生的信號 (通過 CPU 陷阱) 被 Mach 層捕獲,然后才轉(zhuǎn)換為對應(yīng)的 Unix 信號;蘋果為了統(tǒng)一機制,于是操作系統(tǒng)和用戶產(chǎn)生的信號 (通過調(diào)用kill和pthread_kill) 也首先沉下來被轉(zhuǎn)換為 Mach 異常,再轉(zhuǎn)換為 Unix 信號。
signal(SIGABRT, SignalExceptionHandler)
2.NSException 捕獲。
應(yīng)用層,通過 NSUncaughtExceptionHandler捕獲,因為堆棧中不會有出錯代碼,所以需要獲取NSException對象中的reason、name、callStackSymbols。然后把細節(jié)寫入Crash日志,上傳到后臺做數(shù)據(jù)分析。
NSSetUncaughtExceptionHandler(UncaughtExceptionHandler) //程序啟動代理方法
void UncaughtExceptionHandler(NSException *exception) {
NSArray *callStack = [exception callStackSymbols];
NSString *reason = [exception reason];
NSString *name = [exception name];
NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
[formatter setDateFormat:@"YYYY-MM-dd HH:mm:ss"];
NSString * dateStr = [formatter stringFromDate:[NSDate date]];
NSString * iOS_Version = [[UIDevice currentDevice] systemVersion];
NSString * PhoneSize = NSStringFromCGSize([[UIScreen mainScreen] bounds].size);
NSString * App_Version = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"];
NSString * iPhoneType = @"當(dāng)前設(shè)備名字";
NSString *uploadString = @"所有拼接信息";
// 存儲到本地沙盒.下次啟動找尋
}
iOS的Crash分類
1.unrecognized selector crash【實現(xiàn)】
2.KVO/KVC crash【實現(xiàn)】
3.NSNotification crash
4.NSTimer crash【實現(xiàn)】
5.Container crash(數(shù)組越界,插nil等)【實現(xiàn)】
6.NSString crash (字符串操作的crash)【實現(xiàn)】
7.Bad Access crash (野指針)【實現(xiàn)】
8.UI not on Main Thread Crash (非主線程刷UI(機制待改善))
Unrecognized Selector
調(diào)用方法時會轉(zhuǎn)換成objc_msgSend()函數(shù)調(diào)用。
1.首先,在相應(yīng)操作的對象中的緩存方法列表中找調(diào)用的方法,如果找到,轉(zhuǎn)向相應(yīng)實現(xiàn)并執(zhí)行。
2.如果沒找到,在相應(yīng)操作的對象中的方法列表中找調(diào)用的方法,如果找到,轉(zhuǎn)向相應(yīng)實現(xiàn)執(zhí)行。
3.如果沒找到,去父類指針?biāo)赶虻膶ο笾袌?zhí)行1,2。
4.以此類推,如果一直到根類還沒找到,轉(zhuǎn)向攔截調(diào)用,走消息轉(zhuǎn)發(fā)機制。
5.如果沒有重寫攔截調(diào)用的方法,程序報錯。
消息轉(zhuǎn)發(fā)流程
1.調(diào)用resolveInstanceMethod給個機會讓類添加這個實現(xiàn)這個函數(shù)。
2.調(diào)用forwardingTargetForSelector讓別的對象去執(zhí)行這個函數(shù)。
3.調(diào)用forwardInvocation(函數(shù)執(zhí)行器)靈活的將目標(biāo)函數(shù)以其他形式執(zhí)行。
基于此,我選擇2、3都去實現(xiàn)對比方案。
方案一:重寫NSObject的forwardingTargetForSelector方法。雖然不會造成NSInvocation對象的開銷,但是會攔截到系統(tǒng)的其他方法,導(dǎo)致該方法調(diào)用多次問題。
1.動態(tài)創(chuàng)建一個樁類。
2.動態(tài)為樁類添加對應(yīng)的Selector,用一個通用的返回0的函數(shù)來實現(xiàn)該SEL的IMP。
3.將消息直接轉(zhuǎn)發(fā)到這個樁類對象上。

方案二:與方案一思路類似,hook NSObject的methodSignatureForSelector和forwardInvocation,雖然頻繁創(chuàng)建NSInvocation對象,但是到了這里已經(jīng)過濾掉系統(tǒng)的方法。
KVO類型
kvo一般crash原因是
1.KVO的被觀察者dealloc時仍然注冊著KVO。
2.添加KVO重復(fù)添加觀察者或重復(fù)移除觀察者(KVO注冊觀察者與移除觀察者不匹配)。
基于管理混亂問題,可以讓被觀察對象持有一個KVO的delegate,所有和KVO相關(guān)的操作均通過delegate來進行管理,delegate通過建立一張map來維護KVO整個關(guān)系。

hook addObserver:方法

通過上面的流程,將observerd對象的所有kvo相關(guān)的observer信息全部轉(zhuǎn)移到KVOdelegate上,并且避免了相同kvoinfo被重復(fù)添加多次的可能性。
hook removeObserver:方法

移除一個keypath的Observer時,當(dāng)delegate的kvoInfoMap中找不到key為該keypath的時候,說明此時delegate并沒有持有對應(yīng)keypath的observer,即說明移除了一個不匹配的觀察者,此時如果再繼續(xù)操作會導(dǎo)致app崩潰,所以應(yīng)該及時中斷流程,然后統(tǒng)計異常信息。
當(dāng)keypath對應(yīng)的KVOInfo列表(infoArray)為空的時候,說明此時delegate已經(jīng)不再持有任何和keypath相關(guān)的observer了。這時應(yīng)該調(diào)用原有removeObserver的方法將delegate對應(yīng)的觀察者移除。
注意到在檢查遍歷infoArray的時侯,除了要刪除對應(yīng)的info信息,還多了一步檢查info.observer == nil的過程,是因為如果observer為nil,那么此時如果keypath對應(yīng)的值變化的話,也會因為找不到observer而崩潰,所以需要做這一步來阻止該種情況的發(fā)生。
hook observeValueForKeyPath:方法

delegate對于
observeValueForKeyPath方法的修改最主要的地方是,在于將對應(yīng)的響應(yīng)方法轉(zhuǎn)移給真正的KVO Observer,通過keyInfoMap找到keypath對應(yīng)的KVOInfo里面預(yù)先存儲好的observer,然后調(diào)用observer原本的響應(yīng)方法。同時在遍歷InfoArray的時候,發(fā)現(xiàn)info.observerw == nil的時候,需要及時將其清除掉,避免KVO的觀察者observer被釋放后value變化導(dǎo)致的crash.
最后,針對 KVO的被觀察者dealloc時仍然注冊著KVO導(dǎo)致的crash 的情況,可以將NSObject的dealloc swizzle, 在object dealloc的時候自動將其對應(yīng)的kvodelegate所有和kvo相關(guān)的數(shù)據(jù)清空,然后將kvodelegate也置空。避免出現(xiàn)KVO的被觀察者dealloc時仍然注冊著KVO而產(chǎn)生的crash。
KVC類型
hook常用的方法,用Try catch方式守護。

NSNotification類型
主要針對iOS9系統(tǒng)之前不移除通知。蘋果在iOS9之后專門針對于這種情況做了處理,所以在iOS9之后,即使開發(fā)者沒有移除observer,Notification crash也不會再產(chǎn)生了。
hook NSObject的dealloc函數(shù),在對象真正dealloc之前先調(diào)用一下
[[NSNotificationCenter defaultCenter] removeObserver:self]即可。
注意到并不是所有的對象都需要做以上的操作,如果一個對象從來沒有被NSNotificationCenter 添加為observer的話,在其dealloc之前調(diào)用removeObserver完全是多此一舉。 所以我們hook了NSNotificationCenter的addObserver:(id)observer selector:(SEL)aSelector name:(NSString *)aName object:(id)anObject函數(shù),在其添加observer的時候,對observer動態(tài)添加標(biāo)記flag。這樣在observer dealloc的時候,就可以通過flag標(biāo)記來判斷其是否有必要調(diào)用removeObserver函數(shù)了。
NSTimer類型
使用NSTimer的scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:接口做重復(fù)性的定時任務(wù)時存在一個問題:NSTimer會強引用target實例,所以需要在合適的時機invalidate定時器,否則就會由于定時器timer強引用target的關(guān)系導(dǎo)致target不能被釋放,造成內(nèi)存泄露,甚至在定時任務(wù)觸發(fā)時導(dǎo)致crash。 crash的展現(xiàn)形式和具體的target執(zhí)行的selector有關(guān)。與此同時,如果NSTimer是無限重復(fù)的執(zhí)行一個任務(wù)的話,也有可能導(dǎo)致target的selector一直被重復(fù)調(diào)用且處于無效狀態(tài),對app的CPU,內(nèi)存等性能方面均是沒有必要的浪費。

swizzle NSTimer的scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:相關(guān)的方法

在新方法中動態(tài)創(chuàng)建stubTarget對象,stubTarget對象弱引用持有原有的target,selector,timer,targetClass等properties。然后將原target分發(fā)stubTarget上,selector回調(diào)函數(shù)為stubTarget的fireProxyTimer。
通過stubTarget的fireProxyTimer:來具體處理回調(diào)函數(shù)selector的處理和分發(fā)

當(dāng)NSTimer的回調(diào)函數(shù)
fireProxyTimer:被執(zhí)行的時候,會自動判斷原target是否已經(jīng)被釋放,如果釋放了,意味著NSTimer已經(jīng)無效,此時如果還繼續(xù)調(diào)用原有target的selector很有可能會導(dǎo)致crash,而且是沒有必要的。所以此時需要將NSTimer invalidate,然后統(tǒng)計上報錯誤數(shù)據(jù)。如此一來就做到了NSTimer在合適的時機自動invalidate。
Container類型
針對于NSArray/NSMutableArray/NSDictionary/NSMutableDictionary/NSCache的一些常用的會導(dǎo)致崩潰的API進行method swizzling,然后在swizzle的新方法中加入一些條件限制和判斷,從而讓這些API變的安全。
NSString類型
NSString/NSMutableString 類型的crash的產(chǎn)生原因和防護方案與Container crash很相像。
野指針類型
method swizzling替換NSObject的allocWithZone方法
在新的方法中判斷該類型對象是否需要加入野指針防護,如果需要,則通過objc_setAssociatedObject為該對象設(shè)置flag標(biāo)記,被標(biāo)記的對象后續(xù)會進入zombie流程。

做flag標(biāo)記是因為很多系統(tǒng)類,比如NSString,UIView等創(chuàng)建,釋放非常頻繁,而這些實例發(fā)生野指針概率非常低?;径际俏覀冏约簩懙念惒艜幸爸羔樀南嚓P(guān)問題,所以通過在創(chuàng)建時,設(shè)置一個標(biāo)記用來過濾不必要做野指針防護的實例,提高方案的效率。
同時做判斷是否要加入標(biāo)記的條件里面,我們加入了黑名單機制,是因為一些特定的類是不適用于添加到zombie機制的,會發(fā)生崩潰(例如:NSBundle),而且所以和zombie機制相關(guān)的類也不能加入標(biāo)記,否則會在釋放過程中循環(huán)引用和調(diào)用,導(dǎo)致內(nèi)存泄漏甚至棧溢出。
method swizzling替換NSObject的dealloc方法
對flag標(biāo)記的對象實例調(diào)用objc_destructInstance,釋放該實例引用的相關(guān)屬性,然后將實例的isa修改為ShieldZombieObject。通過objc_setAssociatedObject 保存將原始類名保存在該實例中。

dealloc最后會調(diào)到objectdispose函數(shù),在這個函數(shù)里面其實也做了三件事情。
1.調(diào)用objc_destructInstance釋放該實例引用的相關(guān)實例。
2.將該實例的isa修改為stubClass,接受任意方法調(diào)用。
3.釋放該內(nèi)存。
在ShieldZombieSub 通過消息轉(zhuǎn)發(fā)機制forwardingTargetForSelector處理所有攔截的方法
根據(jù)selector動態(tài)添加能夠處理方法的響應(yīng)者ShieldZombieSub 實例,然后通過 objc_getAssociatedObject 獲取之前保存該實例對應(yīng)的原始類名,統(tǒng)計錯誤數(shù)據(jù)。
當(dāng)退到后臺或者達到未釋放實例的上限時,則調(diào)用free函數(shù)釋被引用zombie化的實例。

注:
1.做了野指針防護,通過動態(tài)插入一個空實現(xiàn)的方法來防止出現(xiàn)Crash,但是業(yè)務(wù)層面的表現(xiàn)難以確定,可能會進入業(yè)務(wù)異常的狀態(tài)。需要擬定一下如何展現(xiàn)該問題給用戶的方案。
2.由于做了延時釋放若干實例,對系統(tǒng)總內(nèi)存會產(chǎn)生一定影響,目前將內(nèi)存的緩沖區(qū)開到5M左右,所以應(yīng)該沒有很大的影響,但還是可能潛在一些風(fēng)險。
3.延時釋放實例是根據(jù)相關(guān)功能代碼會聚焦在某一個時間段調(diào)用的假設(shè)前提下,所以野指針的zombie保護機制只能在其實例對象仍然緩存在zombie的緩存機制時才有效,若在實例真正釋放之后,再調(diào)用野指針還是會出現(xiàn)crash,所以不能達到真正防止crash的目的。
據(jù)面試阿里的面試官說可以用計算內(nèi)存堆棧信息的方式,作者表示不理解。
非主線程刷UI類型
- (void)setNeedsLayout;
- (void)setNeedsDisplay;
- (void)setNeedsDisplayInRect:(CGRect)rect;
在這三個方法調(diào)用的時候判斷一下當(dāng)前的線程,如果不是主線程的話,直接利用 dispatch_async(dispatch_get_main_queue(), ^{ //調(diào)用原本方法 });
來將對應(yīng)的刷UI的操作轉(zhuǎn)移到主線程上,同時統(tǒng)計錯誤信息。
但是真正實施了之后,發(fā)現(xiàn)這三個方法并不能完全覆蓋UIView相關(guān)的所有刷UI到操作,但是如果要將全部到UIView的刷UI的方法統(tǒng)計起來并且swizzle,感覺略笨拙而且不高效。