iOS進階之路 (十四)KVO 原理 & 缺陷

Important: In order to understand key-value observing, you must first understand key-value coding. -- 官方文檔html

想要理解KVO,必須先理解KVC,由於鍵值觀察是創建在鍵值編碼 的基礎上。觀衆老爺們能夠參考筆者的上篇文章iOS進階之路 (十三)KVCios

一. KVO的定義

Key-value observing is a mechanism that allows objects to be notified of changes to specified properties of other objects.git

KVO (Key-value observing) 是一個非正式協議,容許對象在其餘對象的指定屬性發生更改時獲得通知。iOS開發者可使用KVO 來檢測對象屬性的變化、快速作出響應,這可以爲咱們在開發強交互、響應式應用以及實現視圖和模型的雙向綁定時提供大量的幫助。github

KVO’s primary benefit is that you don’t have to implement your own scheme to send notifications every time a property changes. Its well-defined infrastructure has framework-level support that makes it easy to adopt—typically you do not have to add any code to your project. In addition, the infrastructure is already full-featured, which makes it easy to support multiple observers for a single property, as well as dependent values.objective-c

KVO的主要好處是,沒必要在每次屬性更改時都實現本身的方案來發送通知。這個過程大部分是內建的,自動的,透明的。這使得采用它很容易,一般您沒必要向項目中添加任何代碼。此外,KVO支持單個屬性添加多個觀察者以及依賴值。設計模式

二. 註冊KVO -- Registering for Key-Value Observing

2.1 註冊觀察者 -- Registering as an Observer

- (void)addObserver:(NSObject *)observer
         forKeyPath:(NSString *)keyPath
            options:(NSKeyValueObservingOptions)options
            context:(void *)context;
複製代碼
  • observer: 註冊 KVO 通知的對象。觀察者必須實現 key-value observing 方法 - observeValueForKeyPath:ofObject:change:context:
  • keyPath: 被觀察者的屬性的 keypath,相對於接受者,值不能是 nil。
  • options: 表明 NSKeyValueObservingOptions 的位掩碼,它指定了觀察通知中包含了什麼
  • context :在 observeValueForKeyPath:ofObject:change:context: 傳給 observer 參數的上下文

2.1.1 更好的 keyPath

傳字符串作爲 keypath 比直接使用屬性更糟糕,由於任何錯字或者拼寫錯誤都不會被編譯器察覺,最終致使不能正常工做。 一個聰明的解決方案是使用 NSStringFromSelector 和一個 @selector 字面值:數組

NSStringFromSelector(@selector(isFinished))
複製代碼

由於 @selector 檢查目標中的全部可用 selector,這並不能阻止全部的錯誤,但它能夠用來捕獲大部分改變。安全

2.1.2 更好的 context

關於context,蘋果官方文檔也作了精彩的註釋。bash

The context pointer in the addObserver:forKeyPath:options:context: message contains arbitrary data that will be passed back to the observer in the corresponding change notifications. You may specify NULL and rely entirely on the key path string to determine the origin of a change notification, but this approach may cause problems for an object whose superclass is also observing the same key path for different reasons.網絡

context 中包含着將在相應的更改通知中傳遞迴觀察員的任意數據。您能夠指定NULL並徹底依賴於keyPath肯定更改通知的來源。可是這種方法可能出現問題:尤爲是處理那些繼承自同一個父類的子類,而且這些子類有相同的 keypath

A safer and more extensible approach is to use the context to ensure notifications you receive are destined for your observer and not a superclass.

一種更安全、更可擴展的方法是:使用context來確保接收到的通知是發送給觀察者的,而不是發送給超類。 如何設置一個好的 content 呢?蘋果官方文檔也給出了推薦的方法。

//  a Person instance registers itself as an observer for an Account instance’s
// 大致意思就是:一個靜態變量存着它本身的指針。這意味着它本身什麼也沒有。
static void *PersonAccountBalanceContext = &PersonAccountBalanceContext;
static void *PersonAccountInterestRateContext = &PersonAccountInterestRateContext;

