本文是 『Crash 防護系統』系列 第三篇。通過本文,您將瞭解到:
- KVC Crash 的主要原因
- KVC 搜索模式
- KVC Crash 防護方案
文中示例代碼在: bujige / YSC-Avoid-Crash
1. KVC Crash 的常見原因
KVC(Key Value Coding),即鍵值編碼,提供一種機制來間接訪問對象的屬性。而不是通過調用 Setter
、Getter
方法進行訪問。
KVC 日常使用造成崩潰的原因通常有以下幾個:
- key 不是對象的屬性,造成崩潰。
- keyPath 不正確,造成崩潰。
- key 爲 nil,造成崩潰。
- value 爲 nil,爲非對象設值,造成崩潰。
常見的使用 KVC 造成崩潰代碼:
/********************* KVCCrashObject.h 文件 *********************/
#import <Foundation/Foundation.h>
@interface KVCCrashObject : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSInteger age;
@end
/********************* KVCCrashObject.m 文件 *********************/
#import "KVCCrashObject.h"
@implementation KVCCrashObject
@end
/********************* ViewController.m 文件 *********************/
#import "ViewController.h"
#import "KVCCrashObject.h"
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
// 1. key 不是對象的屬性,造成崩潰
// [self testKVCCrash1];
// 2. keyPath 不正確,造成崩潰
// [self testKVCCrash2];
// 3. key 爲 nil,造成崩潰
// [self testKVCCrash4];
// 4. value 爲 nil,爲非對象設值,造成崩潰
// [self testKVCCrash4];
}
/**
1. key 不是對象的屬性,造成崩潰
*/
- (void)testKVCCrash1 {
// 崩潰日誌:[<KVCCrashObject 0x600000d48ee0> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key XXX.;
KVCCrashObject *objc = [[KVCCrashObject alloc] init];
[objc setValue:@"value" forKey:@"address"];
}
/**
2. keyPath 不正確,造成崩潰
*/
- (void)testKVCCrash2 {
// 崩潰日誌:[<KVCCrashObject 0x60000289afb0> valueForUndefinedKey:]: this class is not key value coding-compliant for the key XXX.
KVCCrashObject *objc = [[KVCCrashObject alloc] init];
[objc setValue:@"後廠村路" forKeyPath:@"address.street"];
}
/**
3. key 爲 nil,造成崩潰
*/
- (void)testKVCCrash3 {
// 崩潰日誌:'-[KVCCrashObject setValue:forKey:]: attempt to set a value for a nil key
NSString *keyName;
// key 爲 nil 會崩潰,如果傳 nil 會提示警告,傳空變量則不會提示警告
KVCCrashObject *objc = [[KVCCrashObject alloc] init];
[objc setValue:@"value" forKey:keyName];
}
/**
4. value 爲 nil,造成崩潰
*/
- (void)testKVCCrash4 {
// 崩潰日誌:[<KVCCrashObject 0x6000028a6780> setNilValueForKey]: could not set nil as the value for the key XXX.
// value 爲 nil 會崩潰
KVCCrashObject *objc = [[KVCCrashObject alloc] init];
[objc setValue:nil forKey:@"age"];
}
@end
那麼如何來解決這種崩潰問題呢?
首先我們需要先來了解下 KVC 在執行時,具體的搜索模式。也就是 KVC 內部的執行流程。根據瞭解了 KVC 內部的具體執行流程,我們才能知道在哪個步驟對其進行防護。
2. KVC 搜索模式
2.1 KVC Setter 搜索模式
系統在執行 setValue:forKey:
方法時,會把 key
和 value
作爲輸入參數,並嘗試在接收調用對象的內部,給屬性 key
設置 value
值。通過以下幾個步驟:
- 按順序查找名爲
set<Key>:
、_set<Key>:
、setIs<Key>:
方法。如果找到方法,則執行該方法,使用輸入參數設置變量,則setValue:forKey:
完成執行。如果沒找到方法,則執行下一步。 - 訪問類的
accessInstanceVariablesDirectly
屬性。如果accessInstanceVariablesDirectly
屬性返回YES
,就按順序查找名爲_<key>
、_is<Key>
、<key>
、is<Key>
的實例變量,如果找到了對應的實例變量,則使用輸入參數設置變量。則setValue:forKey:
完成執行。如果未找到對應的實例變量,或者accessInstanceVariablesDirectly
屬性返回NO
則執行下一步。 - 調用
setValue: forUndefinedKey:
方法,並引發崩潰。
下邊我們通過圖示來展示一下這個流程。
- 相關代碼:
KVCCrashObject *objc = [[KVCCrashObject alloc] init];
[objc setValue:@"value" forKey:@"name"];
- KVC
setValue:forKey:
搜索模式流程圖:
2.2 KVC Getter 搜索模式
系統在執行 valueForKey:
方法時,會將給定的 key
作爲輸入參數,在調用對象的內部進行以下幾個步驟:
- 按順序查找名爲
get<Key>
、<key>
、is<Key>
、_<key>
的訪問方法。如果找到,調用該方法,並繼續執行步驟 5。否則繼續向下執行步驟 2。 - 搜索形如
countOf<Key>
、objectIn<Key>AtIndex:
、<key>AtIndexes:
的方法。- 如果實現了
countOf<Key>
方法,並且實現了objectIn<Key>AtIndex:
和<key>AtIndexes:
這兩個方法的任意一個方法,系統就會以 NSArray 爲父類,動態生成一個類型爲 NSKeyValueArray 的集合類對象,並調用上邊的實現方法,將結果直接返回。 - 如果對象還實現了形如
get<Key>:range:
的方法,系統也會在必要的時候自動調用。 - 如果上述操作不成功則繼續向下執行步驟 3。
- 如果實現了
- 如果上邊兩步失敗,系統就會查找形如
countOf<Key>
、enumeratorOf<Key>
、memberOf<Key>:
的方法。系統會自動生成一個 NSSet 類型的集合類對象,該對象響應所有 NSSet 方法並將結果返回。如果查找失敗,則執行步驟 4。 - 如果上邊三步失敗,系統就會訪問類的
accessInstanceVariablesDirectly
方法。- 如果返回
YES
,就按順序查找名爲_<key>
、_is<Key>
、<key>
、is<Key>
的實例變量。如果找到了對應的實例變量,則直接獲取實例變量的值。並繼續執行步驟 5。 - 如果返回
NO
,或者未找到對應的實例變量,則繼續執行步驟 6。
- 如果返回
- 分爲三種情況:
- 如果檢索到的屬性值是對象指針,則直接返回結果。
- 如果檢索到的屬性值是
NSNumber
支持的基礎數據類型,則將其存儲在NSNumber
實例中並返回該值。 - 如果檢索到的屬性值是
NSNumber
不支持的數據類型,則轉換爲NSValue
對象並返回該對象。
- 如果一切都失敗了,調用
valueForUndefinedKey:
,並引發崩潰。
3. KVC Crash 防護方案
- 從 2.1 KVC Setter 搜索模式 和 2.2 KVC Getter 搜索模式 可以看出:
setValue:forKey:
執行失敗會調用setValue: forUndefinedKey:
方法,並引發崩潰。valueForKey:
執行失敗會調用valueForUndefinedKey:
方法,並引發崩潰。
所以,爲了進行 KVC Crash 防護,我們就需要重寫 setValue: forUndefinedKey:
方法和 valueForUndefinedKey:
方法。重寫這兩個方法之後,就可以防護 1. key 不是對象的屬性 和 2. keyPath 不正確 這兩種崩潰情況了。
那麼 3. key 爲 nil,造成崩潰 的情況,該怎麼防護呢?**
我們可以利用 Method Swizzling 方法,在 NSObject 的分類中將 setValue:forKey:
和 ysc_setValue:forKey:
進行方法交換。然後在自定義的方法中,添加對 key 爲 nil 這種類型的判斷。
注意:這裏我看到另外一個開發者不是很建議 Hook 掉系統的
setValue:forKey:
方法,說是爲了儘可能少的對系統方法產生邏輯判斷。這裏我也持保留意見。小夥伴可以親自試驗一下。
作者文章鏈接:iOS 中的 crash 防護(二)KVC 造成的 crash
還有最後一種 4. value 爲 nil,爲非對象設值,造成崩潰 的情況。
在 NSKeyValueCoding.h
文件中,有一個 setNilValueForKey:
方法。上邊的官方註釋給了我們答案。
在調用 setValue:forKey:
方法時,系統如果查找到名爲 set<Key>:
方法的時候,會去檢測 value 的參數類型,如果參數類型爲 NSNmber 的標量類型或者是 NSValue 的結構類型,但是 value 爲 nil 時,會自動調用 setNilValueForKey:
方法。這個方法的默認實現會引發崩潰。
所以爲了防止這種情況導致的崩潰,我們可以通過重寫 setNilValueForKey:
來解決。
至此,上文提到的 KVC 使用不當造成的四種類型崩潰就都解決了。下面我們來看下具體實現代碼。
- 具體防護代碼:
/********************* NSObject+KVCDefender.h 文件 *********************/
#import <Foundation/Foundation.h>
@interface NSObject (KVCDefender)
@end
/********************* NSObject+KVCDefender.m 文件 *********************/
#import "NSObject+KVCDefender.h"
#import "NSObject+MethodSwizzling.h"
@implementation NSObject (KVCDefender)
// 不建議攔截 `setValue:forKey:` 方法
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// 攔截 `setValue:forKey:` 方法,替換自定義實現
[NSObject yscDefenderSwizzlingInstanceMethod:@selector(setValue:forKey:)
withMethod:@selector(ysc_setValue:forKey:)
withClass:[NSObject class]];
});
}
- (void)ysc_setValue:(id)value forKey:(NSString *)key {
if (key == nil) {
NSString *crashMessages = [NSString stringWithFormat:@"crashMessages : [<%@ %p> setNilValueForKey]: could not set nil as the value for the key %@.",NSStringFromClass([self class]),self,key];
NSLog(@"%@", crashMessages);
return;
}
[self ysc_setValue:value forKey:key];
}
- (void)setNilValueForKey:(NSString *)key {
NSString *crashMessages = [NSString stringWithFormat:@"crashMessages : [<%@ %p> setNilValueForKey]: could not set nil as the value for the key %@.",NSStringFromClass([self class]),self,key];
NSLog(@"%@", crashMessages);
}
- (void)setValue:(id)value forUndefinedKey:(NSString *)key {
NSString *crashMessages = [NSString stringWithFormat:@"crashMessages : [<%@ %p> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key: %@,value:%@'",NSStringFromClass([self class]),self,key,value];
NSLog(@"%@", crashMessages);
}
- (nullable id)valueForUndefinedKey:(NSString *)key {
NSString *crashMessages = [NSString stringWithFormat:@"crashMessages :[<%@ %p> valueForUndefinedKey:]: this class is not key value coding-compliant for the key: %@",NSStringFromClass([self class]),self,key];
NSLog(@"%@", crashMessages);
return self;
}
@end
經過測試,剛纔因爲使用 KVC 不當造成崩潰都已經被解決了。