談談 KVO


本文結構如下:

  • Why? (為什么要用KVO)
  • What? (KVO是什么)
  • How? ( KVO怎么用)
  • More (更多細節)
  • 原理
  • 自己實現KVO

在我的上一篇文章淺談 iOS Notification中,我們說到了iOS中觀察者模式的一種實現方式:NSNotification 通知,這次我們再來談談iOS中觀察者模式的另一種實現方式:KVO

Why?

假如,有一個person類,和一個Account類,account類中又有兩個公開的屬性,balance和interestRate,當account中的balance和interestRate發生變化時,需要知道通知到這個person,這個要求很正常,我的銀行賬戶里的錢增加或減少了我當然要及時知道啊。有人可能會想,每隔一段時間去輪詢Account中的balance和interestRate,當其發生變化就通知person,但是這樣做不僅低效而且通知也不能及時發出。


這個時候KVO就派上用場了。

What?

KVO到底是什么呢?不著急,要說KVO還得先說下KVC,KVC(Key-value coding)是一種基于NSKeyValueCoding非正式協議的機制,能讓我們直接使用一個或一串字符串標識符去訪問,操作類的屬性。
常用的方法比如:

- (nullable id)valueForKey:(NSString *)key;
- (void)setValue:(nullable id)value forKey:(NSString *)key;

- (nullable id)valueForKeyPath:(NSString *)keyPath;
- (void)setValue:(nullable id)value forKeyPath:(NSString *)keyPath;

通過這些方法加上正確的標識符(一般和屬性同名),可以直接獲取或者設置一個類的屬性,甚至可以輕易越過多個類的層級結構,直接獲取目標屬性。


KVC還提供了集合操作的方法,直接獲取到集合屬性的同時還能對其進行求和,取平均數,求最大最小值等操作,如下為求和操作,具體可以到蘋果官方文檔詳細了解。

NSNumber *amountSum = [self.transactions valueForKeyPath:@"@sum.amount"];
KVO

KVO (Key-Value Observing) 是Cocoa提供的一種基于KVC的機制,允許一個對象去監聽另一個對象的某個屬性,當該屬性改變時系統會去通知監聽的對象(不是被監聽的對象)。

上面那個例子如果用KVO實現的話,大概就是,用Person類的一個對象去監聽Account類的一個對象的屬性,然后當Account類對象的相應屬性改變時,Person類的對象就會收到通知。這也是iOS種觀察者模式的一種實現方式。

也就是說,一般情況下,任何一個對象可以監聽任何一個對象(當然也包括自己本身)的任意屬性,然后在其屬性變化后收到通知。

How?

那么KVO怎么用呢?KVO的使用步驟主要分為3步:添加監聽接收通知移除監聽

1. 添加監聽

通過以下方法添加一個監聽者:

- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;

首先說下這個消息(方法)的接收者,就是調用這個方法的對象,即觀察者模式中的被觀察者,這里有一點需要注意的是,被監聽的對象最好不要設置為,在當前上下文環境中容易改變的對象,因為KVO只會把添加KVO時的被監聽對象保存下來,之后要是被監聽對象發生改變了,KVO監聽的還是之前保存的那個對象,KVO回調方法就不會觸發了。

然后,我們重點關注一下這個方法的4個參數:

  • observer:就是要添加的監聽者對象,,當監聽的屬性發生改變時就會去通知該對象,該對象必須實現- observeValueForKeyPath:ofObject:change:context:方法,要不然當監聽的屬性的改變通知發出來,卻發現沒有相應的接收方法時,程序會拋出異常。

  • keyPath:就是要被監聽的屬性,這里和KVC的規則一樣。但是這個值不能傳nil,要不然會報錯。通常我們在用的時候會傳一個與屬性同名的字符串,但是這樣可能會因為拼寫錯誤,導致監聽不成功,一個推薦的做法是,用這種方式NSStringFromSelector(@selector(propertyName)),其實就是是將屬性的getter方法轉換成了字符串,這樣做的好處就是,如果你寫錯了屬性名,xcode會用警告提醒你。

  • options:是一些配置選項,用來指明通知發出的時機和通知響應方法- observeValueForKeyPath:ofObject:change:context:change字典中包含哪些值,它的取值有4個,定義在NSKeyValueObservingOptions中,可以用|符號連接,如下:
    1> NSKeyValueObservingOptionNew:指明接受通知方法參數中的change字典中應該包含改變后的新值。

2>NSKeyValueObservingOptionOld: 指明接受通知方法參數中的change字典中應該包含改變前的舊值。

3>NSKeyValueObservingOptionInitial: 當指定了這個選項時,在addObserver:forKeyPath:options:context:消息被發出去后,甚至不用等待這個消息返回,監聽者對象會馬上收到一個通知。這種通知只會發送一次,你可以利用這種“一次性“的通知來確定要監聽屬性的初始值。當同時制定這3個選項時,這種通知的change字典中只會包含新值,而不會包含舊值。雖然這時候的新值實際上是改變前的'舊值',但是這個值對于監聽者來說是新的。

