該文章屬於<簡書 — 劉小壯>原創,轉載請註明:
<簡書 — 劉小壯> https://www.jianshu.com/p/1d39bc610a5b
在工作中經常會使用到
KVC
,但是很多人對於KVC
的實現原理並不太清楚。比如說KVC
在進行存取時,是怎麼進行查詢並賦值的。網上有很多講
KVC
的文章,但是有很多質量並不高。這兩天抽空把我所理解的KVC
寫出來,當做學習交流,正好也讓各位大神幫我指正一下,十分感謝!
協議定義
KVC
全稱是Key Value Coding
,定義在NSKeyValueCoding.h
檔案中,是一個非正式協議。KVC
提供了一種間接訪問其屬性方法或成員變數的機制,可以通過字串來訪問對應的屬性方法或成員變數。
在NSKeyValueCoding
中提供了KVC
通用的訪問方法,分別是getter
方法valueForKey:
和setter
方法setValue:forKey:
,以及其衍生的keyPath
方法,這兩個方法各個類通用的。並且由KVC
提供預設的實現,我們也可以自己重寫對應的方法來改變實現。
基礎操作
KVC
主要對三種型別進行操作,基礎資料型別及常量、物件型別、集合型別。
@interface BankAccount : NSObject
@property (nonatomic, strong) NSNumber *currentBalance;
@property (nonatomic, strong) Person *owner;
@property (nonatomic, strong) NSArray<Transaction *> *transactions;
@end
在使用KVC
時,直接將屬性名當做key
,並設定value
,即可對屬性進行賦值。
[myAccount setValue:@(100.0) forKey:@"currentBalance"];
keyPath
除了對當前物件的屬性進行賦值外,還可以對其更“深層”的物件進行賦值。例如對當前物件的address
屬性的street
屬性進行賦值。KVC
進行多級訪問時,直接類似於屬性呼叫一樣用點語法進行訪問即可。
[myAccount setValue:@"中關村大街" forKeyPath:@"address.street"];
通過keyPath
對陣列進行取值時,並且陣列中儲存的物件型別都相同,可以通過valueForKeyPath:
方法指定取出陣列中所有物件的某個欄位。例如下面例子中,通過valueForKeyPath:
將陣列中所有物件的name
屬性值取出,並放入一個陣列中返回。
NSArray *names = [array valueForKeyPath:@"name"];
多值操作
需要注意的是,雖然看到dictionary
的字樣,下面兩個方法並不是字典的方法。
KVC
還有更強大的功能,可以根據給定的一組key
,獲取到一組value
,並且以字典的形式返回,獲取到字典後可以通過key
從字典中獲取到value
。
- (NSDictionary<NSString *, id> *)dictionaryWithValuesForKeys:(NSArray<NSString *> *)keys;
同樣,也可以通過KVC
進行批量賦值。在物件呼叫setValuesForKeysWithDictionary:
方法時,可以傳入一個包含key
、value
的字典進去,KVC
可以將所有資料按照屬性名和字典的key
進行匹配,並將value
給User
物件的屬性賦值。
- (void)setValuesForKeysWithDictionary:(NSDictionary<NSString *, id> *)keyedValues;
實用技巧
在專案中經常會遇到字典轉模型的情況,如果在自定義的init
方法裡逐個賦值,這樣每次資料發生改變還需要改賦值語句。然而通過KVC
為我們提供的賦值API,可以對資料進行批量賦值。假設有以下JSON
資料並定義User
類,在外界通過setValuesForKeysWithDictionary:
方法對User
進行賦值。
JSON資料:
{
"username": "lxz",
"age": 25,
"id": 100
}
@interface User : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSString age;
@property (nonatomic, assign) NSInteger userId;
@end
@implementation User
- (void)setValue:(id)value forUndefinedKey:(NSString *)key {
if ([key isEqualToString:@"id"]) {
self.userId = [value integerValue];
}
}
@end
賦值時會遇到一些問題,例如伺服器會返回一個id
欄位,但是對於客戶端來說id
是系統保留欄位,可以重寫setValue:forUndefinedKey:
方法並在內部處理id
引數的賦值。
轉換時需要伺服器資料和類定義匹配,欄位數量和欄位名都應該匹配。如果User
比伺服器資料多,則伺服器沒傳的欄位為空。如果服務端傳遞的資料User
中沒有定義,則會導致崩潰。
在KVC
進行屬性賦值時,內部會對基礎資料型別做處理,不需要手動做NSNumber
的轉換。需要注意的是,NSArray
和NSDictionary
等集合物件,value
都不能是nil
,否則會導致Crash
。
異常資訊
當根據KVC
搜尋規則,沒有搜尋到對應的key
或者keyPath
,則會呼叫對應的異常方法。異常方法的預設實現,在異常發生時會丟擲一個NSUndefinedKeyException
的異常,並且應用程式Crash
。
我們可以重寫下面兩個方法,根據業務需求合理的處理KVC
導致的異常。
- (nullable id)valueForUndefinedKey:(NSString *)key;
- (void)setValue:(nullable id)value forUndefinedKey:(NSString *)key;
異常處理
當通過KVC
給某個非物件的屬性賦值為nil
時,此時KVC
會呼叫屬性所屬物件的setNilValueForKey:
方法,並丟擲NSInvalidArgumentException
的異常,並使應用程式Crash
。
我們可以通過重寫下面方法,在發生這種異常時進行處理。例如給name
賦值為nil
的時候,就可以重寫setNilValueForKey:
方法並表示name
是空的。
- (void)setNilValueForKey:(NSString *)key {
if ([key isEqualToString:@"name"]) {
[self setValue:@"" forKey:@”age”];
} else {
[super setNilValueForKey:key];
}
}
集合屬性操作
根據KVO
的實現原理,是在執行時生成新的子類並重寫其setter
方法,在其內容發生改變時傳送訊息。但這只是對屬性直接進行賦值會觸發,如果屬性是容器物件,對容器物件進行add
或remove
操作,則不會呼叫KVO
的方法。可以通過KVC
對應的API
來配合使用,使容器物件內部發生改變時也能觸發KVO
。
在進行容器物件操作時,先呼叫下面方法通過key
或者keyPath
獲取集合物件,然後再對容器物件進行add
或remove
等操作時,就會觸發KVO
的訊息通知了。
- (NSMutableArray *)mutableArrayValueForKey:(NSString *)key;
- (NSMutableOrderedSet *)mutableOrderedSetValueForKey:(NSString *)key API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0));
- (NSMutableSet *)mutableSetValueForKey:(NSString *)key;
keyPath方法:
- (NSMutableArray *)mutableArrayValueForKeyPath:(NSString *)keyPath;
- (NSMutableOrderedSet *)mutableOrderedSetValueForKeyPath:(NSString *)keyPath API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0));
- (NSMutableSet *)mutableSetValueForKeyPath:(NSString *)keyPath;
集合運算子
KVC
提供的valueForKeyPath:
方法非常強大,可以通過該方法對集合物件進行“深入”操作,在其keyPath
中巢狀集合運算子,例如求一個陣列中物件某個屬性的count
。(集合物件主要指NSArray
和NSSet
,但不包括NSDictionary
)
上面表示式主要分為三部分,left
部分是要操作的集合物件,如果呼叫KVC
的物件本來就是集合物件,則left
可以為空。中間部分是表示式,表示式一般以@符號開頭。後面是進行運算的屬性。
集合運算子主要分為三類:
- 集合操作符:處理集合包含的物件,並根據操作符的不同返回不同的型別,返回值以
NSNumber
為主。 - 陣列操作符:根據操作符的條件,將符合條件的物件包含在陣列中返回。
- 巢狀操作符:處理集合物件中巢狀其他集合物件的情況,返回結果也是一個集合物件。
example
下面是為了方便模擬KVC
操作,而建立的測試程式碼。定義Transaction
類為模型類,類中包含三種型別的屬性。並定義BankAccount
類,其中包含一個陣列,下面的程式碼示例就都是操作這個陣列的,並且陣列包含所有Transaction
物件。
@interface Transaction : NSObject
@property (nonatomic, strong) NSString *payee;
@property (nonatomic, strong) NSNumber *amount;
@property (nonatomic, strong) NSDate *date;
@end
@interface BankAccount : NSObject
@property (nonatomic, strong) NSArray *transactions;
@end
集合操作符
集合操作符處理NSArray
和NSSet
及其子類這樣的集合物件,並根據不同的操作符返回不同型別的物件,返回值一般都是NSNumber
。
-
@avg
用來計算集合中right keyPath
指定的屬性的平均值。
NSNumber *transactionAverage = [self.transactions valueForKeyPath:@"@avg.amount"];
-
@count
用來計算集合的總數。
NSNumber *numberOfTransactions = [self.transactions valueForKeyPath:@"@count"];
備註:@count
操作符比較特殊,它不需要寫right keyPath
,即使寫了也會被忽略。
-
@sum
用來計算集合中right keyPath
指定的屬性的總和。
NSNumber *amountSum = [self.transactions valueForKeyPath:@"@sum.amount"];
-
@max
用來查詢集合中right keyPath
指定的屬性的最大值。
NSDate *latestDate = [self.transactions valueForKeyPath:@"@max.date"];
-
@min
用來查詢集合中right keyPath
指定的屬性的最小值。
NSDate *earliestDate = [self.transactions valueForKeyPath:@"@min.date"];
備註:@max
和@min
在進行判斷時,都是通過呼叫compare:
方法進行判斷,所以可以通過重寫該方法對判斷過程進行控制。
陣列操作符
-
@unionOfObjects
將集合物件中,所有payee
物件放在一個陣列中並返回。
NSArray *payees = [self.transactions valueForKeyPath:@"@unionOfObjects.payee"];
-
@distinctUnionOfObjects
將集合物件中,所有payee
物件放在一個陣列中,並將陣列進行去重後返回。
NSArray *distinctPayees = [self.transactions valueForKeyPath:@"@distinctUnionOfObjects.payee"];
注意:以上兩個方法中,如果操作的屬性為nil
,在新增到陣列中時會導致Crash
。
巢狀操作符
由於巢狀操作符是需要對巢狀的集合物件進行操作,所以新建一個arrayOfArrays
物件,其中包含兩個陣列,陣列中儲存的都是Transaction
型別物件。
NSArray *moreTransactions = ....;
NSArray *arrayOfArrays = @[self.transactions, moreTransactions];
-
@unionOfArrays
是用來操作集合內部的集合物件,將所有right keyPath
對應的物件放在一個陣列中返回。
NSArray *collectedPayees = [arrayOfArrays valueForKeyPath:@"@unionOfArrays.payee"];
-
@distinctUnionOfArrays
是用來操作集合內部的集合物件,將所有right keyPath
對應的物件放在一個陣列中,並進行排重。
NSArray *collectedDistinctPayees = [arrayOfArrays valueForKeyPath:@"@distinctUnionOfArrays.payee"];
-
@distinctUnionOfSets
是用來操作集合內部的集合物件,將所有right keyPath
對應的物件放在一個set
中,並進行排重。
NSSet *collectedPayees = [arrayOfArrays valueForKeyPath:@"@distinctUnionOfSets.payee"];
小技巧
如果在集合物件中操作的屬性,本來就是NSNumber
型別,則可以像下面這樣,直接用self
代表值自身。
NSArray *array = @[@(productA.price), @(productB.price), @(productC.price), @(productD.price)];
NSNumber *avg = [array valueForKeyPath:@"@avg.self"];
非物件值處理
KVC
是支援基礎資料型別和結構體的,可以在setter
和getter
的時候,通過NSValue
和NSNumber
來轉換為OC
物件。Swift
中不存在這樣的需求,因為Swift
中所有變數都是物件。
以下是結構體轉換的示例程式碼,可以呼叫initWithBool:
方法對基礎資料型別進行包裝,除了呼叫方法外還可以通過字面量實現,例如@(YES)
的呼叫。通過NSNumber
的boolValue
屬性轉換為基礎資料型別。
@property (nonatomic, assign, readonly) BOOL boolValue;
- (NSNumber *)initWithBool:(BOOL)value NS_DESIGNATED_INITIALIZER;
結構體轉換的程式碼定義在UIGeometry.h
中,以NSValue
的Category
形式存在。NSValue
對CGPoint
、CGRect
等結構體都提供了轉換方法,例如下面是對CGPoint
進行轉換的示例程式碼。
@property(nonatomic, assign, readonly) CGPoint CGPointValue;
+ (NSValue *)valueWithCGPoint:(CGPoint)point;
需要注意的是,無論什麼時候都不應該給setter
中傳入nil
,會導致Crash
並引起NSInvalidArgumentException
異常。
屬性驗證
在呼叫KVC
時可以先進行驗證,驗證通過下面兩個方法進行,支援key
和keyPath
兩種方式。驗證方法預設實現返回YES
,可以通過重寫對應的方法修改驗證邏輯。
驗證方法需要我們手動呼叫,並不會在進行KVC
的過程中自動呼叫。
- (BOOL)validateValue:(inout id _Nullable * _Nonnull)ioValue forKey:(NSString *)inKey error:(out NSError **)outError;
- (BOOL)validateValue:(inout id _Nullable * _Nonnull)ioValue forKeyPath:(NSString *)inKeyPath error:(out NSError **)outError;
下面是使用驗證方法的例子。在validateValue
方法的內部實現中,如果傳入的value
或key
有問題,可以通過返回NO
來表示錯誤,並設定NSError
物件。
Person *person = [[Person alloc] init];
NSError *error;
NSString *name = @"John";
if (![person validateValue:&name forKey:@"name" error:&error]) {
NSLog(@"%@", error);
}
單獨驗證
KVC
還支援對單獨屬性做驗證,可以通過定義validate<Key>:error:
格式的方法,並在方法內部實現驗證程式碼。在編寫KVC
驗證程式碼的時候,應該先查詢屬性有沒有自定義validate
方法,然後再查詢validateValue:
方法,如果有則呼叫自己實現的方法,如果兩個方法都沒有實現則預設返回YES
。
- (BOOL)validateName:(id *)ioValue error:(NSError * __autoreleasing *)outError{
if ((*ioValue == nil) || ([(NSString *)*ioValue length] < 2)) {
if (outError != NULL) {
*outError = [NSError errorWithDomain:PersonErrorDomain
code:PersonInvalidNameCode
userInfo:@{ NSLocalizedDescriptionKey
: @"Name too short" }];
}
return NO;
}
return YES;
}
我覺得KVC
應該支援validateValue
自動驗證,在呼叫setValue
或getValue
時自動進行驗證,如果不符合驗證規則,就呼叫失敗。如果外界使用的地方都先呼叫一次validateValue
的話,這是很麻煩的。當然也有解決方法,可以通過Method Swizzling
方法hook
住setValue
和getValue
方法。
搜尋規則
KVC
在通過key
或者keyPath
進行操作的時候,可以查詢屬性方法、成員變數等,查詢的時候可以相容多種命名。具體的查詢規則要以官方文件為主,所以我把官方文件翻譯了一下寫在下面。
在KVC
的實現中,依賴setter
和getter
的方法實現,所以方法命名應該符合蘋果要求的規範,否則會導致KVC
失敗。
在學習KVC
的搜尋規則前,要先弄明白一個屬性的作用,這個屬性在搜尋過程中起到很重要的作用。這個屬性表示是否允許讀取例項變數的值,如果為YES
則在KVC
查詢的過程中,從記憶體中讀取屬性例項變數的值。
@property (class, readonly) BOOL accessInstanceVariablesDirectly;
基礎Getter搜尋模式
這是valueForKey:
的預設實現,給定一個key
當做輸入引數,開始下面的步驟,在這個接收valueForKey:
方法呼叫的類內部進行操作。
- 通過
getter
方法搜尋例項,例如get<Key>
,<key>
,is<Key>
,_<key>
的拼接方案。按照這個順序,如果發現符合的方法,就呼叫對應的方法並拿著結果跳轉到第五步。否則,就繼續到下一步。 - 如果沒有找到簡單的
getter
方法,則搜尋其匹配模式的方法countOf<Key>
、objectIn<Key>AtIndex:
、<key>AtIndexes:
。如果找到其中的第一個和其他兩個中的一個,則建立一個集合代理物件,該物件響應所有
NSArray
的方法並返回該物件。否則,繼續到第三步。代理物件隨後將
NSArray
接收到的countOf<Key>
、objectIn<Key>AtIndex:
、<key>AtIndexes:
的訊息給符合KVC
規則的呼叫方。當代理物件和
KVC
呼叫方通過上面方法一起工作時,就會允許其行為類似於NSArray
一樣。 - 如果沒有找到
NSArray
簡單存取方法,或者NSArray
存取方法組。則查詢有沒有countOf<Key>
、enumeratorOf<Key>
、memberOf<Key>:
命名的方法。如果找到三個方法,則建立一個集合代理物件,該物件響應所有
NSSet
方法並返回。否則,繼續執行第四步。此代理物件隨後轉換
countOf<Key>
、enumeratorOf<Key>
、memberOf<Key>:
方法呼叫到建立它的物件上。實際上,這個代理物件和NSSet
一起工作,使得其表象上看起來是NSSet
。 - 如果沒有發現簡單
getter
方法,或集合存取方法組,以及接收類方法accessInstanceVariablesDirectly
是返回YES
的。搜尋一個名為_<key>
、_is<Key>
、<key>
、is<Key>
的例項,根據他們的順序。如果發現對應的例項,則立刻獲得例項可用的值並跳轉到第五步,否則,跳轉到第六步。
- 如果取回的是一個物件指標,則直接返回這個結果。
如果取回的是一個基礎資料型別,但是這個基礎資料型別是被NSNumber
支援的,則儲存為NSNumber
並返回。
如果取回的是一個不支援NSNumber
的基礎資料型別,則通過NSValue
進行儲存並返回。 - 如果所有情況都失敗,則呼叫
valueForUndefinedKey:
方法並丟擲異常,這是預設行為。但是子類可以重寫此方法。
基礎Setter搜尋模式
這是setValue:forKey:
的預設實現,給定輸入引數value
和key
。試圖在接收呼叫物件的內部,設定屬性名為key
的value
,通過下面的步驟:
- 查詢
set<Key>:
或_set<Key>
命名的setter
,按照這個順序,如果找到的話,呼叫這個方法並將值傳進去(根據需要進行物件轉換)。 - 如果沒有發現一個簡單的
setter
,但是accessInstanceVariablesDirectly
類屬性返回YES
,則查詢一個命名規則為_<key>
、_is<Key>
、<key>
、is<Key>
的例項變數。根據這個順序,如果發現則將value
賦值給例項變數。 - 如果沒有發現
setter
或例項變數,則呼叫setValue:forUndefinedKey:
方法,並預設提出一個異常,但是一個NSObject
的子類可以提出合適的行為。
NSMutableArray搜尋模式
這是mutableArrayValueForKey:
的預設實現,給一個key
當做輸入引數。在接收訪問器呼叫的物件中,返回一個名為key
的可變代理陣列,這個代理陣列就是用來響應外界KVO
的物件,通過下面的步驟進行查詢:
- 查詢一對方法
insertObject:in<Key>AtIndex:
和removeObjectFrom<Key>AtIndex:
(相當於NSMutableArray
的原始方法insertObject:atIndex:
和removeObjectAtIndex:
)或者方法名是insert<Key>:atIndexes:
和remove<Key>AtIndexes:
(相當於NSMutableArray
的原始方法insertObjects:atIndexes:
和removeObjectsAtIndexes:
)。如果找到最少一個
insert
方法和最少一個remove
方法,則返回一個代理物件,來響應傳送給NSMutableArray
的組合訊息insertObject:in<Key>AtIndex:
、removeObjectFrom<Key>AtIndex:
、insert<Key>:atIndexes:
,和remove<Key>AtIndexes:
訊息。當物件接收一個
mutableArrayValueForKey:
訊息並實現可選替換方法,例如replaceObjectIn<Key>AtIndex:withObject:
或replace<Key>AtIndexes:with<Key>:
方法,代理物件會在適當的情況下使用它們,以獲得最佳效能。 - 如果物件沒有可變陣列方法,查詢一個替代方法,命名格式為
set<Key>:
。在這種情況下,向mutableArrayValueForKey:
的原始響應者傳送一個set<Key>:
訊息,來返回一個代理物件來響應NSMutableArray
事件。提示:
這一步描述的機制遠不如上一步有效,因為它可能重複建立新的集合物件,而不是修改現有的物件。因此,在自己設計的KVC
時應該儘量避免它。 - 如果沒有可變陣列的方法,也沒有找到訪問器,但接受響應的類
accessInstanceVariablesDirectly
屬性返回YES
,則查詢一個名為_<key>
或<key>
的例項變數。按照這個順序,如果找到例項變數,則返回一個代理物件。改物件將接收所有
NSMutableArray
傳送過來的訊息,通常是NSMutableArray
或其子類。 - 如果所有情況都失敗,則返回一個可變的集合代理物件。當它接收
NSMutableArray
訊息時,傳送一個setValue:forUndefinedKey:
訊息給接收mutableArrayValueForKey:
訊息的原始物件。這個
setValue:forUndefinedKey:
的預設實現是提出一個NSUndefinedKeyException
異常,但是子類可以重寫這個實現。
其他搜尋模式
還有NSMutableSet
和NSMutableOrderedSet
兩種搜尋模式,這兩種搜尋模式和NSMutableArray
步驟相同,只是搜尋和呼叫的方法不同。詳細的搜尋方法都可以在KVC官方文件中找到,再套用上面的流程即可理解。
程式碼示例
根據上面KVC
查詢規則的描述,我們定義一個TestObject
類,並指定其他setter
和getter
,以及合成為其他的成員變數,看KVC
是否能夠找到屬性的物件並賦值。
@interface TestObject : NSObject {
NSObject *_newObject;
}
@property (nonatomic, strong, setter=newSetObject:, getter=newObject) NSObject *object;
@property (nonatomic, strong) NSObject *twoObject;
@end
@implementation TestObject
@synthesize object = _newObject;
@end
這裡對兩個屬性進行賦值,twoObject
屬性賦值沒有任何問題,而第二個屬性賦值則會導致Crash
。崩潰資訊如上面所述丟擲一個NSUnknownKeyException
異常,並提示沒有找到object
獲取方法和例項物件。
TestObject *object = [[TestObject alloc] init];
[object setValue:[NSObject new] forKey:NSStringFromSelector(@selector(twoObject))];
[object setValue:[NSObject new] forKey:NSStringFromSelector(@selector(object))];
如果將object
改為newObject
則可以解決這個問題,以此驗證上面的KVC
查詢規則。
KVC效能
根據上面KVC
的實現原理,我們可以看出KVC
的效能並不如直接訪問屬性快,雖然這個效能消耗是微乎其微的。所以在使用KVC
的時候,建議最好不要手動設定屬性的setter
、getter
,這樣會導致搜尋步驟變長。
而且儘量不要用KVC
進行集合操作,例如NSArray
、NSSet
之類的,集合操作的效能消耗更大,而且還會建立不必要的物件。
私有訪問
根據上面的實現原理我們知道,KVC
本質上是操作方法列表以及在記憶體中查詢例項變數。我們可以利用這個特性訪問類的私有變數,例如下面在.m
中定義的私有成員變數和屬性,都可以通過KVC
的方式訪問。
這個操作對readonly
的屬性,@protected
的成員變數,都可以正常訪問。如果不想讓外界訪問類的成員變數,則可以將accessInstanceVariablesDirectly
屬性賦值為NO
。
TestObject.m檔案
@interface TestObject () {
NSObject *_objectOne;
}
@property (nonatomic, strong) NSObject *objectTwo;
@end
KVC
在實踐中也有很多用處,例如UITabbar
或UIPageControl
這樣的控制元件,系統已經為我們封裝好了,但是對於一些樣式的改變並沒有提供足夠的API
,這種情況就需要我們用KVC
進行操作了。
可以自定義一個UITabbar
物件,然後在內部建立自己想要的檢視,並通過layoutSubviews
方法在內部進行重新佈局。然後通過KVC
的方式,將UITabbarController
的tabbar
屬性替換為自定義的類即可。
安全性檢查
KVC
存在一個問題在於,因為傳入的key
或keyPath
是一個字串,這樣很容易寫錯或者屬性自身修改後字串忘記修改,這樣會導致Crash
。
可以利用iOS
的反射機制來規避這個問題,通過@selector()
獲取到方法的SEL
,然後通過NSStringFromSelector()
將SEL
反射為字串。這樣在@selector()
中傳入方法名的過程中,編譯器會有合法性檢查,如果方法不存在或未實現會報黃色警告。
[self valueForKey:NSStringFromSelector(@selector(object))];