iOS crash防護技術(shù)方案

以下所有內(nèi)容均為個人觀點,轉(zhuǎn)載請注明出處<簡書--小蝸牛吱呀之悠悠 >,謝謝!

線上的崩潰一直以來都是比較頭疼的問題,由于crash對于APP來說是嚴重的質(zhì)量事故,所以如果能夠降低crash并且將可能出錯的原因收集起來并通過版本迭代修復(fù)掉,那么將會有很大的實際意義,網(wǎng)易的“大白健康系統(tǒng)--APP運行時Crash自動修復(fù)系統(tǒng)”從多個維度講述了防護的方案,本文也將從中借鑒并結(jié)合實際情況展開討論。

一、背景

近期涉及項目穩(wěn)定性的提升以及線上客戶的反饋,為了提升客戶的滿意度,我們有必要實現(xiàn)一個技術(shù)方案來完成“crash發(fā)現(xiàn)及防護 — 錯誤收集 — 通知客戶修復(fù)問題”的閉環(huán)。如果我們能夠在客戶反饋crash問題之前發(fā)現(xiàn)并解決問題,然后第一時間通知客戶迭代版本去修復(fù)問題,不僅可以提升品牌形象,同時也可以減少后續(xù)維護過程crash反饋問題數(shù)。

1、crash發(fā)現(xiàn)及防護

借助iOS運行時的特性,將一些常見,不影響業(yè)務(wù)邏輯,不侵染客戶項目代碼的崩潰,在崩潰之前做出一些防護措施,從而繞開崩潰,并且將產(chǎn)生崩潰的原因收集起來,借助第二環(huán)節(jié)的“錯誤收集”提交到開發(fā)人員,并修復(fù)掉。

2、錯誤收集

項目已經(jīng)采用云音樂的sentry框架收集崩潰信息,此處不展開sentry的討論。sentry框架GitHub鏈接

3、通知客戶修復(fù)問題

當我們從第一、第二環(huán)節(jié)收集到崩潰信息,修復(fù)并發(fā)布版本后,需要及時通知客戶更新版本。此時我們需要知道哪些客戶正在使用有問題的版本,基于這個需求,我們聯(lián)合服務(wù)端收集SDK版本號,并將版本號與AppKey關(guān)聯(lián),從而實現(xiàn)這個需求

二、防護綱要

1、容器越界、非空防護
2、unrecognized selector 崩潰
3、NSTimer
4、KVO crash
5、Bad Access crash (野指針)
結(jié)合七魚SDK項目的實際情況,已經(jīng)按優(yōu)先級將上述防護進行排序。

三、實現(xiàn)方案及原理

1、容器越界、非空防護

七魚中使用的常用容器為:NSArray、NSMutableArray、NSDictionary、NSMutableDictionary、NSString、NSMutableString。
容器的crash一般出現(xiàn)在越界或者插入非空對象,我們采用了方法交換的原理對容器進行crash預(yù)處理。

image.png

image.png

實際操作過程中,使用了如下圖中的做法:
Lark20210623-144722.png

本意是為了增加穩(wěn)定防護,但是卻遺漏了考慮不繼承于NSObject的類NSProxy和繼承于swift根類的_TtCs12_SwiftObject的情況,導(dǎo)致在繼承于NSProxy和_TtCs12_SwiftObject類的正常業(yè)務(wù)邏輯受到了影響

注:為了侵染用戶的代碼,可以在防護的方法中增加判斷,異常是否來源于七魚,僅對來源于七魚的有效。

2、unrecognized selector 崩潰

引起這類崩潰通常是因為一個對象調(diào)用了一個不屬于它方法的方法導(dǎo)致的。這種崩潰出現(xiàn)的頻率比較高,防護的意義較大。
我們先了解一下unrecognized selector 崩潰是怎么產(chǎn)生的。當我們使用對象A調(diào)用方法B的時候,系統(tǒng)默認會幫我們做以下這些事情:

a、去對象A的方法列表中查找是否已經(jīng)實現(xiàn)方法B,如果實現(xiàn)了,直接執(zhí)行,否則執(zhí)行b
b、去對象A的isa指針指向的對象中查找方法B,如果找到了,直接執(zhí)行,如果一直到根類都沒有找到,則系統(tǒng)會走消息轉(zhuǎn)發(fā)機制,即c
c、系統(tǒng)會在崩潰前通過查找是否有重寫攔截的方法確定是否產(chǎn)生崩潰,如果沒有重寫攔截方法,則拋出unrecognized selector 異常。

到這來,unrecognized selector 產(chǎn)生的原因也就顯而易見了。由于類的種類不確定,通過a、b兩個環(huán)節(jié)動態(tài)增加方法不僅實現(xiàn)起來困難,而且代碼侵染性很高;同時,既然系統(tǒng)提供了c這個環(huán)節(jié),我們就考慮從c這個步驟入手進行防護,只要在crash前,重寫攔截方法,就可以避免crash。
攔截的方法一共有3個步驟,關(guān)系如下:


