作者:鄧凱輝
文章結構如下:
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;
我們重點關注一下這個方法的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*)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,但是如果你監聽的屬性是一個集合對象的話,當這個集合中的元素被插入,刪除,替換時,就會分別返回NSKeyValueChangeInsertion,NSKeyValueChangeRemoval和NSKeyValueChangeReplacement。
NSKeyValueChangeNewKey:被監聽屬性改變後新值的key,當監聽屬性為一個集合對象,且NSKeyValueChangeKindKey不為NSKeyValueChangeSetting時,該值返回的是一個數組,包含插入,替換後的新值(刪除操作不會返回新值)。
NSKeyValueChangeOldKey:被監聽屬性改變前舊值的key,當監聽屬性為一個集合對象,且NSKeyValueChangeKindKey不為NSKeyValueChangeSetting時,該值返回的是一個數組,包含刪除,替換前的舊值(插入操作不會返回舊值)
NSKeyValueChangeIndexesKey:如果NSKeyValueChangeKindKey的值為NSKeyValueChangeInsertion, NSKeyValueChangeRemoval, 或者 NSKeyValueChangeReplacement,這個鍵的值是一個NSIndexSet對象,包含了增加,移除或者替換對象的index。
NSKeyValueChangeNotificationIsPriorKey:如果注冊監聽者是options中指明了NSKeyValueObservingOptionPrior,change字典中就會帶有這個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點:
這個類必須使得該屬性支持KVC。
這個類必須保證能夠將改變通知發出。
當有依賴關系的時候,注冊合適的依賴鍵。
第一個條件:這個類必須使得該屬性支持KVC
就是需要實現與該屬性對應的getter和setter方法和其他一些可選方法。幸運的是,NSObject類已經幫我們實現了這些,只要你的類最終是繼承自NSObject,並且使用正常的方式創建屬性,這些屬性都是支持KVO的。
KVO支持的類型和KVC一樣,包括對象類型,標量(例如 int 和 CGFloat)和 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屬性是依賴於lastName和firstName的:
+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key { NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key]; if ([key isEqualToString:@"fullName"]) { NSArray *affectingKeys = @[@"lastName", @"firstName"]; keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys]; } return keyPaths; }
另一種實現同樣結果的方法是實現一個遵循命名方式為keyPathsForValuesAffecting
+ (NSSet *)keyPathsForValuesAffectingFullName { return [NSSet setWithObjects:@"lastName", @"firstName", nil]; }
但是在To-many Relationships中(比如數組屬性),上面的方法就不管用了,比如,假如你有一個Department類,它有一個針對Employee類的to-many關系(即擁有一個裝有Employee類對象的數組),Employee類有salary屬性。你希望Department類有一個totalSalary屬性來計算所有員工的薪水,也就是在這個關系中Department的totalSalary依賴於所有Employee的salary屬性。這種情況你不能通過實現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:@"[email protected]"]]; } - (void)setTotalSalary:(NSNumber *)newTotalSalary { if (totalSalary != newTotalSalary) { [self willChangeValueForKey:@"totalSalary"]; _totalSalary = newTotalSalary; [self didChangeValueForKey:@"totalSalary"]; } } - (NSNumber *)totalSalary { return _totalSalary; }
使用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 toan intermediate class rather than at the true class. As a result the value of the isa pointer does not necessarily reflect the actual classof 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)及其實現機理