簡單易懂KVC基礎篇

SepCode發表於2019-03-26

部落格日誌:2019-3-14 起筆。
部落格日誌:2019-3-22 根據個人閱讀感受對文章大幅重構。
部落格日誌:2019-3-25 封筆。

簡單易懂KVC基礎篇

引言

這篇文章其實就是被他的兄弟KVO給逼出來的,沒辦法。官方文件中介紹過KVC是KVO技術實現的基礎,閒話擴音,我們們請入座。學識有限,有不對的地方,還請大家多多指正。

概述

KVC(Key-value coding)鍵值編碼是一種由NSKeyValueCoding非正式協議(其實就是我們所說的分類或類別)啟用的機制,物件採用該機制提供對其屬性間接訪問。當物件符合鍵值編碼時,其屬性可使用字串引數通過簡潔,統一的訊息傳遞介面(方法)定址。這種間接訪問機制補充了例項變數及其相關訪問器方法提供的直接訪問。

鍵值編碼是一個基本概念,是許多其他Cocoa技術的基礎,例如KVO,(macOS)Cocoa繫結,Core Data和AppleScript。在某些情況下,鍵值編碼還有助於簡化程式碼。

這裡我們搞了段很官方的描述,其實簡單來說的話,就是通過字串名稱訪問物件屬性,就這麼簡單。

API介面

普通用法

訪問物件屬性

- (nullable id)valueForKey:(NSString *)key;
- (void)setValue:(nullable id)value forKey:(NSString *)key;
複製程式碼

KVC提供了簡潔,統一的方法,用來訪問物件屬性。分別是對應於getter訪問器的valueForKey:和對應於setter訪問器的setValue:forKey:。幸運的是,NSObject採用了NSKeyValueCoding協議併為它們和其他基本方法提供預設實現。因此,如果你從NSObject(或其許多子類中的任何一個)派生物件,那麼大部分都工作已經完成了。

@interface BankAccount : NSObject
@property (nonatomic) NSNumber* currentBalance;              // An attribute
@property (nonatomic) Person* owner;                         // A to-one relation
@property (nonatomic) NSArray< Transaction* >* transactions; // A to-many relation
@end

@interface Person : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic) NSUInteger age;
@end
複製程式碼

現在我們宣告瞭兩個類來說明KVC的基礎用法,我們假設BankAccount的例項物件是myAccount,通常我們會直接使用訪問器方法操作屬性。

myAccount.currentBalance = @(100.0);
// 或者
[myAccount setCurrentBalance:@(100.0)];
複製程式碼

當然我們知道上面兩個方法是等價的。現在我們看一下KVC的使用方式:

// setter
[myAccount setValue:@(100.0) forKey:@"currentBalance"];
// getter
NSNumber *currentBalance = [myAccount valueForKey:@"currentBalance"];
複製程式碼

按鍵路徑訪問屬性

如果我們想要獲取銀行賬戶戶主的姓名,我們可以在引入Person.h之後,使用點語法很輕鬆的獲取到:

NSString *myName = myAccount.owner.name;
複製程式碼

當然KVC也提供了我們訪問屬性的屬性的操作方法,通過鍵路徑來訪問屬性。鍵路徑是以點分隔多個鍵的字串用來指定要遍歷的物件屬性的序列。序列中第一個鍵是相對於接收者的屬性,並且每個後續鍵是相對於前一個鍵的屬性。

- (nullable id)valueForKeyPath:(NSString *)keyPath;
- (void)setValue:(nullable id)value forKeyPath:(NSString *)keyPath;
複製程式碼

現在我們可以使用鍵路徑訪問屬性了:

NSString *myName = [myAccount valueForKeyPath:@"owner.name"];
[myAccount setValue:@"SepCode" forKeyPath:@"owner.name"];
複製程式碼

鍵未定義異常