- (void)registerAsObserverForAccount:(Account*)account {
    [account addObserver:self
              forKeyPath:@"balance"
                 options:(NSKeyValueObservingOptionNew |
                          NSKeyValueObservingOptionOld)
                 context:PersonAccountBalanceContext];
 
    [account addObserver:self
              forKeyPath:@"interestRate"
                 options:(NSKeyValueObservingOptionNew |
                          NSKeyValueObservingOptionOld)
                  context:PersonAccountInterestRateContext];
}
複製代碼

2.2 接受變化 -- Receiving Notification of a Change

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
 
    if (context == PersonAccountBalanceContext) {
        // Do something with the balance…
 
    } else if (context == PersonAccountInterestRateContext) {
        // Do something with the interest rate…
 
    } else {
        // Any unrecognized context must belong to super
        [super observeValueForKeyPath:keyPath
                             ofObject:object
                               change:change
                               context:context];
    }
}
複製代碼

2.3 移除觀察者 -- Removing an Object as an Observer

蘋果官方文檔推薦在 initviewDidLoad add觀察者,在 dealloc 裏移除觀察者, 保證 addremove 是成對出現的。

- (void)unregisterAsObserverForAccount:(Account*)account {
    [account removeObserver:self
                 forKeyPath:@"balance"
                    context:PersonAccountBalanceContext];
 
    [account removeObserver:self
                 forKeyPath:@"interestRate"
                    context:PersonAccountInterestRateContext];
}
複製代碼

更好的remove

調用 –removeObserver:forKeyPath:context:時, 當這個對象沒有被註冊爲觀察者(由於它已經解註冊了或者開始沒有註冊),會拋出一個異常。有意思的是,沒有一個內建的方式來檢查對象是否註冊。 蘋果官方文檔推薦使用: @try / @catch 移除觀察者。

- (void)dealloc {
    @try {
        // 3. unsubscribe
        [_account removeObserver:self
                      forKeyPath:NSStringFromSelector(@selector(contentSize))
                         context:ContentSizeContext];
    }
    @catch (NSException *exception) {
        
    }
}
複製代碼

三. 集合屬性的監聽

  1. 集合屬性(這裏指 NSArray 和 NSSet),只有在賦值時會觸發KVO,改變集合屬性裏的元素是不會觸發KVO的(好比添加、刪除、修改元素)。

2. 直接操做:經過使用KVC的集合代理對象(collection proxy object)來處理集合相關的操做,使集合對象內部元素改變時也能觸發KVO。關於集合代理對象,能夠參考 OC由淺入深系列 之 KVC (二):集合代理對象

  • 有序集合

ttps://user-gold-cdn.xitu.io/2020/4/24/171ab2ba83b7cd21?w=1362&h=436&f=png&s=202396)

  • 無需集合 還要額外實現以下的方法:

  1. 間接操做:在進行可變集合對象操做時,先經過key或者keyPath獲取集合對象,而後再對集合對象進行add或remove等操做時,就會觸發KVO的消息通知了。這種方式是實際開發中最經常使用到的。
// key 方法
- (NSMutableArray *)mutableArrayValueForKey:(NSString *)key;
- (NSMutableOrderedSet *)mutableOrderedSetValueForKey:(NSString *)key;
- (NSMutableSet *)mutableSetValueForKey:(NSString *)key;

// keyPath方法:
- (NSMutableArray *)mutableArrayValueForKeyPath:(NSString *)keyPath;
- (NSMutableOrderedSet *)mutableOrderedSetValueForKeyPath:(NSString *)keyPath;
- (NSMutableSet *)mutableSetValueForKeyPath:(NSString *)keyPath;
複製代碼

例子:

四. KVO兼容 -- KVO Compliance

To use KVO, first you must ensure that the observed object, the Account in this case, is KVO compliant. Typically, if your objects inherit from NSObject and you create properties in the usual way, your objects and their properties will automatically be KVO Compliant. It is also possible to implement compliance manually. KVO Compliance describes the difference between automatic and manual key-value observing, and how to implement both.

