再談 iOS App Crash 防禦

在移動開發中,App 的閃退率是工程師十分關注且又頭疼的事情。去年,網易杭州研究院曾經針對 crash 的防禦有提出『大白健康系統--iOS APP 運行時 Crash 自動修復系統』方案,使得 crash 防禦這個想法真正被落實,但至今該方案的具體實現並無被開源。通過一年的時間,圈子裏也有一些開發朋友,基於這套方案設計並開源了本身的 「Baymax」,好比『老司機 iOS 週報第七期』中曾提到的 BayMaxProtector。本文將會針對網易 Baymax 這套方案,結合團隊內的實踐結果,總結其在生產環境中可能遇到的問題及其解決方案,並提出一些本身對這套方案的思考。友情提示,閱讀本文前需對網易『大白健康系統--iOS APP 運行時 Crash 自動修復系統』一文有所瞭解,該文中已有的實現方案,本文不會再花更多筆墨進行贅述。css

Crash 防禦可選的方案

Crash 是什麼?

在探討 Crash 防禦的方案以前,咱們有必要對計算機領域 Crash 這個概念進行從新認識。對於 Crash 的概念,維基百科中是這麼定義的:html

In computing, a crash (or system crash) occurs when a computer program, such as a software application or an operating system, stops functioning properly and exits.ios

An application typically crashes when it performs an operation that is not allowed by the operating system. The operating system then triggers an exception or signal in the application. Unix applications traditionally responded to the signal by dumping core. Most Windows and Unix GUI applications respond by displaying a dialogue box (such as the one shown to the right) with the option to attach a debugger if one is installed. Some applications attempt to recover from the error and continue running instead of exiting.git

對於咱們 iOS 應用層的 App,可簡單總結爲應用執行了某些不被容許的操做觸發了系統拋出異常信號但又沒有處理這些異常信號從而被殺掉的現象,好比常見的閃退(crash to desktop)。在咱們開發領域從拋出異常的對象上來看,一共能夠分爲三類內核致使的異常、應用自身的異常或其餘進程致使的異常:github

  • 由操做系統內核捕獲硬件產生的異常信號,好比 EXC_BAD_ACCESS,這類異常若是沒有被處理掉的話,會被轉發到 SIGBUS 或 SIGSEGV 等類型的 BSD 信號;
  • 由 SDK 開發者或上層應用開發者主動拋出的異常信號,好比各類常見的 NSException,這類異常蘋果爲了統一處理,最終會被轉發爲 SIGABRT 類的 BSD 信號;
  • 其餘進程殺死你的應用;

這裏咱們主要談最多見的前兩種異常。面試

可選的 Crash 防禦方案

上面已經提到了 Crash 實際上咱們觸發了異常,但又沒有去處理這些異常而致使的結果。那麼很天然的第一個防禦方案即可以想到是去處理這些異常。編程

經過 NSUncaughtExceptionHandler 來捕獲並處理異常

蘋果的確提供有異常捕獲的 API 以供開發者使用——NSSetUncaughtExceptionHandler,開發者只須要傳入處理函數的指針,即可以處理掉應用中拋出的 NSException 類的異常。代碼寫起來就是:xcode

NSSetUncaughtExceptionHandler(&HandleException); 

經過 BSD 的 signal 來捕獲並處理異常

因爲蘋果將全部異常最終都轉換成了 BSD 信號的發出,那麼咱們就能夠去捕獲這個信號來處理這些異常,從而達到 Crash 防禦的目的。系統也有提供相關 API 實現:緩存

void (*signal(int, void (*)(int)))(int); 

前一個參數爲異常類型,能夠是 SIGSEGV 等這類,後一個參數爲回調的函數,代碼寫起來就能夠是:性能優化

signal(SIGABRT, SignalHandler);
signal(SIGILL, SignalHandler);
signal(SIGSEGV, SignalHandler);
signal(SIGFPE, SignalHandler);
signal(SIGBUS, SignalHandler);
signal(SIGPIPE, SignalHandler);

注意:因爲 Xcode 默認會開啓 debug executable,它會在咱們捕獲這些異常信號以前攔截掉,所以作這個測試須要手動將 debug executable 功能關閉,或者不在 Xcode 鏈接調試下進行測試。

 
image

 

