1. KVC賦值為什么能觸發(fā)KVO
上一篇KVO的原理及應用遺留了一個問題:實例變量ivar,通過kvc也是可以觸發(fā)kvo的,你知道為什么嗎?
關(guān)于這個問題,大概畫了個流程圖

- 在添加觀察者,觀察ivar的時候,會對支持KVO的進行setter的生成,這個setter生成之后會存儲在一個全局的集合中
- 對于觀察的屬性則會給派生的類增加一個set方法,方法的imp是_NSSetXXXValueAndNotify
- 對于觀察的ivar(沒有實現(xiàn)set的情況),則會根據(jù)kvc的取實例變量的方式得到Ivar然后生成一個setter并緩存到集合中
- 調(diào)用setValue:forKey的時候則去查找setter對象,執(zhí)行setter的imp,也就是_NSSetXXXValueAndNotify
- _NSSetXXXValueAndNotify的內(nèi)部則走了kvo的流程
KVC的取值賦值的邏輯,大家都比較清楚了,這里就不多說了,今天主要是看一下設置nil系統(tǒng)拋出異常的場景及處理
2. KVC賦值nil異常的情況
2.1 測試代碼
@interface TestKVOObject : NSObject
@property (nonatomic, assign) NSInteger testInteger;
@property (nonatomic, assign) NSRange testRange;
@end
- (void)testKVCNilValue {
TestKVOObject *test = [TestKVOObject new];
[test setValue:nil forKey:@"testInteger"];
}
執(zhí)行完后發(fā)現(xiàn)程序閃退了
*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '[<TestKVOObject 0x600003f939a0> setNilValueForKey]: could not set nil as the value for the key testInteger.'
程序執(zhí)行了setNilValueForKey然后閃退了;關(guān)于是怎么調(diào)用到setNilValueForKey可以用hopper查看下偽代碼,或者debug調(diào)試看看匯編,在set方法里會判斷傳的值是否為nil,為nil則執(zhí)行的方法的地址0x7fff86b9d188;我查看的是模擬器的Foundation.framework的偽代碼,所以我用lldb直接打印一下p (IMP)0x7fff86b9d188,可以看到0x00007fff86b9d188 ("setNilValueForKey:"),當對于某些類型的數(shù)據(jù)kvc賦值的時候如果value是nil就會走到setNilValueForKey分支
void __NSSetLongValueForKeyInIvar(int arg0) {
rbx = arg0;
if (rdx != 0x0) {
*(rbx + ivar_getOffset(r8)) = (*_objc_msgSend)(rdx, *0x7fff86b9ca70);
}
else {
rdi = rbx;
(*_objc_msgSend)(rdi, *0x7fff86b9d188);
}
return;
}
void __NSSetLongValueForKeyWithMethod(int arg0) {
r14 = arg0;
if (rdx != 0x0) {
rdi = r14;
rax = method_getImplementation(r8);
(rax)(rdi, method_getName(r8), (*_objc_msgSend)(rdx, *0x7fff86b9ca70));
}
else {
rdi = r14;
(*_objc_msgSend)(rdi, *0x7fff86b9d188);
}
return;
}
(lldb) p (IMP)0x7fff86b9d188
(IMP) $2 = 0x00007fff86b9d188 ("setNilValueForKey:")
2.2 問題分析
看看方法的注釋
/* Given that an invocation of -setValue:forKey: would be unable to set the keyed value because the type of the parameter of the corresponding accessor method is an NSNumber scalar type or NSValue structure type but the value is nil, set the keyed value using some other mechanism. The default implementation of this method raises an NSInvalidArgumentException. You can override it to map nil values to something meaningful in the context of your application.
*/
- (void)setNilValueForKey:(NSString *)key;
文檔說的很清楚了,當是NSNumber標量類型或NSValue結(jié)構(gòu)類型的實例,設置nil值的時候會拋出一個異常NSInvalidArgumentException
3. 如何解決
關(guān)于怎么解決這個問題,蘋果文檔注釋已經(jīng)給出了答案You can override it to map nil values to something meaningful in the context of your application
- (void)setNilValueForKey:(NSString *)key {
if ([key isEqualToString:@"testInteger"]) { // fix 設置number為nil的時候?qū)е聮伋霎惓? [self setValue:@(0) forKey:key];
}
}
只需要在類中重寫setNilValueForKey方法,然后設置對應的key的value為有意義的值就好了,比如NSInteger的屬性,我設置一個@(0)的默認值
然而作為一個稍微有點要求的程序員,這么干顯然不夠優(yōu)雅,有多個屬性,那不是要各種硬編碼去處理
3.1 重寫方法覆蓋所有值類型屬性的異常處理
我們不可能去一個屬性一個屬性的去判斷,去做異常的處理;可以用runtime的API去獲取到key對應的Ivar或者Method,然后獲取到它的encoding就能知道它是不是值類型了,是不是我們需要去處理的類型了
大致思路有了,現(xiàn)在開始實現(xiàn):
3.1.1 根據(jù)key獲取Ivar
獲取Ivar的整體思路:根據(jù)KVC的取值的順序_key、_isKey、key、isKey來依次拼接字符串得到對應的ivarName,在調(diào)用runtime的APIclass_getInstanceVariable來獲取到Ivar
- (nullable Ivar)hc_getIvarByKey:(NSString *)key {
if (![key isKindOfClass:[NSString class]] || key.length <= 0) {
return nil;
}
// 按照_key _isKey key isKey的方式去獲取ivar
NSString *firstCharacter = [key substringToIndex:1];
NSString *upperFirstKey = [key stringByReplacingCharactersInRange:NSMakeRange(0, 1) withString:firstCharacter.uppercaseString];
NSString *_keyName = [NSString stringWithFormat:@"_%@", key];
NSString *_isKeyName = [NSString stringWithFormat:@"_is%@", upperFirstKey];
NSString *keyName = key;
NSString *isKeyName = [NSString stringWithFormat:@"is%@", upperFirstKey];
Ivar ivar;
if ((ivar = [self hc_getIvarByIvarName:_keyName])
|| (ivar = [self hc_getIvarByIvarName:_isKeyName])
|| (ivar = [self hc_getIvarByIvarName:keyName])
|| (ivar = [self hc_getIvarByIvarName:isKeyName])) {
return ivar;
}
return nil;
}
- (nullable Ivar)hc_getIvarByIvarName:(NSString *)ivarNameString {
const char *ivarName = [ivarNameString cStringUsingEncoding:NSUTF8StringEncoding];
Ivar ivar = class_getInstanceVariable(self.class, ivarName);
return ivar;
}
3.1.2 根據(jù)Ivar解析encoding來判斷是否是需要處理的類型
這里所說的需要處理的類型就是NSNumber標量類型或NSValue結(jié)構(gòu)類型;如何判斷了,則可以根據(jù)encoding來判斷;這里有2種方案可以獲取到encoding信息
- @encode(type)函數(shù)一個個的打印
- 查閱官方的文檔Type Encodings
關(guān)于NSNumber值類型可以參照對照表:

關(guān)于Value類型結(jié)構(gòu)體類型:

那么我們按照文檔的規(guī)則,可以推導出NSRange的encode{_NSRange=QQ}

(lldb) po @encode(NSRange)
"{_NSRange=QQ}"
現(xiàn)在我們知道了需要處理的類型的encode信息,那么我們就拿已知的信息跟Ivar的typeEncoding來比較就能判斷是不是需要處理的類型的Ivar了
獲取Ivar的typeEncoding也是用runtime的API就可以獲取到const char *typeEncoding = ivar_getTypeEncoding(ivar)
例如NSRange得到的{_NSRange=\"location\"Q\"length\"Q},可以看到獲取到的跟@encode獲取到的差異就是后面的結(jié)構(gòu)體的字段有字段名的信息。
那么我們就判斷'='字符前面這一段就可以判斷是不是一個類型了
至此整體的思路有了:
- 獲取typeEncoding的第一個字符,判斷是對照表中number類型的,則處理number的設置nil的場景,直接設置一個
@(0)- typeEncoding[0]是字符'{'表示是value類型結(jié)構(gòu)體了,這時候判斷typeEncoding的'='字符前的字符是否一樣就判斷是否是value類型結(jié)構(gòu)體了,針對結(jié)構(gòu)體的設置nil的場景,則需要根據(jù)結(jié)構(gòu)體的不同分別去設置
- 如果不是需要處理的類型,則調(diào)用super的
setNilValueForKey走系統(tǒng)的處理邏輯
- (void)setNilValueForKey:(NSString *)key {
Ivar ivar = [self hc_getIvarByKey:key];
if (!ivar) {
[super setNilValueForKey:key];
}
const char *typeEncoding = ivar_getTypeEncoding(ivar);
switch (typeEncoding[0]) {
// NSNumber scalar type
case 'q': // longlong
case 'Q': // unsigned longlong
case 'i': // int
case 'I': // unsigned int
case 'l': // long
case 'L': // unsigned long
case 's': // short
case 'S': // unsigned short
case 'd': // double
case 'f': // float
[self setValue:@(0) forKey:key];
break;
case '{': {
char* idx = index(typeEncoding, '='); // 獲取'='字符串中第一個出現(xiàn)的參數(shù)'=' 地址,然后將該字符出現(xiàn)的地址返回
/*
eg:"0x000000010bac7c7a {_NSRange=\"location\"Q\"length\"Q}" idx則為 0x000000010bac7c83 "=\"location\"Q\"length\"Q}"
Printing description of idx:
(char *) idx = 0x000000010bac7c83 "=\"location\"Q\"length\"Q}"
Printing description of typeEncoding:
(const char *) typeEncoding = 0x000000010bac7c7a "{_NSRange=\"location\"Q\"length\"Q}"
*/
if (idx == NULL) { // 如果為空則表示沒有找到'=',此時走遠來的流程
[super setNilValueForKey:key];
}
// 處理NSValue的一些場景:比如NSRange、CGRect、CGPoint、CGSize;也就是NSValue structure type
/*
int strncmp(const char *str1, const char *str2, size_t n) 把 str1 和 str2 進行比較,最多比較前 n 個字節(jié)
如果返回值 < 0,則表示 str1 小于 str2。
如果返回值 > 0,則表示 str2 小于 str1。
如果返回值 = 0,則表示 str1 等于 str2。
*/
NSValue *value;
long cmpLength = idx - typeEncoding;
#define SAME_ENCODE(name) (strncmp(typeEncoding, @encode(name), cmpLength) == 0)
if (SAME_ENCODE(NSRange)) {
value = [NSValue valueWithRange:NSMakeRange(0, 0)];
} else if (SAME_ENCODE(CGPoint)) {
value = [NSValue valueWithCGPoint:CGPointZero];
} else if (SAME_ENCODE(CGSize)) {
value = [NSValue valueWithCGSize:CGSizeZero];
} else if (SAME_ENCODE(CGRect)) {
value = [NSValue valueWithCGRect:CGRectZero];
} else if (SAME_ENCODE(CGVector)) {
value = [NSValue valueWithCGVector:CGVectorMake(0, 0)];
} else if (SAME_ENCODE(UIEdgeInsets)) {
value = [NSValue valueWithUIEdgeInsets:UIEdgeInsetsZero];
} else if (SAME_ENCODE(UIOffset)) {
value = [NSValue valueWithUIOffset:UIOffsetZero];
} else if (SAME_ENCODE(CGAffineTransform)) {
value = [NSValue valueWithCGAffineTransform:CGAffineTransformIdentity];
}
#ifndef FOUNDATION_HAS_DIRECTIONAL_GEOMETRY
else if (@available(iOS 11.0, *)) {
if (SAME_ENCODE(NSDirectionalEdgeInsets)) {
value = [NSValue valueWithDirectionalEdgeInsets:NSDirectionalEdgeInsetsZero];
}
} else {
// Fallback on earlier versions
}
#endif
if (value != nil) {
[self setValue:value forKey:key];
} else {
[super setNilValueForKey:key];
}
}
break;
default:
[super setNilValueForKey:key];
break;
}
}
代碼中用到了一些C函數(shù),也是查了下文檔才了解了C的字符串的一些操作函數(shù),也簡單說明一下:
index函數(shù)
char* idx = index(typeEncoding, '=');
獲取'='字符串中第一個出現(xiàn)的參數(shù)'=' 地址,然后將該字符出現(xiàn)的地址返回
eg:typeEncoding為"0x000000010bac7c7a {_NSRange=\"location\"Q\"length\"Q}"
idx則為 0x000000010bac7c83 "=\"location\"Q\"length\"Q}"
Printing description of idx:
(char *) idx = 0x000000010bac7c83 "=\"location\"Q\"length\"Q}"
Printing description of typeEncoding:
(const char *) typeEncoding = 0x000000010bac7c7a "{_NSRange=\"location\"Q\"length\"Q}"
strncmp函數(shù)
int strncmp(const char *str1, const char *str2, size_t n) 把 str1 和 str2 進行比較,最多比較前 n 個字節(jié)
如果返回值 < 0,則表示 str1 小于 str2。
如果返回值 > 0,則表示 str2 小于 str1。
如果返回值 = 0,則表示 str1 等于 str2。
獲取需要比較的字符長度:
idx的地址是=字符的地址,typeEncoding的地址是首字符的地址,兩個一減就得到=字符之前的長度了
long cmpLength = idx - typeEncoding;
判斷是否是需要處理的類型:
只需要判斷=前面的字符是否一樣就可以了
(strncmp(typeEncoding, @encode(name), cmpLength) == 0)
至此,已經(jīng)解決了Number值類型,Value結(jié)構(gòu)體類型設置nil的異常處理了。
3.2 讓你的代碼更健壯
上面我是在類中重寫了setNilValueForKey方法,來處理的,這有個弊端就是不能對所有的類都生效,除非你重寫了基類中的實現(xiàn),這樣才能對所有的對象生效
顯然也是有手段的,hook掉NSObject的setNilValueForKey方法,將其實現(xiàn)改為上面的邏輯即可
這里我優(yōu)化了一下整體的獲取encoding信息的邏輯:先獲取set方法的參數(shù)encoding信息,如果沒有set方法,再判斷是否支持
accessInstanceVariablesDirectly再去獲取Ivar信息
整體的實現(xiàn)思路:
- 獲取set方法Method,如果有則獲取方法index為2的參數(shù)的typeEncoding信息
- 如果沒有set方法,判斷是否支持
accessInstanceVariablesDirectly,支持獲取Ivar信息得到它的typeEncoding信息- 獲取到的typeEncoding信息為空則調(diào)用原始的實現(xiàn)
- 比較typeEncoding跟@encode得到的信息,來確定是否是值類型,去做處理;不需要處理的情況也調(diào)用原始的實現(xiàn)
``
根據(jù)key獲取Method:
- (nullable Method)hc_getMethodByKey:(NSString *)key {
if (![key isKindOfClass:[NSString class]] || key.length <= 0) {
return nil;
}
NSString *firstCharacter = [key substringToIndex:1];
NSString *upperFirstKey = [key stringByReplacingCharactersInRange:NSMakeRange(0, 1) withString:firstCharacter.uppercaseString];
NSString *setKeyName = [NSString stringWithFormat:@"set%@:", upperFirstKey];
NSString *_setKeyName = [NSString stringWithFormat:@"_set%@:", upperFirstKey];
NSString *setIsKeyName = [NSString stringWithFormat:@"setIs%@:", upperFirstKey];
Method method;
#define METHOD_BY_NAME(selName) class_getInstanceMethod(self.class, NSSelectorFromString(selName))
if ((method = METHOD_BY_NAME(setKeyName))
|| (method = METHOD_BY_NAME(_setKeyName))
|| (method = METHOD_BY_NAME(setIsKeyName))) {
return method;
}
return nil;
}
完整的實現(xiàn)代碼:
@interface NSObject(HCKVCNilHandle)
@end
@implementation NSObject(HCKVCNilHandle)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Method originMethod = class_getInstanceMethod(self.class, @selector(setNilValueForKey:));
Method hookMethod = class_getInstanceMethod(self.class, @selector(hc_setNilValueForKey:));
method_exchangeImplementations(originMethod, hookMethod);
});
}
- (nullable Ivar)hc_getIvarByKey:(NSString *)key {
if (![key isKindOfClass:[NSString class]] || key.length <= 0) {
return nil;
}
// 按照_key _isKey key isKey的方式去獲取ivar
NSString *firstCharacter = [key substringToIndex:1];
NSString *upperFirstKey = [key stringByReplacingCharactersInRange:NSMakeRange(0, 1) withString:firstCharacter.uppercaseString];
NSString *_keyName = [NSString stringWithFormat:@"_%@", key];
NSString *_isKeyName = [NSString stringWithFormat:@"_is%@", upperFirstKey];
NSString *keyName = key;
NSString *isKeyName = [NSString stringWithFormat:@"is%@", upperFirstKey];
Ivar ivar;
if ((ivar = [self hc_getIvarByIvarName:_keyName])
|| (ivar = [self hc_getIvarByIvarName:_isKeyName])
|| (ivar = [self hc_getIvarByIvarName:keyName])
|| (ivar = [self hc_getIvarByIvarName:isKeyName])) {
return ivar;
}
return nil;
}
- (nullable Ivar)hc_getIvarByIvarName:(NSString *)ivarNameString {
const char *ivarName = [ivarNameString cStringUsingEncoding:NSUTF8StringEncoding];
Ivar ivar = class_getInstanceVariable(self.class, ivarName);
return ivar;
}
- (nullable Method)hc_getMethodByKey:(NSString *)key {
if (![key isKindOfClass:[NSString class]] || key.length <= 0) {
return nil;
}
NSString *firstCharacter = [key substringToIndex:1];
NSString *upperFirstKey = [key stringByReplacingCharactersInRange:NSMakeRange(0, 1) withString:firstCharacter.uppercaseString];
NSString *setKeyName = [NSString stringWithFormat:@"set%@:", upperFirstKey];
NSString *_setKeyName = [NSString stringWithFormat:@"_set%@:", upperFirstKey];
NSString *setIsKeyName = [NSString stringWithFormat:@"setIs%@:", upperFirstKey];
Method method;
#define METHOD_BY_NAME(selName) class_getInstanceMethod(self.class, NSSelectorFromString(selName))
if ((method = METHOD_BY_NAME(setKeyName))
|| (method = METHOD_BY_NAME(_setKeyName))
|| (method = METHOD_BY_NAME(setIsKeyName))) {
return method;
}
return nil;
}
- (void)hc_setNilValueForKey:(NSString *)key {
// 獲取是否有set方法
Method method = [self hc_getMethodByKey:key];
const char *typeEncoding = NULL;
if (method != nil) {
typeEncoding = method_copyArgumentType(method, 2); // 獲取參數(shù)的encoding信息,method有2個缺省參數(shù) self _cmd 所以這里是2
} else if ([self.class accessInstanceVariablesDirectly]) {
// 獲取ivar
Ivar ivar = [self hc_getIvarByKey:key];
if (ivar != nil) {
typeEncoding = ivar_getTypeEncoding(ivar);
}
}
if (typeEncoding == NULL) {
[self hc_setNilValueForKey:key];
return;
}
// 遍歷出所有的number、value類型的encoding,針對性的處理
// https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html
/*
Printing description of typeEncoding:
(const char *) typeEncoding = 0x000000010f4c5c7a "{_NSRange=\"location\"Q\"length\"Q}"
(lldb) po @encode(NSRange)
"{_NSRange=QQ}"
*/
switch (typeEncoding[0]) {
// NSNumber scalar type
case 'q': // longlong
case 'Q': // unsigned longlong
case 'i': // int
case 'I': // unsigned int
case 'l': // long
case 'L': // unsigned long
case 's': // short
case 'S': // unsigned short
case 'd': // double
case 'f': // float
[self setValue:@(0) forKey:key];
break;
case '{': {
char* idx = index(typeEncoding, '='); // 獲取'='字符串中第一個出現(xiàn)的參數(shù)'=' 地址,然后將該字符出現(xiàn)的地址返回
/*
eg:"0x000000010bac7c7a {_NSRange=\"location\"Q\"length\"Q}" idx則為 0x000000010bac7c83 "=\"location\"Q\"length\"Q}"
Printing description of idx:
(char *) idx = 0x000000010bac7c83 "=\"location\"Q\"length\"Q}"
Printing description of typeEncoding:
(const char *) typeEncoding = 0x000000010bac7c7a "{_NSRange=\"location\"Q\"length\"Q}"
*/
if (idx == NULL) { // 如果為空則表示沒有找到'=',此時走遠來的流程
[self hc_setNilValueForKey:key];
}
// 處理NSValue的一些場景:比如NSRange、CGRect、CGPoint、CGSize;也就是NSValue structure type
/*
int strncmp(const char *str1, const char *str2, size_t n) 把 str1 和 str2 進行比較,最多比較前 n 個字節(jié)
如果返回值 < 0,則表示 str1 小于 str2。
如果返回值 > 0,則表示 str2 小于 str1。
如果返回值 = 0,則表示 str1 等于 str2。
*/
NSValue *value;
long cmpLength = idx - typeEncoding;
#define SAME_ENCODE(name) (strncmp(typeEncoding, @encode(name), cmpLength) == 0)
if (SAME_ENCODE(NSRange)) {
value = [NSValue valueWithRange:NSMakeRange(0, 0)];
} else if (SAME_ENCODE(CGPoint)) {
value = [NSValue valueWithCGPoint:CGPointZero];
} else if (SAME_ENCODE(CGSize)) {
value = [NSValue valueWithCGSize:CGSizeZero];
} else if (SAME_ENCODE(CGRect)) {
value = [NSValue valueWithCGRect:CGRectZero];
} else if (SAME_ENCODE(CGVector)) {
value = [NSValue valueWithCGVector:CGVectorMake(0, 0)];
} else if (SAME_ENCODE(UIEdgeInsets)) {
value = [NSValue valueWithUIEdgeInsets:UIEdgeInsetsZero];
} else if (SAME_ENCODE(UIOffset)) {
value = [NSValue valueWithUIOffset:UIOffsetZero];
} else if (SAME_ENCODE(CGAffineTransform)) {
value = [NSValue valueWithCGAffineTransform:CGAffineTransformIdentity];
}
#ifndef FOUNDATION_HAS_DIRECTIONAL_GEOMETRY
else if (@available(iOS 11.0, *)) {
if (SAME_ENCODE(NSDirectionalEdgeInsets)) {
value = [NSValue valueWithDirectionalEdgeInsets:NSDirectionalEdgeInsetsZero];
}
} else {
// Fallback on earlier versions
}
#endif
if (value != nil) {
[self setValue:value forKey:key];
} else {
[self hc_setNilValueForKey:key];
}
}
break;
default:
[self hc_setNilValueForKey:key];
break;
}
}
@end
4. 總結(jié)
本文主要是對KVC設置value為nil的一些異常場景的處理,來達到讓程序遇到這種setNilValueForKey的情況不會崩潰;主要的思路就是拿到encoding信息,判斷是否是會發(fā)生異常的類型,進行處理。