要使用KVO,首先必須確保被觀察的對象符合KVO。一般,若是您的對象繼承自NSObject,而且您以一般的方式建立屬性,那麼您的對象及其屬性將自動與KVO兼容。固然,也能夠手動實現聽從性。KVO Compliance講述述了自動和手動鍵值觀察之間的區別,以及如何實現二者。

能夠經過複寫 automaticallyNotifiesObserversForKey: 的返回值,選擇自動退出 KVO。同時該類方法還能完成控制特定屬性的目的。

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey {
 
    BOOL automatic = NO;
    if ([theKey isEqualToString:@"balance"]) {
        automatic = NO;
    } else {
        automatic = [super automaticallyNotifiesObserversForKey:theKey];
    }
    return automatic;
}
複製代碼

4.1 自動KVO -- Automatic Change Notification

// Call the accessor method.
[account setName:@"Savings"];
 
// Use setValue:forKey:.
[account setValue:@"Savings" forKey:@"name"];
 
// Use a key path, where 'account' is a kvc-compliant property of 'document'.
[document setValue:@"Savings" forKeyPath:@"account.name"];
 
// Use mutableArrayValueForKey: to retrieve a relationship proxy object.
Transaction *newTransaction = <#Create a new transaction for the account#>;
NSMutableArray *transactions = [account mutableArrayValueForKey:@"transactions"];
[transactions addObject:newTransaction];
複製代碼

4.2 手動KVO -- Manual Change Notification

手動 KVO 能夠幫助咱們將多個屬性值的更改合併成一個,這樣在回調的時候就有一次了,同時也能最大程度地減小處於應用程序特定緣由而致使的通知發生。

  • 要實現手動觀察者通知,請在更改值以前調用willChangeValueForKey,在更改值以後調用didChangeValueForKey
- (void)setBalance:(double)theBalance {
    [self willChangeValueForKey:@"balance"];
    _balance = theBalance;
    [self didChangeValueForKey:@"balance"];
}
複製代碼
  • 爲了性能最佳,經過檢查值是否發生變化,最小化發送沒必要要的通知
- (void)setBalance:(double)theBalance {
    if (theBalance != _balance) {
        [self willChangeValueForKey:@"balance"];
        _balance = theBalance;
        [self didChangeValueForKey:@"balance"];
    }
}
複製代碼
  • 若是一個操做致使多個鍵發生更改,應該這樣更改
- (void)setBalance:(double)theBalance {
    [self willChangeValueForKey:@"balance"];
    [self willChangeValueForKey:@"itemChanged"];
    _balance = theBalance;
    _itemChanged = _itemChanged+1;
    [self didChangeValueForKey:@"itemChanged"];
    [self didChangeValueForKey:@"balance"];
}
複製代碼
  • 對於有序的一對多關係屬性,不只必須指定已更改的鍵,還必須指定更改的類型和所涉及對象的索引。 更改的類型是 NSKeyValueChangeNSKeyValueChangeInsertionNSKeyValueChangeRemovalNSKeyValueChangeReplacement),受影響的對象的索引用 NSIndexSet 對象
- (void)removeTransactionsAtIndexes:(NSIndexSet *)indexes {
    [self willChange:NSKeyValueChangeRemoval
        valuesAtIndexes:indexes forKey:@"transactions"];
 
    // Remove the transaction objects at the specified indexes.
 
    [self didChange:NSKeyValueChangeRemoval
        valuesAtIndexes:indexes forKey:@"transactions"];
}
複製代碼

五. 註冊依賴鍵 -- Registering Dependent Keys

有一些屬性的值取決於一個或者多個其餘對象的屬性值,一旦某個被依賴的屬性值變了,依賴它的屬性的變化也須要被通知。

5.1 To-One Relationships

要自動觸發 To-One 關係,有兩種方法:

  • 重寫 keyPathsForValuesAffectingValueForKey:方法
  • 定義名稱爲 keyPathsForValuesAffecting<Key> 的方法。