至此,彷佛一切看起來都很順利,然而實踐過程當中你會發現程序並無在你處理完這些異常後就能繼續進行。這與 iOS 的 Runloop 機制有關,在觸發異常後,Main Runloop 將不會繼續運行,這也就意味着 App 跑不起來了。固然,你可能會很天然地聯想到,我本身再把 Main Runloop 繼續掛起來跑不就好了嗎?如如下相似代碼:

//這裏取到的是 Main Runloop CFRunLoopRef runLoop = CFRunLoopGetCurrent(); CFArrayRef allModes = CFRunLoopCopyAllModes(runLoop); while (YES) { for (NSString *mode in (NSArray *)allModes) { CFRunLoopRunInMode((CFStringRef)mode, 0.001, false); } } CFRelease(allModes); 

這樣一試,確實程序在捕獲異常以後又可以繼續運行了。但『經過 NSUncaughtExceptionHandler 來捕獲並處理異常』和『經過 BSD 的 signal 來捕獲並處理異常』這兩種方式去作 Crash 防禦並非一種靠譜的方式,緣由有如下幾點:

  • iOS/OSX 在被拋出異常後,被認爲是不可恢復的,若是咱們強行恢復 Runloop,整個 App 的不肯定性將會更大,crash 的部分可能會再次發生;
  • 內核拋出的異常通常都是較嚴重的底層硬件問題,若是這類問題不及時中止程序運行,可能會進一步影響整個系統的運行,乃至損壞硬件;
  • 以上兩種作法,一般是用於 Crash 日誌收集上,若是咱們防禦層也經過這個方案去作的話,衝突的可能性會很大;

這裏附帶下『App Architecture book』做者 Matt Gallagher 早年對於這部分研究後的一個 demo,因爲是 MRC 時代的代碼了,修改了部分配置使得可以正常編譯且測試。

經過 try-catch 的組合拳來捕獲異常

和其餘編程語言同樣,Objective-C 中也有萬能的 try-catch 組合來捕獲異常,這樣處理不就能夠了?這種方案確實是可行的,我也確實有見過一些人使用 try-catch 來作一些常見的 Crash 防禦。但 Objective-C 的 try-catch 實際上有先天缺陷的,首先是效率並不高,甚至某些狀況下會致使內存泄漏,不可控。

  • 效率不高是因爲 try-catch 是基於 block 的處理方案,會多出額外的開銷(不過蘋果已經重寫了 64 bit 機器上的 try-catch,並且聲明是 zero-cost);
  • 可能會內存泄漏是因爲 Xcode 默認並不會對 try-catch 中的代碼進行 ARC 管理。try 在捕捉到 Exception 以後,會當即轉到 catch 中執行,這樣就致使了若是 release 代碼是寫在 try 中 throw 異常的代碼以後的話,就會不被執行而致使內存泄漏。若是爲了防止這個泄漏而去配置 -fobjc-arc-exceptions 選項,更會由於生成低效代碼而得不償失,這也是蘋果並不推薦的方式。

但這不能徹底否認 try-catch 組合在咱們平常編程中的做用,在一些容易出現異常的操做上,好比文件讀寫或者須要配合使用 throw 的狀況等。這裏指的不適合,只是針對在大範圍防禦並不適合。

Baymax 的方案

在綜合分析了以上幾個防禦方案後,咱們再來看看 Baymax 中採用的方案。若是說上面三種方案都是在已經拋出了異常以後再去捕獲處理,也就是「喝後悔藥」的機制,那麼 Baymax 的方案即是不讓這些異常產生。不讓錯誤異常產生能夠經過多種作法,往項目管理上說提升代碼質量,增長 Code Review 等,從編碼角度來講,咱們能夠經過各類保護性代碼進行。Baymax 中的大部分防禦方案均可以理解爲一種爲你自動增長保護性代碼的措施。好比,各類 Collection 類型,String 類型等。

實踐 Baymax 方案中可能遇到的問題

高頻調用方法的性能問題

