iOS KVO crash 自修復技術實現與原理解析
摘要: 【前言】KVO API設計非常不合理,於是有很多的KVO三方庫,比如 KVOController 用更優的API來規避這些crash,但是侵入性比較大,必須編碼規範來約束所有人都要使用該方式。有沒有什麼更優雅,無感知的接入方式?
KVO crash 自修復技術實現與原理解析
前言
【前言】KVO API設計非常不合理,於是有很多的KVO三方庫,比如 KVOController 用更優的API來規避這些crash,但是侵入性比較大,必須編碼規範來約束所有人都要使用該方式。有沒有什麼更優雅,無感知的接入方式?
簡介
KVO crash 也是非常常見的 Crash 型別,在探討 KVO crash 原因前,我們先來看一下傳統的KVO寫發:
#warning move this to top of .m file
//#define MyKVOContext(A) static void * const A = (void*)&A;
static void * const MyContext = (void*)&MyContext;
#warning move this to viewdidload or init method
// KVO註冊監聽:
// _A 監聽 _B 的 @"keyPath" 屬性
//[self.B addObserver: self.A forKeyPath:@"keyPath" options:NSKeyValueObservingOptionNew context:MyContext];
- (void)dealloc {
// KVO反註冊
[_B removeObserver:_A forKeyPath:@"keyPath"];
}
// KVO監聽執行
#warning — please move this method to the class of _A
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if(context != MyContext) {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
return;
}
if(context == MyContext) {
//if ([keyPath isEqualToString:@"keyPath"]) {
id newKey = change[NSKeyValueChangeNewKey];
BOOL boolValue = [newKey boolValue];
}
}
看到如上的寫發,大概我們就明白了 API 設計不合理的地方:
B 需要做的工作太多,B可能引起Crash的點也太多:
B 需要主動移除監聽者的時機,否則就crash:
B 在釋放變為nil後,hook dealloc時機
A 在釋放變為nil後 否則報錯 Objective-C Thread 1: EXC_BAD_ACCESS (code=EXC_I386_GPFLT)
KVO的被觀察者dealloc時仍然註冊著KVO導致的crash
B 不能移除監聽者A的時機,否則就crash:
B沒有被A監聽
B已經移除A的監聽。
新增KVO重複新增觀察者或重複移除觀察者(KVO 註冊觀察者與移除觀察者不匹配)導致的crash。
採取的措施:
B新增A監聽的時候,避免重複新增,移除的時候避免重複移除。
B dealloc時及時移除 A
A dealloc時,讓 B 移除A。
避免重複新增,避免重複移除。
報錯資訊一覽:
2018-01-24 16:08:54.100667+0800 BootingProtection[63487:29487624] *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: '<CYLObserverView: 0x7fb287002fb0; frame = (0 0; 207 368); layer = <CALayer: 0x604000039360>>: An -observeValueForKeyPath:ofObject:change:context: message was received but not handled.
防crash措施
於是有很多的KVO三方庫,比如 KVOController 用更優的API來規避這些crash,但是侵入性比較大,必須編碼規範來約束所有人都要使用該方式。有沒有什麼更優雅,無感知的接入方式?
那便是我們下面要講的 KVO crash 防護機制。
我們可以對比下其他的一些KVO防護方案:
網路上有一些類似的方案,“大白健康系統”方案大致如下:
KVO的被觀察者dealloc時仍然註冊著KVO導致的crash 的情況,可以將NSObject的dealloc swizzle, 在object dealloc的時候自動將其對應的kvodelegate所有和kvo相關的資料清空,然後將kvodelegate也置空。避免出現KVO的被觀察者dealloc時仍然註冊著KVO而產生的crash
這樣未免太過麻煩,我們可以藉助第三方庫 CYLDeallocBlockExecutor hook 任意一個物件的 dealloc 時機,然後在 dealloc 前進行我們需要進行的操作,因此也就不需要為 NSObject 加 flag 來進行全域性的篩選。flag 效率非常底,影響 app 效能。
“大白健康系統”思路是建立一個delegate,觀察者和被觀察者通過delegate間接建立聯絡,由於沒有demo原始碼,這種方案比較繁瑣。可以考慮建立一個雜湊表,用來儲存觀察者、keyPath的資訊,如果雜湊表裡已經有了相關的觀察者,keyPath資訊,那麼繼續新增觀察者的話,就不載進行新增,同樣移除觀察的時候,也現在雜湊表中進行查詢,如果存在觀察者,keypath資訊,那麼移除,如果沒有的話就不執行相關的移除操作。要實現這樣的思路就需要用到methodSwizzle來進行方法交換。我這通過寫了一個NSObject的cagegory來進行方法交換。示例程式碼如下:
下面是核心的swizzle方法:
- (void)cyl_crashProtectaddObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context{
if (!observer || !keyPath || keyPath.length == 0) {
return;
}
@synchronized (self) {
NSInteger kvoHash = [self _cyl_crashProtectHash:observer :keyPath];
if (!self.KVOHashTable) {
self.KVOHashTable = [NSHashTable hashTableWithOptions:NSPointerFunctionsStrongMemory];
}
if (![self.KVOHashTable containsObject:@(kvoHash)]) {
[self.KVOHashTable addObject:@(kvoHash)];
[self cyl_crashProtectaddObserver:observer forKeyPath:keyPath options:options context:context];
[self cyl_willDeallocWithSelfCallback:^(__unsafe_unretained id observedOwner, NSUInteger identifier) {
[observedOwner cyl_crashProtectremoveObserver:observer forKeyPath:keyPath context:context];
}];
__unsafe_unretained typeof(self) unsafeUnretainedSelf = self;
[observer cyl_willDeallocWithSelfCallback:^(__unsafe_unretained id observerOwner, NSUInteger identifier) {
[unsafeUnretainedSelf cyl_crashProtectremoveObserver:observerOwner forKeyPath:keyPath context:context];
}];
}
}
}
- (void)cyl_crashProtectremoveObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(void *)context {
//TODO: 加上 context 限制,防止父類、子類使用同一個keyPath。
[self cyl_crashProtectremoveObserver:observer forKeyPath:keyPath];
}
- (void)cyl_crashProtectremoveObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath{
//TODO: white list
if (!observer || !keyPath || keyPath.length == 0) {
return;
}
@synchronized (self) {
if (!observer) {
return;
}
NSInteger kvoHash = [self _cyl_crashProtectHash:observer :keyPath];
NSHashTable *hashTable = [self KVOHashTable];
if (!hashTable) {
return;
}
if ([hashTable containsObject:@(kvoHash)]) {
[self cyl_crashProtectremoveObserver:observer forKeyPath:keyPath];
[hashTable removeObject:@(kvoHash)];
}
}
}
之後我們就可以模擬dealloc中不寫removeObserver,同時也可以寫,
同時也可以多次 addObserver、removeObserver 這樣就完全不干擾我們平時的程式碼書寫邏輯了。