根據KVC規定的方式(搜尋模式)找不到由key命名的屬性時,就會呼叫獲取值的valueForUndefinedKey:或設定值的setValue:forUndefinedKey:方法,系統預設的該方法會引發一個 NSUndefinedKeyException的異常導致崩潰,我們可以重寫該方法避免崩潰。並且我們也可以在重寫該方法時,加入邏輯處理以使其更加的優雅。

// 重寫UndefinedKey:方法
// getter
- (id)valueForUndefinedKey:(NSString *)key {
    return nil;
}
// setter
- (void)setValue:(id)value forUndefinedKey:(NSString *)key {
    
}
複製程式碼

非物件值和nil

當我們通過setValue:forKey:對屬性賦值,如果該屬性不是物件而是標量或結構體時,KVC會自動展開物件獲取值並賦值給屬性。同樣當執行valueForKey:時,則會自動包裝屬性值,返回一個與其對應的NSNumber或NSValue物件。

// setter
[owner setValue:@(26) forKey:@"age"];
// getter
NSNumber *myAge = [owner valueForKey:@"age"];
複製程式碼

當我們給物件賦值nil時,這很容易理解,表示把物件設定為空。但是當我們通過setValue:forKey:設定非物件屬性值為nil時,沒有物件可展開了,難道我們都把這些非物件值設定為0嗎?官方並沒有給我們實現預設的賦值操作,而是呼叫setNilValueForKey:方法,而系統預設的該方法會引發一個NSInvalidArgumentException的異常,當然我們也可以重寫該方法實現特定的行為。

// nil
[owner setValue:nil forKey:@"age"];
...
- (void)setNilValueForKey:(NSString *)key {
    if ([key isEqualToString:@"age"]) {
        [self setValue:@(0) forKey:@”age”];
    } else {
        [super setNilValueForKey:key];
    }
}
複製程式碼

多值訪問

我們看到官方還提供了dictionary相關的方法,但他並不是針對字典的方法。而是同時訪問多個屬性的方法,其實就是呼叫每個key的setValue:forKey:valueForKey:方法,這很容易理解我們不再贅述。

NSDictionary *dict = [owner dictionaryWithValuesForKeys:@[@"name",@"age"]];
dict = @{@"name":@"sepCode",@"age":@(62)};
[owner setValuesForKeysWithDictionary:dict];
複製程式碼

特殊用法

訪問集合屬性

我們前面講述了KVC訪問物件的方式,當然它也同樣適用於集合物件。你可以像使用任何其他物件一樣,通過valueForKey:setValue:forKey:(或它們的鍵路徑方式)獲取或設定集合物件。

@interface Transaction : NSObject
 
@property (nonatomic) NSString* payee;   // To whom
@property (nonatomic) NSNumber* amount;  // How much
@property (nonatomic) NSDate* date;      // When
 
@end
複製程式碼

現在我們又定義了一個交易類,假如我們想獲取個人銀行賬戶中的所有收款人。

NSArray *payees = [myAccount valueForKeyPath:@"transactions.payee"];
複製程式碼

請求transactions.payee鍵路徑的值將返回一個陣列,包含transactions中所有的payee物件。這也適用於鍵路徑中的多個陣列。假如我們想獲取多個銀行賬戶中的所有收款人,請求鍵路徑accounts.transactions.payee的值返回一個陣列,其中包含所有帳戶中所有交易的所有收款人物件。

對於獲取值我們看到了KVC的方便之處,但是對於設定值我們卻很少用到KVC。它會把集合內包含的所有鍵物件的值設定為相同的值,這不是我們想要的結果。

雖然我們可以使用通用的方式訪問集合物件,但是,當你想要操縱這些集合的內容時,官方推薦我們最有效的方法是使用協議定義的可變代理方法。 協議為訪問集合物件定義了三種不同的代理方法,每種方法都有key和keyPath變種: mutableArrayValueForKey:mutableArrayValueForKeyPath: 它們返回一個行為類似於NSMutableArray物件的代理物件。 mutableSetValueForKey:mutableSetValueForKeyPath: 它們返回一個行為類似於NSMutableSet物件的代理物件。 mutableOrderedSetValueForKey:mutableOrderedSetValueForKeyPath: 它們返回一個行為類似於NSMutableOrderedSet物件的代理物件。

