在移動開發中,App 的閃退率是工程師十分關註且又頭疼的事情。去年,網易杭州研究院曾經針對 crash 的防護有提出『大白健康系統--iOS APP 運行時 Crash 自動修複系統』方案,使得 crash 防護這個想法真正被落實,但至今該方案的具體實現並沒有被開源。經過一年的時間,圈子裡也有一些開 ...
在移動開發中,App 的閃退率是工程師十分關註且又頭疼的事情。去年,網易杭州研究院曾經針對 crash 的防護有提出『大白健康系統--iOS APP 運行時 Crash 自動修複系統』方案,使得 crash 防護這個想法真正被落實,但至今該方案的具體實現並沒有被開源。經過一年的時間,圈子裡也有一些開發朋友,基於這套方案設計並開源了自己的 “Baymax”,比如『老司機 iOS 周報第七期』中曾提到的 BayMaxProtector。本文將會針對網易 Baymax 這套方案,結合團隊內的實踐結果,總結其在生產環境中可能遇到的問題及其解決方案,並提出一些自己對這套方案的思考。友情提示,閱讀本文前需對網易『大白健康系統--iOS APP 運行時 Crash 自動修複系統』一文有所瞭解,該文中已有的實現方案,本文不會再花更多筆墨進行贅述。
Crash 防護可選的方案
Crash 是什麼?
在探討 Crash 防護的方案之前,我們有必要對電腦領域 Crash 這個概念進行重新認識。對於 Crash 的概念,維基百科中是這麼定義的:
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.
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.
對於我們 iOS 應用層的 App,可簡單總結為應用執行了某些不被允許的操作觸發了系統拋出異常信號但又沒有處理這些異常信號從而被殺掉的現象,比如常見的閃退(crash to desktop)。在我們開發領域從拋出異常的對象上來看,一共可以分為三類內核導致的異常、應用自身的異常或其他進程導致的異常:
- 由操作系統內核捕獲硬體產生的異常信號,比如
EXC_BAD_ACCESS
,這類異常如果沒有被處理掉的話,會被轉發到SIGBUS
或SIGSEGV
等類型的 BSD 信號; - 由 SDK 開發者或上層應用開發者主動拋出的異常信號,比如各種常見的
NSException
,這類異常蘋果為了統一處理,最終會被轉發為SIGABRT
類的 BSD 信號; - 其他進程殺死你的應用;
這裡我們主要談最常見的前兩種異常。
可選的 Crash 防護方案
上面已經提到了 Crash 實際上我們觸發了異常,但又沒有去處理這些異常而導致的結果。那麼很自然的第一個防護方案便可以想到是去處理這些異常。
通過 NSUncaughtExceptionHandler
來捕獲並處理異常
蘋果的確提供有異常捕獲的 API 以供開發者使用——NSSetUncaughtExceptionHandler,開發者只需要傳入處理函數的指針,便可以處理掉應用中拋出的 NSException
類的異常。代碼寫起來就是:
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 預設會開啟
imagedebug executable
,它會在我們捕獲這些異常信號之前攔截掉,因此做這個測試需要手動將debug executable
功能關閉,或者不在 Xcode 連接調試下進行測試。
至此,似乎一切看起來都很順利,然而實踐過程中你會發現程式並沒有在你處理完這些異常後就能繼續進行。這與 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 本身是非正常情況下才產生的,如果一味地規避這種異常,可能會產生更多的異常情況,特別是業務邏輯上會出現不可控制的流程。
最後,這套防護方案的作用究竟有多大呢?根據筆者個人經驗來說,對於越成熟的團隊,防護方案帶來的效果會越小。因為成熟團隊的代碼質量相對更高,一些低級錯誤出現的概率極小。但對於小團隊,或者歷史比較久的項目而言,這套方案帶來的幫助會比較大,畢竟坑總是防不勝防的。