舉個例子: 一我的的全名 fullName 是由 firstName 和 lastName 組成的,一個觀察 fullName 的程序在 firstName 或者 lastName 變化時也應該接收到通知。

- (NSString *)fullName
{
    return [NSString stringWithFormat:@"%@ %@",firstName, lastName];
}
複製代碼
  1. 方法一:重寫 keyPathsForValuesAffectingValueForKey:方法, 來代表 fullname 屬性是依賴於 firstname 和 lastname 的:
+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
 
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
 
    if ([key isEqualToString:@"fullName"]) {
        NSArray *affectingKeys = @[@"lastName", @"firstName"];
        keyPaths = [keyPaths setByAddingObjectsFromArray: affectingKeys];
    }
    return keyPaths;
}
複製代碼

至關於在影響 fullName 值的 keypath 中新加了兩個key:lastName 和 firstName,很容易理解。

值得注意的是:須要先對父類發送 keyPathsForValuesAffectingValueForKey 消息,以避免干擾父類中對此方法的重寫

  1. 方法二:實現一個遵循命名方式爲keyPathsForValuesAffecting的類方法,是依賴於其餘值的屬性名(首字母大寫)
+ (NSSet *)keyPathsForValuesAffectingFullName
{
    return [NSSet setWithObjects:@"lastName", @"firstName", nil];
}
複製代碼

若是在分類中,使用 keyPathsForValuesAffectingFullName 更合理,由於分類中是不容許重載方法的,因此 keyPathsForValuesAffectingValueForKey 方法確定是不能在分類中使用的。

5.2 To-many Relationships

keyPathsForValuesAffectingValueForKey:方法不支持包含 to-many 關係的 keypath

好比,有一個 Department(部門) 類,它有一個針對 Employee(僱員) 類的 to-many 關係,Employee類有 salary(薪資)屬性。你但願 Department類有一個 totalSalary 屬性來計算全部員工的薪水,也就是 Department的totalSalary 依賴於全部 Employee的salary 屬性。你不能經過實現 keyPathsForValuesAffectingTotalSalary 方法並返回 employees.salary

你能夠用KVO將parent(好比Department)做爲全部children(好比Employee)相關屬性的觀察者。你必須在把child添加或刪除到parent時也把parent做爲child的觀察者添加或刪除。在observeValueForKeyPath:ofObject:change:context:方法中咱們能夠針對被依賴項的變動來更新依賴項的值:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
 
    if (context == totalSalaryContext) {
        [self updateTotalSalary];
    }
    else
    // deal with other observations and/or invoke super...
}
 
- (void)updateTotalSalary {
    [self setTotalSalary:[self valueForKeyPath:@"employees.@sum.salary"]];
}
 
- (void)setTotalSalary:(NSNumber *)newTotalSalary {
 
    if (totalSalary != newTotalSalary) {
        [self willChangeValueForKey:@"totalSalary"];
        _totalSalary = newTotalSalary;
        [self didChangeValueForKey:@"totalSalary"];
    }
}
 
- (NSNumber *)totalSalary {
    return _totalSalary;
}
複製代碼

將 Department 實例對象註冊爲觀察者,而後觀察對象爲 totalSalary 屬性,可是在通知回調中會手動調用 totalSalary 屬性的 setter 方法,而且傳入值是經過 KVC 的集合運算符的方式取出 employees 屬性所對應的集合中全部 sum 值之和。而後在 totalSalary 屬性的 setter 方法中,會相應的調用 willChangeValueForKey: 和 didChangeValueForKey: 方法。

六. KVO的原理

先看看官方文檔的解釋

Automatic key-value observing is implemented using a technique called isa-swizzling.

  • 自動的鍵值觀察的實現基於 isa-swizzling

The isa pointer, as the name suggests, points to the object's class which maintains a dispatch table. This dispatch table essentially contains pointers to the methods the class implements, among other data.

  • 顧名思義,isa指針指向維護分配表的對象的類,該分派表實質上包含指向該類實現的方法的指針以及其餘數據