4>NSKeyValueObservingOptionPrior:當指定了這個選項時,在被監聽的屬性被改變前,監聽者對象就會收到一個通知(一般的通知發出時機都是在屬性改變后,雖然change字典中包含了新值和舊值,但是通知還是在屬性改變后才發出),這個通知會包含一個NSKeyValueChangeNotificationIsPriorKeykey,其對應的值為一個NSNumber類型的YES。當同時指定該值、new和old的話,change字典會包含舊值而不會包含新值。你可以在這個通知中調用- (void)willChangeValueForKey:(NSString *)key;

  • context:添加監聽方法的最后一個參數,是一個可選的參數,可以傳任何數據,這個參數最后會被傳到監聽者的響應方法中,可以用來區分不同通知,也可以用來傳值。如果你要用context來區分不同的通知,一個推薦的做法是聲明一個靜態變量,其保持它自己的地址,這個變量沒有什么意義,但是卻能起到區分的作用,如下:
static void *PersonAccountBalanceContext = &PersonAccountBalanceContext;
static void *PersonAccountInterestRateContext = &PersonAccountInterestRateContext;

然后,結合上面Person,account的例子,我們可以給Account對象添加監聽:

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

需要注意的是,添加監聽的方法addObserver:forKeyPath:options:context:并不會對監聽和被監聽的對象以及context做強引用,你必須自己保證他們在監聽過程中不被釋放。

2. 接受通知

前面說過了,每一個監聽者對象都必須實現下面這個方法來接收通知:

- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context;

keyPath,object,context和監聽方法中指定的一樣,關于change參數,它是一個字典,有五個常量作為它的鍵:

NSString *const NSKeyValueChangeKindKey;  
NSString *const NSKeyValueChangeNewKey;  
NSString *const NSKeyValueChangeOldKey;  
NSString *const NSKeyValueChangeIndexesKey;  
NSString *const NSKeyValueChangeNotificationIsPriorKey;

一個一個分析下:

  • NSKeyValueChangeKindKey:指明了變更的類型,值為“NSKeyValueChange”枚舉中的某一個,類型為NSNumber。
enum {
   NSKeyValueChangeSetting = 1,
   NSKeyValueChangeInsertion = 2,
   NSKeyValueChangeRemoval = 3,
   NSKeyValueChangeReplacement = 4
};
typedef NSUInteger NSKeyValueChange;

一般情況下返回的都是1也就是第一個NSKeyValueChangeSetting,但是如果你監聽的屬性是一個集合對象的話,當這個集合中的元素被插入,刪除,替換時,就會分別返回NSKeyValueChangeInsertionNSKeyValueChangeRemovalNSKeyValueChangeReplacement

  • NSKeyValueChangeNewKey:被監聽屬性改變后新值的key,當監聽屬性為一個集合對象,且NSKeyValueChangeKindKey不為NSKeyValueChangeSetting時,該值返回的是一個數組,包含插入,替換后的新值(刪除操作不會返回新值)。

  • NSKeyValueChangeOldKey:被監聽屬性改變前舊值的key,當監聽屬性為一個集合對象,且NSKeyValueChangeKindKey不為NSKeyValueChangeSetting時,該值返回的是一個數組,包含刪除,替換前的舊值(插入操作不會返回舊值)

  • NSKeyValueChangeIndexesKey:如果NSKeyValueChangeKindKey的值為NSKeyValueChangeInsertion, NSKeyValueChangeRemoval, 或者 NSKeyValueChangeReplacement,這個鍵的值是一個NSIndexSet對象,包含了增加,移除或者替換對象的index。

  • NSKeyValueChangeNotificationIsPriorKey:如果注冊監聽者是options中指明了NSKeyValueObservingOptionPriorchange字典中就會帶有這個key,值為NSNumber類型的YES.

最后,完整的change字典大概就類似這樣:

    NSDictionary *change = @{
                             NSKeyValueChangeKindKey : NSKeyValueChange(枚舉值),
                             NSKeyValueChangeNewKey : newValue,
                             NSKeyValueChangeOldKey : oldValue,
                             NSKeyValueChangeIndexesKey : @[NSIndexSet, NSIndexSet],
                             NSKeyValueChangeNotificationIsPriorKey : @1,
                             };

繼續用上面的例子實現接受通知如下:

 - (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];
    }
}

你可以通過context或者keypath來區分不同的通知,但是要注意的是,正如上面實例代碼中那樣,當接收到一個不能識別的context或者keypath的話,需要調用一下父類的- observeValueForKeyPath:ofObject:change:context:方法