當你對代理物件進行操作,新增物件,從中刪除物件或替換物件時,協議的預設實現會相應地修改原物件。現在假如我們想使用KVC通用方法,在個人銀行賬戶增加一次交易,通過valueForKey:獲取非可變集合物件,建立可變集合物件增加內容,然後使用setValue:forKey:訊息將其儲存回物件。相比之下通過代理物件操作,就顯得方便很多。在許多情況下,它比直接使用可變屬性更有效。例如,當我們不使用常量字串作為key,而是使用變數時。這允許我們不必知道呼叫方法的確切名稱,只要物件和正在使用的key符合KVC,一切都會正常工作。 當維護集合中物件時,這些方法還使其可以支援鍵值觀察機制。這也是為什麼KVO的文章寫到一半時,我又突然先來寫KVC了。

這裡我們需要注意的是,這些方法的作用是返回一個集合物件的代理物件。當然你也可以像我們之前講到的一樣,請求集合內物件的屬性,從而達到返回一個屬性集合物件,但這僅僅侷限於獲取值。如果這種情況下操作屬性集合物件原集合內的物件的屬性的值就會被設定為操作後的屬性集合物件,這也不是我們想要的結果。

使用集合運算子

當你向符合鍵值編碼的物件傳送valueForKeyPath:訊息時,或者表述為當物件呼叫valueForKeyPath:方法時,可以在鍵路徑中嵌入集合運算子。集合運算子是一個前面是at符號(@)的關鍵字,它指定了getter應該執行的操作,以便在返回之前以某種方式運算元據。NSObject為此行為提供了預設實現。

當鍵路徑包含集合運算子時,運算子之前的鍵路徑(稱為左鍵路徑)指示相對於訊息接收者操作的集合。如果將訊息直接傳送到集合物件(例如NSArray例項),則可以省略左鍵路徑。操作符之後的鍵路徑部分(稱為右鍵路徑)指定操作員應處理的集合中的屬性。除了@count之外,所有集合運算子都需要右鍵路徑。

集合運算子鍵路徑格式

集合運算子的表現行為可分為三種基本型別:

  • 聚合運算子以某種方式合併集合的物件,並返回通常與右鍵路徑中指定的屬性的資料型別匹配的單個物件。@count是一個例外,它沒有右鍵路徑即便是有也會被忽略並始終將返回一個NSNumber例項。

    NSNumber *transactionAverage = [self.transactions valueForKeyPath:@"@avg.amount"];
    NSNumber *numberOfTransactions = [self.transactions valueForKeyPath:@"@count"];
    NSDate *latestDate = [self.transactions valueForKeyPath:@"@max.date"];
    複製程式碼
  • 陣列運算子返回與右鍵路徑指示的特定物件集相對應的物件陣列。

  • 巢狀操作符處理包含其他集合的集合,並根據操作符返回一個NSArray或NSSet例項,它以某種方式組合巢狀集合的物件。

具體運算子用法,請點選上述各型別超連結在官方文件中檢視。

屬性驗證

鍵值編碼協議定義了支援屬性驗證的方法。就像使用KVC通用方法一樣,你也可以按鍵(或鍵路徑)驗證屬性。當你呼叫validateValue:forKey:error:(或validateValue:forKeyPath:error:)方法時,協議的預設實現會使物件例項搜尋是否實現了validate<Key>:error:方法。如果物件沒有實現此類方法,則預設驗證成功,並返回YES。