When an observer is registered for an attribute of an object the isa pointer of the observed object is modified, pointing to an intermediate class rather than at the true class. As a result the value of the isa pointer does not necessarily reflect the actual class of the instance.

  • 當一個觀察者註冊了對一個對象的某個屬性鍵值觀察以後,被觀察對象的 isa 指針所指向的內容發生了變化,指向了一箇中間類而不是真正的類。這也致使 isa 指針並不必定是指向實例所屬的真正的類。

You should never rely on the isa pointer to determine class membership. Instead, you should use the class method to determine the class of an object instance.

  • 你永遠不該依靠 isa 指針來肯定類成員身份。相反,你應該使用 class 方法來肯定對象實例所屬的類。

6.1 動態生成中間子類:NSKVONotifying_XXX

根據官網文檔,咱們初步判斷,在 KVO 底層會有一箇中間類生成,中間類會讓對象的 isa 指針發生變化。測試一下:

  • 添加觀察者以前:類對象爲AKPerson,實例對象 self.person 的isa 指向 AKPerson類
  • 添加觀察者以後:類對象爲AKPerson,實例對象 self.person 的isa 指向 NSKVONotifying_AKPerson類

結論1:添加觀察者後,系統動態生成了中間類NSKVONotifying_AKPerson,實例對象self.person 的isa由指向 原始AKPerson類, 修改成指向中間類NSKVONotifying_AKPerson

那麼這個中間類和原來的類是什麼關係呢? 遍歷下二者的類以及子類,測試一下

#pragma mark - 遍歷類以及子類
- (void)printClasses:(Class)cls {
    // 註冊類的總數
    int count = objc_getClassList(NULL, 0);
    // 建立一個數組, 其中包含給定對象
    NSMutableArray *mArray = [NSMutableArray arrayWithObject:cls];
    // 獲取全部已註冊的類
    Class* classes = (Class*)malloc(sizeof(Class)*count);
    objc_getClassList(classes, count);
    for (int i = 0; i<count; i++) {
        if (cls == class_getSuperclass(classes[i])) {
            [mArray addObject:classes[i]];
        }
    }
    free(classes);
    NSLog(@"classes = %@", mArray);
}
複製代碼

結論2:添加觀察者以後,中間類 NSKVONotifying_AKPerson 是原始類 AKPerson 的子類.

6.2 動態子類觀察 setter 方法

KVO關注的是屬性值的變化,而 屬性 = 成員變量 + getter + setter,顯然只有 setter 和成員變量賦值兩種方式能夠改變屬性值。

咱們作下測試:AKPerson 添加成員變量name屬性nickName, 添加觀察者後同時修改二者的值:

@interface AKPerson : NSObject{
    @public
    NSString *name;
}
@property (nonatomic, copy) NSString *nickName;

@end
複製代碼

  • 屬性nickName的setter方法,觸發了KVO的回調函數
  • 成員變量name的賦值操做,不觸發了KVO的回調函數

結論3: 動態子類觀察的是setter方法

6.3 動態子類內部策略

6.3.1 動態子類重寫了什麼方法?

咱們能夠遍歷原始類和中間類的方法列表, 觀察下中間類重寫了哪些方法?

#pragma mark - 遍歷方法-ivar-property
- (void)printClassAllMethod:(Class)cls{
    NSLog(@"***** %@ *****", cls);
    unsigned int count = 0;
    Method *methodList = class_copyMethodList(cls, &count);
    for (int i = 0; i<count; i++) {
        Method method = methodList[i];
        SEL sel = method_getName(method);
        IMP imp = class_getMethodImplementation(cls, sel);
        NSLog(@"%@-%p",NSStringFromSelector(sel),imp);
    }
    free(methodList);
}
複製代碼

  • AKPerson類 中的方法沒有改變(imp實現地址沒有變化)
  • NSKVONotifying_AKPerson中間類 重寫了 父類AKPersondealloc方法
  • NSKVONotifying_AKPerson中間類 重寫了 基類NSObjectclass方法_isKVOA方法
  • _isKVOA 方法:作個標識
  • 根據上面的推測,中間類重寫的 class方法 仍然是返回的是原始類,目的就是隱藏中間類的存在,讓調用者調用 class方法 結果先後一致