3. 移除監聽

當一個監聽者完成了它的監聽任務之后,就需要注銷(移除)監聽者,調用以下2個方法來移除監聽。通常會在-dealloc方法或者-observeValueForKeyPath:ofObject:change:context:方法中移除。

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context
或者
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

有幾點需要注意的:

  • 當你向一個不是監聽者的對象發送remove消息的時候(也可能是,你發送remove消息時,接受消息的對象已經被remove了一次,或者在注冊為監聽者前就調用了remove),xcode會拋出一個NSRangeException異常,所以,保險的做法是,把remove操作放在try/catch中。

  • 一個監聽者在其被銷毀時,并不會自己注銷監聽,而給一個已經銷毀的監聽者發送通知,會造成野指針錯誤。所以至少保證,在監聽者被釋放前,將其監聽注銷。保證有一個add方法,就有一個remove方法。

More

再說更多的一些東西,想讓類的某個屬性支持KVO機制的話,這個類必須滿足一下3點:

  1. 這個類必須使得該屬性支持KVC。
  2. 這個類必須保證能夠將改變通知發出。
  3. 當有依賴關系的時候,注冊合適的依賴鍵。
  • 第一個條件:這個類必須使得該屬性支持KVC
    就是需要實現與該屬性對應的getter和setter方法和其他一些可選方法。幸運的是,NSObject類已經幫我們實現了這些,只要你的類最終是繼承自NSObject,并且使用正常的方式創建屬性,這些屬性都是支持KVO的。

KVO支持的類型和KVC一樣,包括對象類型,標量(例如 intCGFloat)和 struct(例如 CGRect)。

  • 第二個條件:這個類必須保證能夠將改變通知發出。
    通知發出的方式又分為自動通知手動通知
    1> 自動通知
    自動通知由NSObject默認實現了,也就是說一般情況下,你不用寫額外的一些代碼,屬性改變的通知就會自動發出,這也是我們平常開發中接觸最多的。

觸發自動通知發出的方式包括下面這些:

 // 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];

其中包括調用setter方法,調用KVC的setValue:forKey:setValue:forKeyPath:,最后一個方法需要說一下,mutableArrayValueForKey:也是KVC的方法,大家應該都知道,如果你用KVO監聽了一個集合對象(比如一個數組),當你給數組發送addObject:消息時,是不會觸發KVO通知的,但是通過mutableArrayValueForKey:這個方法對集合對象進行的相關操作(增加,刪除,替換元素)就會觸發KVO通知,這個方法會返回一個中間代理對象,這個中間代理對象的類會指向一個中間類,你在這個代理對象上進行的操作最終應在原始對象上造成同樣的效果。
2> 手動通知
有時候,你可能會想控制通知的發送,比如,阻止一些不必要的通知發出,或者把一組類似的通知合并成一個,這時候就需要手動發送通知了。

首先,你需要重寫NSObject的一個類方法,來指明你不想讓哪個屬性的改變通知自動發出。

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey {

   BOOL automatic = NO;
   if ([theKey isEqualToString:@"balance"]) {
       automatic = NO;
   }
   else {
       automatic = [super automaticallyNotifiesObserversForKey:theKey];
   }
   return automatic;
}

如上,return NO就可以阻止,該key對應的屬性改變時,通知不會自動發送給監聽者對象,當然對于其他的屬性別忘了調用super方法保持它原來的狀態。(改方法默認返回YES)

然后,你需要重寫你想手動發送通知屬性的setter方法,然后在屬性值改變之前和之后分別調用willChangeValueForKey:didChangeValueForKey:方法。

 - (void)setBalance:(double)theBalance {
    [self willChangeValueForKey:@"balance"];
    _balance = theBalance;
    [self didChangeValueForKey:@"balance"];
 }

這樣就基本實現了一個KVO的手動通知,當該屬性值改變時,監聽者對象就能收到改變通知了。

你還可以過濾一些通知,像下面的例子就是只有當屬性真正改變時才會發出通知

 - (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"];
 }

在to-many關系操作的情形中,你不僅必須表明key是什么,還要表明變更類型和影響到的索引。變更類型是一個 NSKeyValueChange值,被影響對象的索引是一個 NSIndexSet對象。

下面的代碼示范了在to-many關系transactions對象中的刪除操作:

 - (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"];
 }
  • 第三個條件:這個類必須使得該屬性支持KVC

有時候會存在這樣一種情況,一個屬性的改變依賴于別的一個或多個屬性的改變,也就是說當別的屬性改了,這個屬性也會跟著改變,比如說一個人的全名fullName包括firstName和lastName,當firstName或者lastName中任何一個值改變了,fullName也就改變了。一個監聽者監聽了fullName,當firstName或者lastName改變時,這個監聽者也應該被通知。