通常可採用以下驗證方式:

  • 當值物件有效時,返回YES,不更改值物件或錯誤。

  • 當值物件無效時,並且你不能或不想提供有效的替代方法,設定錯誤原因NSError並且返回NO。

  • 當值物件無效但你知道有效的替代方法時,建立有效物件,將值引用分配給新物件,然後返回YES,不設定NSError錯誤。如果提供其他值,則始終返回新物件,而不是修改正在驗證的物件,即使原始物件是可變的。

Person* person = [[Person alloc] init];
NSError* error;
NSString* name = @"John";
if (![person validateValue:&name forKey:@"name" error:&error]) {
    NSLog(@"%@",error);
}
...

- (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;
}
複製程式碼

上述用例演示了一個name字串屬性的驗證方法,該方法確保值物件的最小長度和不為nil。如果驗證失敗,此方法不會替換其他值。

原理解析

訪問者搜尋模式

KVC協議中最關鍵的部分就是訪問者搜尋模式,NSObject提供的NSKeyValueCoding協議的預設實現,使用明確定義的規則集將基於鍵的訪問器(KVC存取方法)呼叫對映到物件的屬性。這些協議方法使用鍵引數在其自己的物件例項中搜尋訪問器,例項變數以及遵循某些命名約定的相關方法。

可變陣列的搜尋模式

這裡我們僅介紹一種模式可變陣列的搜尋模式,其他搜尋模式可通過訪問者搜尋模式瞭解詳細內容。

mutableArrayValueForKey:的預設實現,輸入一個鍵引數,返回一個可變代理陣列。物件內部的名為key的屬性,通過以下過程接受訪問器的呼叫:

  1. 查詢一對方法名如insertObject:in<Key>AtIndex:removeObjectFrom<Key>AtIndex:(分別對應於NSMutableArray的基本方法insertObject:atIndex:removeObjectAtIndex:)或名稱類似於insert<Key>:atIndexes:remove<Key>AtIndexes:的方法(對應於NSMutableArrayinsertObjects:atIndexes:removeObjectsAtIndexes:方法)。

    如果物件具有至少一個插入方法和至少一個刪除方法,返回一個代理物件來響應這些NSMutableArray的訊息。通過傳送一些組合的訊息insertObject:in<Key>AtIndex:, removeObjectFrom<Key>AtIndex:, insert<Key>:atIndexes:,和remove<Key>AtIndexes:mutableArrayValueForKey:訊息的接受者來實現。 或者可以表述為通過使呼叫mutableArrayValueForKey:方法的物件,呼叫上述方法,來響應這些插入或刪除方法。

    當接收mutableArrayValueForKey:訊息的物件也實現名稱為replaceObjectIn<Key>AtIndex:withObject:replace<Key>AtIndexes:with<Key>:的(可選)替換方法時,代理物件也會在適當時使用這些方法以獲得最佳效能。

  2. 如果物件沒有可變陣列的方法,查詢名稱與模式集匹配的set<Key>:的訪問器方法。在這種情況下,返回一個代理物件。通過向mutableArrayValueForKey:的原始接收者發出set<Key>:訊息,來響應上述那些NSMutableArray的訊息。

    注意:前兩步簡單來說就是代理物件操作集合內容時,先去查詢是否實現了插入,刪除,(可選)替換的方法,沒實現就去查詢setter方法。步驟2中描述的機制比前一步驟的效率低得多,因為它可能涉及重複建立新的集合物件而不是修改現有的集合物件。因此,在設計自己的符合鍵值編碼的物件時,通常應該避免使用它。

  3. 如果既未找到可變陣列方法,也未找到訪問器,並且接收者的類對accessInstanceVariablesDirectly的響應為YES,表示允許搜尋例項變數,則按順序搜尋名稱為_<key><key>的例項變數。 如果找到這樣的例項變數,則返回一個代理物件,該物件將它接收的每個NSMutableArray訊息轉發給例項變數,通常是NSMutableArray或其子類之一的例項。

  4. 如果所有其他方法都失敗了,則返回一個可變集合代理物件,該物件在收到NSMutableArray訊息時向mutableArrayValueForKey:訊息的原始接收者發出setValue:forUndefinedKey:訊息。 setValue:forUndefinedKey:的預設實現會引發NSUndefinedKeyException異常。

    注意:後兩步簡單來說就是,如果允許搜尋例項變數,就去查詢變數,如果以上搜尋都失敗,就報錯。