消息攔截方法關(guān)系圖
//給對象添加這個方法的實現(xiàn)
+ (BOOL)resolveInstanceMethod:(SEL)sel;
//讓別的對象去執(zhí)行這個函數(shù)
- (id)forwardingTargetForSelector:(SEL)aSelector;
//將目標函數(shù)以其他形式執(zhí)行
- (void)forwardInvocation:(NSInvocation *)anInvocation;

通過上圖可以知道,方法一和a、b環(huán)節(jié)類似,不予考慮,方法三經(jīng)常被重寫,而且可以將消息以NSInvocation形式轉(zhuǎn)發(fā)個多個對象,增加了不必要的開銷,所以我們選擇方法二比較合適。也就是說如果我們能建立一個專門用于消息攔截的類,每次發(fā)生unrecognized selector的時候,都將消息轉(zhuǎn)發(fā)給這個類,那么我們就可以統(tǒng)一處理了。

//將崩潰信息轉(zhuǎn)發(fā)到一個指定的類中執(zhí)行FastForwarding
- (id)BMP_forwardingTargetForSelector:(SEL)selector{
    /*判斷當前類有沒有重寫消息轉(zhuǎn)發(fā)的相關(guān)方法*/
    if ([self isEqual:[NSNull null]] || ![self overideForwardingMethods]) {//沒有重寫消息轉(zhuǎn)發(fā)方法
        NSArray *callStackSymbolsArr = [NSThread callStackSymbols];
        //錯誤發(fā)生在viewdidload中的時候獲取發(fā)生錯誤的視圖控制器的類名
        NSString *vcClassName = GetClassNameOfViewControllerIfErrorHappensInViewDidloadProcessWithCallStackSymbols(callStackSymbolsArr);
        errors = ErrorInfosMake([NSStringFromClass(self.class) cStringUsingEncoding:NSASCIIStringEncoding], [NSStringFromSelector(selector) cStringUsingEncoding:NSASCIIStringEncoding]);
        //為BayMaxCrashHandler類增加selector對應(yīng)的方法
        class_addMethod([BayMaxCrashHandler class], selector, (IMP)DynamicAddMethodIMP, "v@:");
        //收集錯誤信息
        [[BayMaxCrashHandler sharedBayMaxCrashHandler]forwardingCrashMethodInfos:@{ErrorClassName:NSStringFromClass(self.class),
                                                                                   ErrorFunctionName:NSStringFromSelector(selector),
                                                                                   ErrorViewController:[[BayMaxDegradeAssist Assist]topViewController]
        }];
        BayMaxCatchError *bmpError = [BayMaxCatchError BMPErrorWithType:BayMaxErrorTypeUnrecognizedSelector infos:@{
            BMPErrorUnrecognizedSel_Reason:[NSString stringWithFormat:@"UNRecognized Selector:'%@' sent to instance %@",NSStringFromSelector(selector),self],
            BMPErrorUnrecognizedSel_VC:vcClassName == nil?([[BayMaxDegradeAssist Assist]topViewController] == nil?@"":[[BayMaxDegradeAssist Assist]topViewController]):vcClassName,
            BMPErrorCallStackSymbols:callStackSymbolsArr
        }];
        if (_showDebugView) {
            [[BayMaxDebugView sharedDebugView]addErrorInfo:bmpError.errorInfos];
        }
        [[BayMaxDegradeAssist Assist]handleError:bmpError];
        if (_errorHandler) {
            _errorHandler(bmpError);
        }
        //告訴系統(tǒng),去BayMaxCrashHandler方法列表查找具體的實現(xiàn)
        return [BayMaxCrashHandler sharedBayMaxCrashHandler];
    }
    return [self BMP_forwardingTargetForSelector:selector];
}

如果某個類本身進行了消息攔截方法的重寫,我們再將消息轉(zhuǎn)移到BayMaxCrashHandler類上,則會使原來重寫的流程失效,所以在overideForwardingMethods方法中進行了判斷。

3、NSTimer防護

NSTimer使用頻率很高,但每次使用后,都要在dealloc之前將定時器釋放掉,否則會引起循環(huán)引用,甚至崩潰。因為timer被target強引用的同時自身也被target所持有,如果不在dealloc之前釋放定時器,target對象也將無法釋放。
針對這個情況,我們想到的是解除target和timer之間互相引用的環(huán),但又不影響業(yè)務(wù)邏輯的開展。那么如何才能解除這個環(huán)呢?

1、timer被target引用
@property (nonatomic, weak) NSTimer *timer;

target依然被timer強引用,導(dǎo)致dealloc方法不執(zhí)行,循環(huán)引用仍然存在

2、target被timer引用
__weak typeof(self) weakSelf = self;
        self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:weakSelf selector:@selector(test) userInfo:nil repeats:YES];

