題外話:近來工作閑暇之余把以前看的網(wǎng)易大神寫的crash防護(hù)手動實(shí)現(xiàn)了。紙上得來終覺淺崔兴,絕知此事要躬行华烟。記錄一下思路宿稀,大部分還是參考大神的經(jīng)驗(yàn)伴奥∠裨疲框架內(nèi)部可接入日志上報(bào)系統(tǒng)肃叶,結(jié)合服務(wù)端進(jìn)行收集夯辖。
Baymax:網(wǎng)易iOS App運(yùn)行時(shí)Crash自動防護(hù)實(shí)踐
自己實(shí)現(xiàn)的OCShield
代碼捕獲crash
Crash一般產(chǎn)生自 iOS 的微內(nèi)核 Mach蕴茴,然后在 BSD 層轉(zhuǎn)換成 UNIX SIGABRT 信號劝评,以標(biāo)準(zhǔn) POSIX 信號的形式提供給用戶。NSException 是使用者在處理 App 邏輯時(shí)倦淀,用編程的方法拋出蒋畜。
crash的捕獲的方式
1.Mach 異常與 Unix 信號
Mach 異常捕獲∽策矗基于Mach內(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 層實(shí)現(xiàn)的腐宋。
因此,EXC_BAD_ACCESS (SIGSEGV)表示的意思是:Mach 層的EXC_BAD_ACCESS異常檀轨,在 host 層被轉(zhuǎn)換成 SIGSEGV 信號投遞到出錯的線程胸竞。既然最終以信號的方式投遞到出錯的線程,那么就可以通過注冊 signalHandler 來捕獲信號:
signal(SIGSEGV,signalHandler);
捕獲 Mach 異巢翁眩或者 Unix 信號都可以抓到 crash 事件卫枝,這兩種方式哪個更好呢?優(yōu)選 Mach 異常讹挎,因?yàn)?Mach 異常處理會先于 Unix 信號處理發(fā)生校赤,如果 Mach 異常的 handler 讓程序 exit 了,那么 Unix 信號就永遠(yuǎn)不會到達(dá)這個進(jìn)程了筒溃。轉(zhuǎn)換 Unix 信號是為了兼容更為流行的 POSIX 標(biāo)準(zhǔn) (SUS 規(guī)范)马篮,這樣不必了解 Mach 內(nèi)核也可以通過 Unix 信號的方式來兼容開發(fā)。
因?yàn)橛布a(chǎn)生的信號 (通過 CPU 陷阱) 被 Mach 層捕獲怜奖,然后才轉(zhuǎn)換為對應(yīng)的 Unix 信號浑测;蘋果為了統(tǒng)一機(jī)制,于是操作系統(tǒng)和用戶產(chǎn)生的信號 (通過調(diào)用kill和pthread_kill) 也首先沉下來被轉(zhuǎn)換為 Mach 異常歪玲,再轉(zhuǎn)換為 Unix 信號迁央。
signal(SIGABRT, SignalExceptionHandler)
2.NSException 捕獲掷匠。
應(yīng)用層,通過 NSUncaughtExceptionHandler
捕獲岖圈,因?yàn)槎褩V胁粫谐鲥e代碼讹语,所以需要獲取NSException對象中的reason、name幅狮、callStackSymbols募强。然后把細(xì)節(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【實(shí)現(xiàn)】
2.KVO/KVC crash【實(shí)現(xiàn)】
3.NSNotification crash
4.NSTimer crash【實(shí)現(xiàn)】
5.Container crash(數(shù)組越界擎值,插nil等)【實(shí)現(xiàn)】
6.NSString crash (字符串操作的crash)【實(shí)現(xiàn)】
7.Bad Access crash (野指針)【實(shí)現(xiàn)】
8.UI not on Main Thread Crash (非主線程刷UI(機(jī)制待改善))
Unrecognized Selector
調(diào)用方法時(shí)會轉(zhuǎn)換成objc_msgSend()函數(shù)調(diào)用。
1.首先逐抑,在相應(yīng)操作的對象中的緩存方法列表中找調(diào)用的方法鸠儿,如果找到,轉(zhuǎn)向相應(yīng)實(shí)現(xiàn)并執(zhí)行厕氨。
2.如果沒找到进每,在相應(yīng)操作的對象中的方法列表中找調(diào)用的方法,如果找到命斧,轉(zhuǎn)向相應(yīng)實(shí)現(xiàn)執(zhí)行田晚。
3.如果沒找到,去父類指針?biāo)赶虻膶ο笾袌?zhí)行1国葬,2贤徒。
4.以此類推,如果一直到根類還沒找到汇四,轉(zhuǎn)向攔截調(diào)用接奈,走消息轉(zhuǎn)發(fā)機(jī)制。
5.如果沒有重寫攔截調(diào)用的方法通孽,程序報(bào)錯序宦。
消息轉(zhuǎn)發(fā)流程
1.調(diào)用resolveInstanceMethod給個機(jī)會讓類添加這個實(shí)現(xiàn)這個函數(shù)。
2.調(diào)用forwardingTargetForSelector讓別的對象去執(zhí)行這個函數(shù)背苦。
3.調(diào)用forwardInvocation(函數(shù)執(zhí)行器)靈活的將目標(biāo)函數(shù)以其他形式執(zhí)行互捌。
基于此,我選擇2行剂、3都去實(shí)現(xiàn)對比方案疫剃。
方案一:重寫NSObject的forwardingTargetForSelector
方法。雖然不會造成NSInvocation對象的開銷硼讽,但是會攔截到系統(tǒng)的其他方法,導(dǎo)致該方法調(diào)用多次問題牲阁。
1.動態(tài)創(chuàng)建一個樁類固阁。
2.動態(tài)為樁類添加對應(yīng)的Selector壤躲,用一個通用的返回0的函數(shù)來實(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時(shí)仍然注冊著KVO。
2.添加KVO重復(fù)添加觀察者或重復(fù)移除觀察者(KVO注冊觀察者與移除觀察者不匹配)况褪。
基于管理混亂問題撕贞,可以讓被觀察對象持有一個KVO的delegate,所有和KVO相關(guān)的操作均通過delegate來進(jìn)行管理测垛,delegate通過建立一張map來維護(hù)KVO整個關(guān)系捏膨。
hook addObserver:方法
通過上面的流程,將observerd對象的所有kvo相關(guān)的observer信息全部轉(zhuǎn)移到KVOdelegate上食侮,并且避免了相同kvoinfo被重復(fù)添加多次的可能性号涯。
hook removeObserver:方法
移除一個keypath的Observer時(shí),當(dāng)delegate的kvoInfoMap中找不到key為該keypath的時(shí)候锯七,說明此時(shí)delegate并沒有持有對應(yīng)keypath的observer链快,即說明移除了一個不匹配的觀察者,此時(shí)如果再繼續(xù)操作會導(dǎo)致app崩潰眉尸,所以應(yīng)該及時(shí)中斷流程域蜗,然后統(tǒng)計(jì)異常信息。
當(dāng)keypath對應(yīng)的KVOInfo列表(infoArray)為空的時(shí)候效五,說明此時(shí)delegate已經(jīng)不再持有任何和keypath相關(guān)的observer了地消。這時(shí)應(yīng)該調(diào)用原有removeObserver的方法將delegate對應(yīng)的觀察者移除。
注意到在檢查遍歷infoArray的時(shí)侯畏妖,除了要刪除對應(yīng)的info信息脉执,還多了一步檢查info.observer == nil的過程,是因?yàn)槿绻鹢bserver為nil戒劫,那么此時(shí)如果keypath對應(yīng)的值變化的話半夷,也會因?yàn)檎也坏給bserver而崩潰,所以需要做這一步來阻止該種情況的發(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)方法湘换。同時(shí)在遍歷InfoArray的時(shí)候,發(fā)現(xiàn)info.observerw == nil的時(shí)候,需要及時(shí)將其清除掉彩倚,避免KVO的觀察者observer被釋放后value變化導(dǎo)致的crash.
最后筹我,針對 KVO的被觀察者dealloc時(shí)仍然注冊著KVO導(dǎo)致的crash 的情況,可以將NSObject的dealloc swizzle帆离, 在object dealloc的時(shí)候自動將其對應(yīng)的kvodelegate所有和kvo相關(guān)的數(shù)據(jù)清空蔬蕊,然后將kvodelegate也置空。避免出現(xiàn)KVO的被觀察者dealloc時(shí)仍然注冊著KVO而產(chǎn)生的crash哥谷。
KVC類型
hook常用的方法岸夯,用Try catch方式守護(hù)。
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的時(shí)候,對observer動態(tài)添加標(biāo)記flag刮刑。這樣在observer dealloc的時(shí)候喉祭,就可以通過flag標(biāo)記來判斷其是否有必要調(diào)用removeObserver函數(shù)了。
NSTimer類型
使用NSTimer的scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:
接口做重復(fù)性的定時(shí)任務(wù)時(shí)存在一個問題:NSTimer會強(qiáng)引用target實(shí)例雷绢,所以需要在合適的時(shí)機(jī)invalidate定時(shí)器泛烙,否則就會由于定時(shí)器timer強(qiáng)引用target的關(guān)系導(dǎo)致target不能被釋放,造成內(nèi)存泄露翘紊,甚至在定時(shí)任務(wù)觸發(fā)時(shí)導(dǎo)致crash蔽氨。 crash的展現(xiàn)形式和具體的target執(zhí)行的selector有關(guān)。與此同時(shí)帆疟,如果NSTimer是無限重復(fù)的執(zhí)行一個任務(wù)的話鹉究,也有可能導(dǎo)致target的selector一直被重復(fù)調(diào)用且處于無效狀態(tài),對app的CPU踪宠,內(nèi)存等性能方面均是沒有必要的浪費(fè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í)行的時(shí)候,會自動判斷原target是否已經(jīng)被釋放孤页,如果釋放了,意味著NSTimer已經(jīng)無效涩馆,此時(shí)如果還繼續(xù)調(diào)用原有target的selector很有可能會導(dǎo)致crash行施,而且是沒有必要的。所以此時(shí)需要將NSTimer invalidate魂那,然后統(tǒng)計(jì)上報(bào)錯誤數(shù)據(jù)蛾号。如此一來就做到了NSTimer在合適的時(shí)機(jī)自動invalidate。
Container類型
針對于NSArray/NSMutableArray/NSDictionary/NSMutableDictionary/NSCache的一些常用的會導(dǎo)致崩潰的API進(jìn)行method swizzling涯雅,然后在swizzle的新方法中加入一些條件限制和判斷鲜结,從而讓這些API變的安全。
NSString類型
NSString/NSMutableString 類型的crash的產(chǎn)生原因和防護(hù)方案與Container crash很相像活逆。
野指針類型
method swizzling替換NSObject的allocWithZone
方法
在新的方法中判斷該類型對象是否需要加入野指針防護(hù)精刷,如果需要,則通過objc_setAssociatedObject為該對象設(shè)置flag標(biāo)記蔗候,被標(biāo)記的對象后續(xù)會進(jìn)入zombie流程怒允。
做flag標(biāo)記是因?yàn)楹芏嘞到y(tǒng)類,比如NSString锈遥,UIView等創(chuàng)建纫事,釋放非常頻繁,而這些實(shí)例發(fā)生野指針概率非常低所灸±龌蹋基本都是我們自己寫的類才會有野指針的相關(guān)問題,所以通過在創(chuàng)建時(shí)爬立,設(shè)置一個標(biāo)記用來過濾不必要做野指針防護(hù)的實(shí)例钾唬,提高方案的效率。
同時(shí)做判斷是否要加入標(biāo)記的條件里面懦尝,我們加入了黑名單機(jī)制知纷,是因?yàn)橐恍┨囟ǖ念愂遣贿m用于添加到zombie機(jī)制的,會發(fā)生崩潰(例如:NSBundle)陵霉,而且所以和zombie機(jī)制相關(guān)的類也不能加入標(biāo)記琅轧,否則會在釋放過程中循環(huán)引用和調(diào)用,導(dǎo)致內(nèi)存泄漏甚至棧溢出踊挠。
method swizzling替換NSObject的dealloc
方法
對flag標(biāo)記的對象實(shí)例調(diào)用objc_destructInstance
乍桂,釋放該實(shí)例引用的相關(guān)屬性冲杀,然后將實(shí)例的isa修改為ShieldZombieObject。通過objc_setAssociatedObject 保存將原始類名保存在該實(shí)例中睹酌。
dealloc最后會調(diào)到objectdispose函數(shù)权谁,在這個函數(shù)里面其實(shí)也做了三件事情。
1.調(diào)用objc_destructInstance釋放該實(shí)例引用的相關(guān)實(shí)例憋沿。
2.將該實(shí)例的isa修改為stubClass旺芽,接受任意方法調(diào)用。
3.釋放該內(nèi)存辐啄。
在ShieldZombieSub 通過消息轉(zhuǎn)發(fā)機(jī)制forwardingTargetForSelector
處理所有攔截的方法
根據(jù)selector動態(tài)添加能夠處理方法的響應(yīng)者ShieldZombieSub 實(shí)例采章,然后通過 objc_getAssociatedObject 獲取之前保存該實(shí)例對應(yīng)的原始類名,統(tǒng)計(jì)錯誤數(shù)據(jù)壶辜。當(dāng)退到后臺或者達(dá)到未釋放實(shí)例的上限時(shí)悯舟,則調(diào)用free函數(shù)釋被引用zombie化的實(shí)例。
注:
1.做了野指針防護(hù)砸民,通過動態(tài)插入一個空實(shí)現(xiàn)的方法來防止出現(xiàn)Crash抵怎,但是業(yè)務(wù)層面的表現(xiàn)難以確定,可能會進(jìn)入業(yè)務(wù)異常的狀態(tài)岭参。需要擬定一下如何展現(xiàn)該問題給用戶的方案反惕。
2.由于做了延時(shí)釋放若干實(shí)例,對系統(tǒng)總內(nèi)存會產(chǎn)生一定影響冗荸,目前將內(nèi)存的緩沖區(qū)開到5M左右承璃,所以應(yīng)該沒有很大的影響,但還是可能潛在一些風(fēng)險(xiǎn)蚌本。
3.延時(shí)釋放實(shí)例是根據(jù)相關(guān)功能代碼會聚焦在某一個時(shí)間段調(diào)用的假設(shè)前提下盔粹,所以野指針的zombie保護(hù)機(jī)制只能在其實(shí)例對象仍然緩存在zombie的緩存機(jī)制時(shí)才有效,若在實(shí)例真正釋放之后程癌,再調(diào)用野指針還是會出現(xiàn)crash,所以不能達(dá)到真正防止crash的目的舷嗡。
據(jù)面試阿里的面試官說可以用計(jì)算內(nèi)存堆棧信息的方式,作者表示不理解嵌莉。
非主線程刷UI類型
- (void)setNeedsLayout;
- (void)setNeedsDisplay;
- (void)setNeedsDisplayInRect:(CGRect)rect;
在這三個方法調(diào)用的時(shí)候判斷一下當(dāng)前的線程进萄,如果不是主線程的話,直接利用 dispatch_async(dispatch_get_main_queue(), ^{ //調(diào)用原本方法 });
來將對應(yīng)的刷UI的操作轉(zhuǎn)移到主線程上锐峭,同時(shí)統(tǒng)計(jì)錯誤信息中鼠。
但是真正實(shí)施了之后,發(fā)現(xiàn)這三個方法并不能完全覆蓋UIView相關(guān)的所有刷UI到操作沿癞,但是如果要將全部到UIView的刷UI的方法統(tǒng)計(jì)起來并且swizzle援雇,感覺略笨拙而且不高效。