原理實踐

現在我們根據可變陣列的搜尋模式,做一些實踐和測試:

@interface ViewController ()
/// array
@property (nonatomic, strong) NSMutableArray *array;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view, typically from a nib.
    
    self.array = [@[@(1),@(2),@(3)] mutableCopy];
    NSMutableArray *kvcArray = [self mutableArrayValueForKey:@"array"];
    // 傳送NSMutableArray訊息
    [kvcArray addObject:@(4)];
    [kvcArray removeLastObject];
    [kvcArray replaceObjectAtIndex:0 withObject:@(4)];
    
}
// 可變陣列多對多優化
- (void)insertObject:(NSNumber *)object inArrayAtIndex:(NSUInteger)index {
    [self.array insertObject:object atIndex:index];
}

- (void)removeObjectFromArrayAtIndex:(NSUInteger)index {
    [self.array removeObjectAtIndex:index];
}

- (void)replaceObjectInArrayAtIndex:(NSUInteger)index withObject:(id)object {
    [self.array replaceObjectAtIndex:index withObject:object];
}

@end
複製程式碼

簡單易懂KVC基礎篇

上圖的測試結果,向我們展現瞭如果我們使用代理物件時,最好實現完整協議,優化多對多關係,否則隨著資料量級增加,效能會呈指數級下降,這真的很糟糕。

疑點解惑

在這裡我要說一下我對於kvc是kvo實現的基礎的理解。因為在網上看到一位文章寫的還不錯的作者,他講到二者實現機制不同,並無必然聯絡,只是KVC對KVO的支援比較好。我非常不同意這個觀點。在官方鍵值觀察程式設計指南中明確指出,該類的屬性必須遵守KVC合規性。KVC是一個通過字串訪問物件屬性的協議,包括搜尋模式也屬於該協議的一部分。KVO觀察的屬性,必須遵守KVC合規性,並且支援觀察KVC相容的所有訪問器修改屬性。通常我們所理解的KVO都是基於setter訪問器實現的,然而並非如此。下圖也充分驗證KVO支援KVC的搜尋模式:

簡單易懂KVC基礎篇

這裡讓我想到了餓了麼技術沙龍中蘭建剛的忠告:中文部落格-在你沒有能力分辨對錯之前,少看。

結語

這篇文章呢,寫著寫著我就又有感慨了。我深深的感受到,我是一個學習者,這些知識都是別人創造的,用的都是別人提供給我們的方法。就連學習也可能是靠他人總結的,我還不是一個創造者。

不過認清自己是多麼菜,也沒什麼不好的。即便同樣處於學習階段的他人,也可以成為自己的老師,希望大家可以多多指點迷津。

最近看了不少他人的文章,我從自己的感受發現幾點。

  • 喜歡作者把技術通過圖或者文字表述的很清楚,不喜歡看作者大段的程式碼來表述,但是簡單的用例還是必須的。
  • 不要一下子把介面全列出來,最多掃一眼,除非作者的目的也是你就瞄一眼就可以了。所以講解時的順序可以是表述,介面,用例。一個點一個點的展開。
  • 文章結構清晰,不要天上一腳,地上一腳,所以前提是作者思路清晰。

另外有大牛建議不需要看太多書,經典的書多讀幾遍,獨立思考。本篇文章基本是在多看官方文件的基礎上誕生的,本人對於細節知識還是比較在意的,如果有理解不對的地方,還請大家多多指正。

相關文章