對target進行弱引用以后,并不能改變target指向的內(nèi)存地址,例如上述的self指向地址S,弱引用后,weakSelf仍然指向地址S,當我們使用weakSelf傳給定時器的時候,相當于是將地址S傳過去,scheduledTimerWithTimeInterval方法內(nèi)部仍然強引用地址S,dealloc方法仍然無法被執(zhí)行,循環(huán)引用仍然存在。

3、timer與target直接加入一個橋階層
橋階層
image.png
image.png

通過方法交換,在scheduledTimerWithTimeInterval方法中,新建一個subTarget對象,并將target,selector,timer,targetClass等參數(shù)傳遞給subTarget,subTarget內(nèi)部對這些參數(shù)進行弱引用持有,并實現(xiàn)一個中轉(zhuǎn)方法給原始scheduledTimerWithTimeInterval調(diào)用。這樣,每當定時器觸發(fā)時,target對象強引用了timer,timer強引用subTarget,并向subTarget調(diào)用中轉(zhuǎn)方法,由subTarget的中轉(zhuǎn)方法再將方法分發(fā)給原始target,而原始target則被subTarget弱引用。
此時,當target被需要被釋放時,由于沒有被timer強引用,dealloc會被調(diào)用,同時會釋放subTarget和timer。由于timer及時被釋放了,回調(diào)函數(shù)在target被釋放后不會再執(zhí)行,就不會再引起野指針等異常內(nèi)存地址訪問的問題。

4、KVO crash防護

我們在需要監(jiān)測某個變量值的變化時常使用到KVO,但KVO需要注冊和釋放成對配套使用,任意一個環(huán)節(jié)過多或過少,都會導(dǎo)致崩潰。我曾經(jīng)對UITableviewCell的某個屬性使用過KVO,由于Cell存在復(fù)用機制,導(dǎo)致KVO的注冊和釋放不配套,造成crash。如果能夠?qū)崿F(xiàn)一種機制,使得KVO不依賴于注冊和釋放成對出現(xiàn),那么此類crash將大幅減少。
從上述內(nèi)容我們可知,KVO的注冊與釋放彼此依賴,與NSTimer的防護非常相似,如果能夠打破這個環(huán)結(jié)構(gòu),那么就可以避免KVO的崩潰了。我們參考NSTimer的思路,在注冊與釋放環(huán)節(jié)增加中間橋接層,讓注冊與依賴不強相關(guān),而分別于橋接層強相關(guān),在對象釋放的時候,只需要操作橋接層即可,即便注冊和釋放不成對,也不會引起KVO的crash。


KVO橋接層.png

如上圖我們可以知道,在觀察者與被觀察者之間的這層橋接層(KVO delegate)負責記錄兩者之間的關(guān)系,對觀察者而言,KVO delegate是被觀察者,對被觀察者而言,KVO delegate是觀察者。


image.png

image.png

KVO delegate有一個屬性用于記錄觀察路徑等信息,當對象被釋放時,會將KVO delegate的所有路徑釋放完,這樣就形成了注冊和釋放的閉環(huán)。
image.png

5、Bad Access crash (野指針)

野指針或者異常內(nèi)存地址訪問是經(jīng)常遇到,并且是很難處理的問題,如果能夠?qū)@一塊的崩潰進行提前規(guī)避掉,將大大降低崩潰率。
野指針問題之所以難處理往往是因為崩潰日志能提供的信息很有限,場景又難以復(fù)現(xiàn),所以如果有一個機制,能夠?qū)⒁爸羔槅栴}所需要的信息收集起來,這樣對解決問題將很有幫助。
“大白健康系統(tǒng)--APP運行時Crash自動修復(fù)系統(tǒng)”一文中關(guān)于這一點的思路是參考僵尸對象原理,模擬構(gòu)建一個僵尸對象,在訪問到異常內(nèi)存地址時,主動將其釋放掉,并將isa指針指向僵尸對象,以此來繞開崩潰。但這種做法風險很大,雖然避免了暫時的崩潰,但程序后續(xù)會怎么運行將存在不確定性;繞開了野指針引起的崩潰,可能也繞開了我們正常的業(yè)務(wù)邏輯,容易引起業(yè)務(wù)邏輯的錯誤;
基于上述考慮,并結(jié)合前文提到的“crash發(fā)現(xiàn)及防護 — 錯誤收集 — 通知客戶修復(fù)問題”閉環(huán),我們可以在即將崩潰時,充分收集有效的數(shù)據(jù),并借助crash上報的功能,將crash防護作為解決野指針問題的收集工具。

注意:由于大白健康系統(tǒng)并未對外發(fā)布實際框架,本文借助BayMaxProtector展開論述,這個框架基本與本文思想一致,但結(jié)合實際業(yè)務(wù)仍有待改進之處:

1、框架中未完善白名單黑名單制度,對于一些需要特殊處理的類需要繞開。
2、框架中的部分判斷邏輯未兼容NSProxy類和_TtCs12_SwiftObject類,防護無效。
3、可以增加針對特定范圍的防護,以避免收集不屬于SDK的錯誤信息。
4、需要結(jié)合實際日志收集功能,實際需要收集的信息加以優(yōu)化。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容