6.3.2 移除觀察後,動態子類何去何從?

  • 移除觀察者後,原始類的isa由 NSKVONotifying_AKPerson中間類 指會 AKPerson類
  • 移除觀察者後,動態子類依然存在。

6.4 KVO調試

經過observationInfo命令,能夠在 lldb 裏查看一個被觀察對象的全部觀察信息。

這會打印出有關誰觀察誰之類的不少信息。 這個信息的格式不是公開的,咱們不能讓任何東西依賴它,由於蘋果隨時均可以改變它。不過這是一個很強大的排錯工具。

七. KVO的缺陷

KVO 是 Cocoa 爲咱們提供的一種模式,用於訂閱其餘對象屬性的更改。 KVO 很強大,沒錯。知道它內部實現,或許能幫助更好地使用它,或在它出錯時更方便調試。但官方 KVO 提供的 API 並很差用。甚至咱們的平常開發中,除非萬不得已,不多去使用官方 KVO 的 API。

在 Soroush Khanlou 的 Key-Value Observing Done Right 文章中,KVO 被批判的體無完膚。好比:

  • KVO all comes through one method: 只能經過重寫 -observeValueForKeyPath:ofObject:change:context: 方法來得到通知,想要提供自定義的 selector ,想要傳一個 block ,沒門!
  • KVO is string-ly typed: 傳遞的 keyPath 是字符串類型的,任何錯字或者拼寫錯誤都不會被編譯器察覺,最終致使不能正常工做。
  • KVO requires you to handle superclasses yourself: KVO要求咱們本身處理超類的問題。尤爲是處理那些繼承自同一個父類的子類,而且這些子類有相同的 keypath。
  • KVO can crash when deregistering:KVO 須要手動移除,移除的時候還可能引發崩潰,並且 KVO 沒有一個內建的方式來檢查對象是否註冊或銷燬。例如:一個超類也觀察同一個對象上的同一個參數,會發生什麼狀況?它會被移除兩次,第二次會致使崩潰。

若是想完美的解決這些問題,建議閱讀下強大的自定義KVO框架 facebookarchive/KVOController

八. 總結

Objective-CCocoa 中,有許多事件之間進行通訊的方式,而且每一個都有不一樣程度的形式和耦合

  1. NSNotification
  • 提供了一箇中央樞紐,應用的任何部分均可能通知或者被通知應用的其餘部分的變化。惟一須要作的是要知道在尋找什麼,主要靠通知的名字。
  • 例如,UIApplicationDidReceiveMemoryWarningNotification 是給應用發了一個內存不足的信號。
  • 通知的接收方線程取決於通知的發送方(子線程發送通知,會在同一子線程接收到,此時更新UI須要切回祝線程)。
  1. Key-Value Observing
  • 容許 ad-hoc,經過在特定對象之間監聽一個特定的 keypath 的改變進行事件內省。
  • 例如:一個 ProgressView 能夠觀察 網絡請求的 numberOfBytesRead 來更新它本身的 progress 屬性。
  1. Delegate
  • 是一個流行的傳遞事件的設計模式,經過定義一系列的方法來傳遞給指定的處理對象。
  • 例如:UIScrollView 每次它的 scroll offset 改變的時候都會發送 scrollViewDidScroll: 到它的代理
  1. Callbacks
  • 不論是像 NSOperation 裏的 completionBlock(當 isFinished==YES 的時候會觸發),仍是 C 裏邊的函數指針,傳遞一個函數鉤子
  • 例如:NetworkReachabilitySetCallback(3)。

最後附上KVO的流程圖:

參考資料

Key-Value Observing Programming Guide - Apple 官方文檔

iOS 底層探索KVO - leejunhui_ios

Objective-C中的KVC和KVO

Key-Value Observing - NSHipster

相關文章
相關標籤/搜索