KVC原理剖析

劉小壯發表於2018-03-02
該文章屬於<簡書 — 劉小壯>原創,轉載請註明:

<簡書 — 劉小壯> https://www.jianshu.com/p/1d39bc610a5b


在工作中經常會使用到KVC,但是很多人對於KVC的實現原理並不太清楚。比如說KVC在進行存取時,是怎麼進行查詢並賦值的。

網上有很多講KVC的文章,但是有很多質量並不高。這兩天抽空把我所理解的KVC寫出來,當做學習交流,正好也讓各位大神幫我指正一下,十分感謝!


部落格配圖

協議定義

KVC全稱是Key Value Coding,定義在NSKeyValueCoding.h檔案中,是一個非正式協議KVC提供了一種間接訪問其屬性方法或成員變數的機制,可以通過字串來訪問對應的屬性方法或成員變數。

NSKeyValueCoding Protocol

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:方法時,可以傳入一個包含keyvalue的字典進去,KVC可以將所有資料按照屬性名和字典的key進行匹配,並將valueUser物件的屬性賦值。

- (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的轉換。需要注意的是,NSArrayNSDictionary等集合物件,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方法,在其內容發生改變時傳送訊息。但這只是對屬性直接進行賦值會觸發,如果屬性是容器物件,對容器物件進行addremove操作,則不會呼叫KVO的方法。可以通過KVC對應的API來配合使用,使容器物件內部發生改變時也能觸發KVO

在進行容器物件操作時,先呼叫下面方法通過key或者keyPath獲取集合物件,然後再對容器物件進行addremove等操作時,就會觸發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。(集合物件主要指NSArrayNSSet,但不包括NSDictionary)

集合運算子格式

上面表示式主要分為三部分,left部分是要操作的集合物件,如果呼叫KVC的物件本來就是集合物件,則left可以為空。中間部分是表示式,表示式一般以@符號開頭。後面是進行運算的屬性。

集合運算子主要分為三類:

  1. 集合操作符:處理集合包含的物件,並根據操作符的不同返回不同的型別,返回值以NSNumber為主。
  2. 陣列操作符:根據操作符的條件,將符合條件的物件包含在陣列中返回。
  3. 巢狀操作符:處理集合物件中巢狀其他集合物件的情況,返回結果也是一個集合物件。

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

集合操作符

集合操作符處理NSArrayNSSet及其子類這樣的集合物件,並根據不同的操作符返回不同型別的物件,返回值一般都是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是支援基礎資料型別和結構體的,可以在settergetter的時候,通過NSValueNSNumber來轉換為OC物件。Swift中不存在這樣的需求,因為Swift中所有變數都是物件。

以下是結構體轉換的示例程式碼,可以呼叫initWithBool:方法對基礎資料型別進行包裝,除了呼叫方法外還可以通過字面量實現,例如@(YES)的呼叫。通過NSNumberboolValue屬性轉換為基礎資料型別。

@property (nonatomic, assign, readonly) BOOL boolValue;
- (NSNumber *)initWithBool:(BOOL)value NS_DESIGNATED_INITIALIZER;

結構體轉換的程式碼定義在UIGeometry.h中,以NSValueCategory形式存在。NSValueCGPointCGRect等結構體都提供了轉換方法,例如下面是對CGPoint進行轉換的示例程式碼。

@property(nonatomic, assign, readonly) CGPoint CGPointValue;
+ (NSValue *)valueWithCGPoint:(CGPoint)point;

需要注意的是,無論什麼時候都不應該給setter中傳入nil,會導致Crash並引起NSInvalidArgumentException異常。

屬性驗證

在呼叫KVC時可以先進行驗證,驗證通過下面兩個方法進行,支援keykeyPath兩種方式。驗證方法預設實現返回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方法的內部實現中,如果傳入的valuekey有問題,可以通過返回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自動驗證,在呼叫setValuegetValue時自動進行驗證,如果不符合驗證規則,就呼叫失敗。如果外界使用的地方都先呼叫一次validateValue的話,這是很麻煩的。當然也有解決方法,可以通過Method Swizzling方法hooksetValuegetValue方法。

搜尋規則

KVC在通過key或者keyPath進行操作的時候,可以查詢屬性方法、成員變數等,查詢的時候可以相容多種命名。具體的查詢規則要以官方文件為主,所以我把官方文件翻譯了一下寫在下面。

KVC的實現中,依賴settergetter的方法實現,所以方法命名應該符合蘋果要求的規範,否則會導致KVC失敗。

在學習KVC的搜尋規則前,要先弄明白一個屬性的作用,這個屬性在搜尋過程中起到很重要的作用。這個屬性表示是否允許讀取例項變數的值,如果為YES則在KVC查詢的過程中,從記憶體中讀取屬性例項變數的值。

@property (class, readonly) BOOL accessInstanceVariablesDirectly;

基礎Getter搜尋模式

這是valueForKey:的預設實現,給定一個key當做輸入引數,開始下面的步驟,在這個接收valueForKey:方法呼叫的類內部進行操作。

  1. 通過getter方法搜尋例項,例如get<Key>, <key>, is<Key>, _<key>的拼接方案。按照這個順序,如果發現符合的方法,就呼叫對應的方法並拿著結果跳轉到第五步。否則,就繼續到下一步。
  2. 如果沒有找到簡單的getter方法,則搜尋其匹配模式的方法countOf<Key>objectIn<Key>AtIndex:<key>AtIndexes:

    如果找到其中的第一個和其他兩個中的一個,則建立一個集合代理物件,該物件響應所有NSArray的方法並返回該物件。否則,繼續到第三步。

    代理物件隨後將NSArray接收到的countOf<Key>objectIn<Key>AtIndex:<key>AtIndexes:的訊息給符合KVC規則的呼叫方。

    當代理物件和KVC呼叫方通過上面方法一起工作時,就會允許其行為類似於NSArray一樣。

  3. 如果沒有找到NSArray簡單存取方法,或者NSArray存取方法組。則查詢有沒有countOf<Key>enumeratorOf<Key>memberOf<Key>:命名的方法。

    如果找到三個方法,則建立一個集合代理物件,該物件響應所有NSSet方法並返回。否則,繼續執行第四步。

    此代理物件隨後轉換countOf<Key>enumeratorOf<Key>memberOf<Key>:方法呼叫到建立它的物件上。實際上,這個代理物件和NSSet一起工作,使得其表象上看起來是NSSet

  4. 如果沒有發現簡單getter方法,或集合存取方法組,以及接收類方法accessInstanceVariablesDirectly是返回YES的。搜尋一個名為_<key>_is<Key><key>is<Key>的例項,根據他們的順序。

    如果發現對應的例項,則立刻獲得例項可用的值並跳轉到第五步,否則,跳轉到第六步。

  5. 如果取回的是一個物件指標,則直接返回這個結果。
    如果取回的是一個基礎資料型別,但是這個基礎資料型別是被NSNumber支援的,則儲存為NSNumber並返回。
    如果取回的是一個不支援NSNumber的基礎資料型別,則通過NSValue進行儲存並返回。
  6. 如果所有情況都失敗,則呼叫valueForUndefinedKey:方法並丟擲異常,這是預設行為。但是子類可以重寫此方法。

基礎Setter搜尋模式

這是setValue:forKey:的預設實現,給定輸入引數valuekey。試圖在接收呼叫物件的內部,設定屬性名為keyvalue,通過下面的步驟:

  1. 查詢set<Key>:_set<Key>命名的setter,按照這個順序,如果找到的話,呼叫這個方法並將值傳進去(根據需要進行物件轉換)。
  2. 如果沒有發現一個簡單的setter,但是accessInstanceVariablesDirectly類屬性返回YES,則查詢一個命名規則為_<key>_is<Key><key>is<Key>的例項變數。根據這個順序,如果發現則將value賦值給例項變數。
  3. 如果沒有發現setter或例項變數,則呼叫setValue:forUndefinedKey:方法,並預設提出一個異常,但是一個NSObject的子類可以提出合適的行為。

NSMutableArray搜尋模式

這是mutableArrayValueForKey:的預設實現,給一個key當做輸入引數。在接收訪問器呼叫的物件中,返回一個名為key的可變代理陣列,這個代理陣列就是用來響應外界KVO的物件,通過下面的步驟進行查詢:

  1. 查詢一對方法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>:方法,代理物件會在適當的情況下使用它們,以獲得最佳效能。

  2. 如果物件沒有可變陣列方法,查詢一個替代方法,命名格式為set<Key>:。在這種情況下,向mutableArrayValueForKey:的原始響應者傳送一個set<Key>:訊息,來返回一個代理物件來響應NSMutableArray事件。

    提示:
    這一步描述的機制遠不如上一步有效,因為它可能重複建立新的集合物件,而不是修改現有的物件。因此,在自己設計的KVC時應該儘量避免它。

  3. 如果沒有可變陣列的方法,也沒有找到訪問器,但接受響應的類accessInstanceVariablesDirectly屬性返回YES,則查詢一個名為_<key><key>的例項變數。

    按照這個順序,如果找到例項變數,則返回一個代理物件。改物件將接收所有NSMutableArray傳送過來的訊息,通常是NSMutableArray或其子類。

  4. 如果所有情況都失敗,則返回一個可變的集合代理物件。當它接收NSMutableArray訊息時,傳送一個setValue:forUndefinedKey:訊息給接收mutableArrayValueForKey:訊息的原始物件。

    這個setValue:forUndefinedKey:的預設實現是提出一個NSUndefinedKeyException異常,但是子類可以重寫這個實現。

其他搜尋模式

還有NSMutableSetNSMutableOrderedSet兩種搜尋模式,這兩種搜尋模式和NSMutableArray步驟相同,只是搜尋和呼叫的方法不同。詳細的搜尋方法都可以在KVC官方文件中找到,再套用上面的流程即可理解。

程式碼示例

根據上面KVC查詢規則的描述,我們定義一個TestObject類,並指定其他settergetter,以及合成為其他的成員變數,看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的時候,建議最好不要手動設定屬性的settergetter,這樣會導致搜尋步驟變長。

而且儘量不要用KVC進行集合操作,例如NSArrayNSSet之類的,集合操作的效能消耗更大,而且還會建立不必要的物件。

私有訪問

根據上面的實現原理我們知道,KVC本質上是操作方法列表以及在記憶體中查詢例項變數。我們可以利用這個特性訪問類的私有變數,例如下面在.m中定義的私有成員變數和屬性,都可以通過KVC的方式訪問。

這個操作對readonly的屬性,@protected的成員變數,都可以正常訪問。如果不想讓外界訪問類的成員變數,則可以將accessInstanceVariablesDirectly屬性賦值為NO

TestObject.m檔案

@interface TestObject () {
    NSObject *_objectOne;
}
@property (nonatomic, strong) NSObject *objectTwo;
@end

KVC在實踐中也有很多用處,例如UITabbarUIPageControl這樣的控制元件,系統已經為我們封裝好了,但是對於一些樣式的改變並沒有提供足夠的API,這種情況就需要我們用KVC進行操作了。

新浪微博

可以自定義一個UITabbar物件,然後在內部建立自己想要的檢視,並通過layoutSubviews方法在內部進行重新佈局。然後通過KVC的方式,將UITabbarControllertabbar屬性替換為自定義的類即可。

安全性檢查

KVC存在一個問題在於,因為傳入的keykeyPath是一個字串,這樣很容易寫錯或者屬性自身修改後字串忘記修改,這樣會導致Crash

可以利用iOS的反射機制來規避這個問題,通過@selector()獲取到方法的SEL,然後通過NSStringFromSelector()SEL反射為字串。這樣在@selector()中傳入方法名的過程中,編譯器會有合法性檢查,如果方法不存在或未實現會報黃色警告。

[self valueForKey:NSStringFromSelector(@selector(object))];

相關文章