一種方法就是重寫keyPathsForValuesAffectingValueForKey:方法去指明fullName屬性是依賴于lastNamefirstName的:

 + (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
 
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
 
    if ([key isEqualToString:@"fullName"]) {
        NSArray *affectingKeys = @[@"lastName", @"firstName"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    }
    return keyPaths;
 }

另一種實現同樣結果的方法是實現一個遵循命名方式為keyPathsForValuesAffecting<Key>的類方法,<Key>是依賴于其他值的屬性名(首字母大寫),用上面代碼的例子來重新實現一下:

 + (NSSet *)keyPathsForValuesAffectingFullName {
    return [NSSet setWithObjects:@"lastName", @"firstName", nil];
}

但是在To-many Relationships中(比如數組屬性),上面的方法就不管用了,比如,假如你有一個Department類,它有一個針對Employee類的to-many關系(即擁有一個裝有Employee類對象的數組),Employee類有salary屬性。你希望Department類有一個totalSalary屬性來計算所有員工的薪水,也就是在這個關系中DepartmenttotalSalary依賴于所有Employeesalary屬性。這種情況你不能通過實現keyPathsForValuesAffectingTotalSalary方法并返回employees.salary

有兩種解決方法:

  1. 你可以用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;
}
  1. 使用iOS中觀察者模式的另一種實現方式:通知 (NSNotification) ,有關通知相關的概念和用法,可以參考我上一篇文章 淺談 iOS Notification 。??

原理

說了這么多,KVO的原理到底是什么呢?

先上官方文檔:

  Automatic key-value observing is implemented using a technique called 
isa-swizzling...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.

對于KVO實現的原理,蘋果官方文檔描述的比較少,從中只能知道蘋果使用了一張叫做isa-swizzling的黑魔法...

其實,當某個類的對象第一次被觀察時,系統就會在運行期動態地創建該類的一個派生類(類名就是在該類的前面加上NSKVONotifying_ 前綴),在這個派生類中重寫基類中任何被觀察屬性的 setter 方法。

派生類在被重寫的 setter 方法實現真正的通知機制,就如前面手動實現鍵值觀察那樣,調用willChangeValueForKey:didChangeValueForKey:方法。這么做是基于設置屬性會調用 setter 方法,而通過重寫就獲得了 KVO 需要的通知機制。當然前提是要通過遵循 KVO 的屬性設置方式來變更屬性值,如果僅是直接修改屬性對應的成員變量,是無法實現 KVO 的。

同時派生類還重寫了 class 方法以“欺騙”外部調用者它就是起初的那個類。然后系統將這個對象的 isa 指針指向這個新誕生的派生類,因此這個對象就成為該派生類的對象了,因而在該對象上對 setter 的調用就會調用重寫的 setter,從而激活鍵值通知機制。此外,派生類還重寫了 dealloc 方法來釋放資源。

自己實現KVO

港真,原生的KVO API是不太友好的,需要監聽者對象,和被監聽的對象分別去實現一些東西,代碼實現比較分散,并且響應通知的方法也不能自定義,只能在蘋果提供的方法中處理,不能用我們熟悉的block或者Target-Action,最后還不能忘了調用removeObserve方法,一忘可能程序運行的時候就奔潰了...

在知道了KVO的使用方法和內部原理之后,我們其實可以自己去實現一個使用起來更加便捷,API更加友好的KVO的,這類的實現網上有很多,我就不獻丑了... github上也有一些開源的實現代碼,感興趣的童鞋可以自行查閱。

其實基本思路和蘋果官方的原理差不多,都是創建一個原類的派生類當做中間類,再把原來的對象指向這個中間類,再重寫監聽屬性的Setter方法,在屬性改變后調用回調通知監聽者。


參考:

KVO官方文檔
KVC官方文檔
Objective-C中的KVC和KVO
詳解鍵值觀察(KVO)及其實現機理

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 本文由我們團隊的 糾結倫 童鞋撰寫。 文章結構如下: Why? (為什么要用KVO) What? (KVO是什么...
    知識小集閱讀 7,427評論 7 105
  • 在iOS開發中,我們常常用到鍵值編碼KVC和鍵值監聽KVO兩個東東,今天小編和大家分享的就是這兩個東東在應用開發中...
    突然自我閱讀 1,018評論 2 3
  • 由于ObjC主要基于Smalltalk進行設計,因此它有很多類似于Ruby、Python的動態特性,例如動態類型、...
    JonesCxy閱讀 379評論 0 0
  • 你要知道的KVC、KVO、Delegate、Notification都在這里 轉載請注明出處 http://www...
    WWWWDotPNG閱讀 1,827評論 1 3
  • 由于ObjC主要基于Smalltalk進行設計,因此它有很多類似于Ruby、Python的動態特性,例如動態類型、...
    JonesCxy閱讀 481評論 1 0