Baymax 是基於 AOP 思想而設計的,方案中會充斥着各類 Hook 系統方法,這對於高頻調用的方法,性能上的損耗是不可忽略的。爲了將損耗盡可能下降,咱們能夠經過只防御特定類來進行,好比只針對咱們的自定義類和部分在防禦名單內的類,而對於系統的類,咱們不進行防禦,這樣就能在必定限度上下降性能損耗。對於判斷自定義類能夠經過如下方法進行:

若是隻是判斷 main bundle 的話能夠經過如下代碼進行:

+ (BOOL)isMainBundleClass:(Class)cls { return cls && [[NSBundle bundleForClass:cls] isEqual:[NSBundle mainBundle]] ; } 

但在組件化開發中,咱們的代碼會經過各類私有 pod 的形式導入,這樣只判斷 main bundle 的方式就不夠用了,咱們能夠經過如下代碼進行:

+ (BOOL)isCustomClass:(Class)cls { ///var/containers/Bundle/Application/CB0D354B-DD08-4845-A084-A22FF01097FE/Example.app NSString *mainBundlePath = [NSBundle mainBundle].bundlePath; ///var/containers/Bundle/Application/CB0D354B-DD08-4845-A084-A22FF01097FE/Example.app/Frameworks/Baymax.framework NSString *clsBundlePath = [NSBundle bundleForClass:cls].bundlePath; return cls && mainBundlePath && clsBundlePath && [clsBundlePath hasPrefix:mainBundlePath]; } 

另外,因爲判斷是否防禦的條件會相對比較多,這裏能夠引入名單緩存來作進一步的效率優化,將本次判斷結果存儲到 NSCache 中,下回優先從 Cache 裏讀取防禦狀態,性能提高將會十分顯著。大體代碼以下:

//先從緩存中讀取狀態 NSNumber *status = [baymax needBaymaxStatusInProtectionCache:clsStr]; //若是有在緩存中 則直接返回緩存中的狀態 若不在緩存中 則繼續走判斷邏輯 if (status != nil) return [status boolValue]; 

UnrecognizedSelector 防禦的坑

蘋果在 KVO 的實現中,爲每種類型都封裝了一個特定的 set 方法,緣由未知(或許又是 Historical Reasons 吧),這裏涵蓋了 CoreFoundation 裏的全部基礎類型。

_NSSetBoolValueAndNotify、_NSSetCharValueAndNotify、_NSSetDoubleValueAndNotify、_NSSetFloatValueAndNotify、_NSSetIntValueAndNotify、_NSSetLongLongValueAndNotify、_NSSetLongValueAndNotify、_NSSetObjectValueAndNotify、_NSSetPointValueAndNotify、_NSSetRangeValueAndNotify、_NSSetRectValueAndNotify、_NSSetShortValueAndNotify、_NSSetSizeValueAndNotify、_NSSetUnsignedCharValueAndNotify、_NSSetUnsignedIntValueAndNotify、_NSSetUnsignedLongLongValueAndNotify、_NSSetUnsignedLongValueAndNotify、_NSSetUnsignedShortValueAndNotify

除這些類型外的其餘類型(好比 UIKit 中的 struct 或者其餘自定義的 struct)被做爲 property 觀察時,都會走如下的轉發邏輯。這樣的處理邏輯在特定的狀況下就會影響防禦,好比 UIEdgeInsets 類型的 property 被加入 KVO 檢測,那麼以後再 set 這個 property 的時候,set 方法就會進入轉發邏輯,這樣就會被誤識別爲一次UnrecognizedSelector 的 Crash,且致使原有的 KVO 邏輯失效。

<_NSCallStackArray 0x100700630>( 0 ??? 0x00000001001f3ecd 0x0 + 4297014989, 1 KVOAnalysisDemo 0x0000000100001850 main + 0, 2 Foundation 0x00007fff981fd67d NSKeyValueNotifyObserver + 350, 3 Foundation 0x00007fff981fcf14 NSKeyValueDidChange + 486, 4 Foundation 0x00007fff981cbdf6 -[NSObject(NSKeyValueObserverNotification) didChangeValueForKey:] + 118, 5 Foundation 0x00007fff9829cc11 NSKVOForwardInvocation + 325, 6 CoreFoundation 0x00007fff967c65fa ___forwarding___ + 538, 7 CoreFoundation 0x00007fff967c6358 _CF_forwarding_prep_0 + 120, 8 KVOAnalysisDemo 0x000000010000198b main + 315, 9 libdyld.dylib 0x00007fffabf2d235 start + 1 ) 

解決方案是經過判斷是否重寫相關轉發方法決定是否須要防禦,主要代碼以下:

BOOL isMethodOverride = ([self isMethodOverride:cls selector:@selector(forwardInvocation:)] || [self isMethodOverride:cls selector:@selector(forwardingTargetForSelector:)]); if (!isMethodOverride) { return YES; } 
+ (BOOL)isMethodOverride:(Class)cls selector:(SEL)sel { IMP selfIMP = class_getMethodImplementation(cls, sel); IMP superIMP = class_getMethodImplementation(class_getSuperclass(cls), sel); return selfIMP != superIMP; } 

iOS SDK 在不斷調整

因爲 iOS 系統的封閉性,系統 API 的實現咱們是沒法直接看到的。而蘋果有可能在更新系統版本的時候,出於各類緣由對一些 API 進行調整。在測試中已發現有如下幾個系統類在 iOS8-iOS10 中被調整過:

po [@[] class] before iOS8:__NSArrayI later:__NSArray0 po [@[@1] class] before iOS9:__NSArrayI iOS10:__NSSingleObjectArrayI po [objc_getClass("NSTaggedPointerString") superclass] before iOS8:NSObject after iOS8:NSString 

以上這些實現的調整,形成的影響均是 method-swizzling 的失敗。但從實際測試狀況來看,雖然以上類有作了調整,但其實並不影響防禦。好比,__NSArray0 在 iOS8 中是__NSArrayI 代替,而 __NSArrayI 這個類在 iOS8 或者以後的系統都是會被防禦的。

BadAccess 防禦中原 dealloc 方法的延遲調用

BadAccess 防禦的核心原理是延遲內存釋放,這裏就須要在以後的某個合適的時機,手動去調用原有的釋放方法來執行真正的內存釋放。但在實際開發中,發現直接去調用保存的原 dealloc,並不能作到正確釋放內存。排查搜索以後,發現這多是在 ARC 環境下,蘋果對 dealloc 方法的特殊處理致使的,在 method-swizzling 後,原 dealloc 的 selector 實際上已經變成了轉發後的 selector 了,而猜想目前 ARC 的對 dealloc 的處理只認 dealloc 這個 selector,因此惟一的方法處理即是仍是經過 imp(obj, NSSelectorFromString(@"dealloc")) 來調用。

目前的解決方法:直接用 c 函數傳 imp 和 dealloc 調用,主要代碼以下:

// Get Original Dealloc IMP. // See more in JSPatch:https://github.com/bang590/JSPatch/blob/master/JSPatch/JPEngine.m Class objCls = object_getClass(obj); Method deallocMethod = class_getInstanceMethod(objCls, NSSelectorFromString(@"wycd_dealloc")); void (*originalDealloc)(__unsafe_unretained id, SEL) = (__typeof__(originalDealloc))method_getImplementation(deallocMethod); originalDealloc(obj, NSSelectorFromString(@"dealloc")); 

NSArray 防禦後出現的奇葩問題

Hook 掉 objectAtIndex:方法後,在這樣一個場景下會出現意外的 crash:調出系統鍵盤再把 App 切到後臺,就出現 [uikeyboardlayoutstar release] message sent to deallocated instance crash。這實際上是 iOS 系統在 ARC 下的一個坑,ARC 致使了 over-released 的 crash,暫時沒有其餘更好的解決方案,只能把這部分防禦改成 MRC 編寫。

如何保證 SDK 更新的穩定性

Baymax 方案涉及到不少的系統方法,那麼怎麼保證每一次更新迭代不會形成嚴重的線上問題呢?這最終仍是要落實到單元測試上,咱們能夠給 Baymax 編寫足夠完善的單元測試用例,而後配置一個觸發腳本,來自動地在咱們每次 push 到開發分支時跑這些測試用例。固然,必須值得注意的是,測試必須覆蓋到你當前支持的全部 iOS 版本,若是是使用 GitLab Runner 能夠按以下配置作:

test_job:
  only:
    - UnitTest
  stage: test
  script:
    - export LC_ALL='en_US.UTF-8'
    - xcodebuild clean -workspace Example/Baymax.xcworkspace -scheme Baymax-Example | xcpretty
    - pod install --project-directory=Example
    - xcodebuild test -workspace Example/Baymax.xcworkspace -scheme Baymax-Example -destination 'platform=iOS Simulator,name=iPhone 5s,OS=11.2' -destination 'platform=iOS Simulator,name=iPhone 5s,OS=9.3' -destination 'platform=iOS Simulator,name=iPhone 5s,OS=8.4' | xcpretty -s

大體的單元測試代碼能夠以下:

- (void)testCrashProtection { //given when Baymax *baymax = [Baymax sharedInstance]; [baymax configBaymaxType:BaymaxAll]; [baymax start]; //then for (int i = 0 ; i < kBaymaxType; i++) { NSUInteger type = 1 << i; Tester *tester = [Tester tester:type]; NSUInteger caseCount = [[tester testCaseSelectors] count]; for (int j = 0; j < caseCount; j++) { XCTAssertNoThrow([tester executeTestCase:j]); } } } 

防禦的代價是什麼

任何事物咱們都從正反兩方面考慮,既然 Baymax 提供了防禦功能,那其必然也存在着弊端。

首先,第一點就是上面提到的性能問題,在方案調研階段,筆者曾經使用 XCTest 對 Collection 類型的防禦作了部分的性能測試,結果大體以下:

不作 Hook
Test Case '-[PerformanceTests testPerformance_Collection]' measured [Time, seconds] average: 0.000, relative standard deviation: 151.327%, values: [0.000011, 0.000002, 0.000001, 0.000001, 0.000001, 0.000001, 0.000001, 0.000001, 0.000001, 0.000001]

作了 Hook 可是不觸發防禦邏輯
Test Case '-[PerformanceTests testPerformance_Collection]' measured [Time, seconds] average: 0.000, relative standard deviation: 83.636%, values: [0.000021, 0.000005, 0.000005, 0.000009, 0.000003, 0.000003, 0.000003, 0.000003, 0.000009, 0.000003]

作了 Hook 且觸發了防禦邏輯
Test Case '-[PerformanceTests testPerformance_Collection]' measured [Time, seconds] average: 0.000, relative standard deviation: 47.857%, values: [0.000026, 0.000010, 0.000009, 0.000009, 0.000008, 0.000009, 0.000009, 0.000008, 0.000009, 0.000009]

從上面數據能夠很直觀地看到,在不作任何優化的前提下性能降低十分明顯,效率損失甚至高達 3 倍以上,因此若是要作防禦,必須充分考慮到性能優化這些點。

其次,須要合理權衡開啓的防禦類型,目前咱們僅默認開啓線上反饋的常見類型,而不是開啓全部類型,其餘類型能夠配置爲動態開啓,根據用戶設備的閃退日誌開啓防禦。其中,Baymax 中提到的野指針防禦,在實踐中發現用處頗有限,由於只是作了延遲釋放,而不是真正意義上對野指針這種 crash 進行防禦,且因爲對系統的釋放時機進行了處理,與 Xcode 原來的 Zombie 機制有必定衝突,也會產生一些很奇葩的問題,不肯定性很高。

再次,各類Hook帶來的未知性,Crash 自己是非正常狀況下才產生的,若是一味地規避這種異常,可能會產生更多的異常狀況,特別是業務邏輯上會出現不可控制的流程。

最後,這套防禦方案的做用究竟有多大呢?根據筆者我的經驗來講,對於越成熟的團隊,防禦方案帶來的效果會越小。由於成熟團隊的代碼質量相對更高,一些低級錯誤出現的機率極小。但對於小團隊,或者歷史比較久的項目而言,這套方案帶來的幫助會比較大,畢竟坑老是防不勝防的。

推薦👇:

面試題持續整理更新中,若是你想一塊兒進階,不妨添加一下交流羣1012951431

面試題資料或者相關學習資料都在羣文件中 進羣便可下載!

 

 
相關文章
相關標籤/搜索