KVO使用
KVO(key-value-observing)鍵值監(jiān)聽常用來監(jiān)聽特定對象中某屬性值的變化轨奄,日常開發(fā)中我們常常監(jiān)聽數(shù)據(jù)模型的變化從而動態(tài)的修改對應(yīng)視圖。當(dāng)然上述需求用代理和通知機(jī)制也可以完成腕让,但它們都有各自的優(yōu)缺點和適用場景,后面會詳細(xì)介紹歧斟。
常用方法
常用的方法有如下幾個纯丸,各參數(shù)含義見注釋:
/*
注冊觀察者(監(jiān)聽器對象)
觀察者對象是observer,被觀察者是消息的發(fā)送者(方法的調(diào)用者)静袖,在回調(diào)函數(shù)中會被回傳
觀察的屬性路徑為keyPath觉鼻,支持點語法的嵌套
觀察類型為options,支持按位或來監(jiān)聽多個事件類型
觀察上下文context队橙,主要用于對多個觀察者對象觀察相同keyPath時進(jìn)行區(qū)分
添加觀察者只會保留觀察者對象的地址坠陈,不會增加引用,也不會在對象釋放后置空捐康,因此需要自己持有觀察者對象的強(qiáng)引用仇矾,該參數(shù)也會在回調(diào)函數(shù)中回傳
*/
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
/*
刪除觀察者
觀察者對象為observer,被觀察者對象為消息的發(fā)送者即方法的調(diào)用者吹由,應(yīng)與addObserver方法匹配
觀察的屬性路徑為keyPath若未,應(yīng)與addObserver方法的keyPath匹配
觀察上下文context朱嘴,應(yīng)與addObserver方法的context匹配
*/
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0));
/*
與上一個方法相同倾鲫,只是少了context參數(shù)
推薦使用上一個方法,該方法由于沒有傳遞context可能會產(chǎn)生異常結(jié)果
*/
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
/*
觀察者對象的觀察回調(diào)方法萍嬉,被觀察對象屬性發(fā)生變化時乌昔,觀察者會調(diào)用該方法
keyPath即為觀察的屬性路徑
object為被觀察的對象
change保存被觀察的值產(chǎn)生的變化
context為觀察上下文,由add方法回傳
*/
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context;
謹(jǐn)記壤追,我們要在對象銷毀前刪除監(jiān)聽器磕道。這要從KVO注冊監(jiān)聽器開始說起。
KVO在注冊監(jiān)聽器的時候行冰,不會持有觀察者對象的引用溺蕉,也不會像weak那樣,在觀察者對象被銷毀時置nil悼做,而僅僅保留觀察者對象的地址疯特,類似于copy。當(dāng)觀察者對象被銷毀而又沒有刪除監(jiān)聽器時肛走,如果這個時候被觀察對象的值發(fā)生變化系統(tǒng)會執(zhí)行監(jiān)聽器的回調(diào)函數(shù)漓雅,這個時候觀察者對象已經(jīng)不存在了,KVO保留的地址就是一個野指針,因此會產(chǎn)生野指針錯誤邻吞。
下面來看一個產(chǎn)生上面情況的例子:
程序界面如圖所示
操作過程為:先點擊紫色按鈕组题,跳轉(zhuǎn)到黃色試圖控制器,再點擊黑色按鈕回到綠色試圖控制器抱冷,最后點擊紅色按鈕崔列。
// 示例程序共有兩個viewController,一個是根控制器viewcontroller旺遮,一個是displayViewcontroller峻呕。
// 根控制器上有兩個按鈕,上面的按鈕用來跳轉(zhuǎn)到displayViewcontroller趣效。下面的按鈕用來改變被觀察屬性的值瘦癌。
// displayViewcontroller上有一個按鈕,用來退出當(dāng)前控制器
// viewController.m
- (IBAction)jumpToDisplayButton:(UIButton *)sender {
HuPerson *person = [[HuPerson alloc] init];// HuPerson是要觀察的模型對象
_person = person;
HuDisplayViewController *displayVC = [[HuDisplayViewController alloc] initWithModel:person];
[self presentViewController:displayVC animated:YES completion:nil];
}
- (IBAction)changeAgeButton:(UIButton *)sender {
_person.age = 20;
}
// displayViewcontroller.m
-(instancetype)initWithModel:(HuPerson *)person {
if (self = [super init]) {
_person = person;
// 創(chuàng)建監(jiān)聽器
[_person addObserver:self forKeyPath:@"age" options:NSKeyValueObservingOptionNew context:nil];
}
return self;
}
// 監(jiān)聽器的回調(diào)方法
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if (object == self.person && [keyPath isEqual: @"age"]) {
NSLog(@"%@", change);
}
}
// 注釋掉該段代碼跷敬,測試如果不刪除監(jiān)聽器會發(fā)生什么情況
//- (void)dealloc {
// [_person removeObserver:self forKeyPath:@"age" context:nil];
//}
- (IBAction)clickButton:(UIButton *)sender {
[self dismissViewControllerAnimated:self completion:nil];
}
我們在開發(fā)中經(jīng)常會遇到這種需求:在一個頁面獲取到數(shù)據(jù)后讯私,使用另一個頁面來展示數(shù)據(jù),第二個界面很有可能根據(jù)需求來監(jiān)聽模型對象西傀。如果我們像上面的代碼一樣斤寇,沒有在觀察者對象銷毀的時候釋放監(jiān)聽器,那么在點擊viewController第二個按鈕的時候拥褂,就會產(chǎn)生野指針錯誤娘锁。
因為在第二個按鈕的點擊方法中,我們改變了被觀察對象屬性的值饺鹃,由于前一個視圖中沒有釋放監(jiān)聽器莫秆,KVO中仍有監(jiān)聽器的存在,此時會觸發(fā)監(jiān)聽器的回調(diào)方法悔详,但displayViewcontroller已經(jīng)被銷毀了镊屎,因此產(chǎn)生野指針。
KVO中一對多 和 多對一
- KVO支持多個觀察者對象觀察同一對象的某個屬性茄螃。上面的例子中缝驳,我們在viewController中也添加對person.age的監(jiān)聽。當(dāng)age屬性發(fā)生變化的時候归苍,監(jiān)聽器會觸發(fā)所有監(jiān)聽該屬性的回調(diào)函數(shù)用狱。
- KVO也支持一個觀察者對象觀察多個屬性∑雌可以按照我們常用的通過keyPath字符串來判斷產(chǎn)生回調(diào)的具體是哪個屬性值夏伊,但如果監(jiān)聽很多屬性值,這樣的方法看起來很凌亂肴敛,而且逐一進(jìn)行字符串判斷很浪費資源署海,并且當(dāng)我們在后期修改了屬性的名稱還不能忘記修改監(jiān)聽器的keyPath判斷語句吗购,那有什么辦法能夠取代keyPath嗎?答案是context砸狞,之前我經(jīng)常直接將context置為nil捻勉,但context才是KVO保證正確運行的關(guān)鍵,context也是蘋果推薦我們的做法刀森。
context參數(shù)的使用
context是一個id類型的參數(shù)踱启,在注冊監(jiān)聽器時可以傳入該參數(shù),在回調(diào)函數(shù)中會回傳該參數(shù)研底,因此埠偿,該參數(shù)可以解決KVO中一對多,多對一產(chǎn)生的一些問題榜晦。那context這個id類型的參數(shù)設(shè)置為什么值比較合適呢冠蒋?可能第一感覺還是設(shè)置為NSString類型,但這樣仍然可能會產(chǎn)生沖突乾胶,蘋果推薦的做法是創(chuàng)建一個靜態(tài)變量然后使用該靜態(tài)變量的地址作為context抖剿,通過這樣的方法就能夠保證context唯一。
首先來看一個例子:
/*
本例子需要使用三個UIViewController
ViewController是根視圖控制器
DisplayViewController 父視圖控制器
SubViewController 子視圖控制器
ViewController不監(jiān)聽模型识窿,包括一個按鈕用于創(chuàng)建SubViewController并展示
DisplayViewController跟上述例子中的一樣
SubViewController繼承DisplayViewController并且也創(chuàng)建了監(jiān)聽器來監(jiān)聽person.age屬性
*/
//ViewController部分代碼如下
//該控制器只有一個按鈕
- (void)buttonClicked
{
SubViewController *vc = [[SubViewController alloc] initWithModel:self.person];
[self presentViewController:vc animated:YES completion:nil];
}
//DisplayViewController的部分代碼如下
//為了便于輸出這里使用的是NSString類型的context
static void * DisplayViewControllerBalanceObserverContext = @"父類的context";
//在初始化方法中輸入上面的變量作為context進(jìn)行監(jiān)聽器的注冊
[self.person addObserver:self forKeyPath:@"age" options:NSKeyValueObservingOptionInitial context:DisplayViewControllerBalanceObserverContext];
//退出按鈕方法
- (void)exitButtonClickedHandler
{
[self dismissViewControllerAnimated:YES completion:nil];
}
//模擬修改模型數(shù)據(jù)變化的按鈕
- (void)changeValueButtonClickedHandler
{
self.person.age = 110;
}
//監(jiān)聽器回調(diào)函數(shù)
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
//將void *的context轉(zhuǎn)換為NSString類型
NSString *d = (__bridge NSString*)context;
NSLog(@"%@", d);
if (context == DisplayViewControllerBalanceObserverContext)
{
NSLog(@"父類監(jiān)聽的age屬性改變了%lf", self.person.age);
}
}
//刪除監(jiān)聽器
- (void)dealloc
{
[self.model removeObserver:self forKeyPath:@"age" context:DisplayViewControllerBalanceObserverContext];
}
//SubViewController部分代碼如下
//為了便于輸出使用NSString類型的context
static void * SubViewControllerBalanceObserverContext = @"子類的context";
- (instancetype)initWithModel:(HuPerson *)model;
{
if (self = [super initWithModel:model])
{
//注冊監(jiān)聽器
[self.person addObserver:self forKeyPath:@"age" options:NSKeyValueObservingOptionNew context:SubViewControllerBalanceObserverContext];
}
return self;
}
//監(jiān)聽器回調(diào)方法
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
NSString *d = (__bridge NSString*)context;
NSLog(@"%@", d);
if (context == SubViewControllerBalanceObserverContext)
{
NSLog(@"子類監(jiān)聽的age值改變了: %lf", self.person.age);
}
}
//刪除監(jiān)聽器
- (void)dealloc
{
[self.person removeObserver:self forKeyPath:@"age" context:SubViewControllerBalanceObserverContext];
上述代碼運行后斩郎,根視圖控制器為ViewController展示一個按鈕,點擊后會創(chuàng)建SubViewController并展示喻频,此時會有兩個按鈕缩宜,一個退出、一個修改模型值甥温,接下來點擊修改模型值按鈕會發(fā)現(xiàn)有如下輸出:
子類的context
子類監(jiān)聽的age值改變了:110
父類的context
當(dāng)我們點擊修改模型按鈕后會觸發(fā)監(jiān)聽器的回調(diào)函數(shù)锻煌,然后執(zhí)行SubViewController的回調(diào)方法就會輸出上面兩行的打印結(jié)果,那第三行是什么呢窿侈?第三行還是SubViewController的輸出結(jié)果炼幔,但是打印的context卻是DisplayViewController注冊的,也就是說史简,KVO在觸發(fā)回調(diào)函數(shù)時會向所有注冊了的監(jiān)聽器發(fā)送回調(diào)信息,也就是所有注冊了的監(jiān)聽器都會執(zhí)行回調(diào)函數(shù)肛著,但由于繼承關(guān)系的存在沒有執(zhí)行父類的回調(diào)函數(shù)而是執(zhí)行了兩次子類的回調(diào)函數(shù)圆兵,因此,為了使得父類也能夠正確執(zhí)行監(jiān)聽器的回調(diào)函數(shù)枢贿,在子類的回調(diào)函數(shù)中應(yīng)當(dāng)手動調(diào)用殉农,所以子類監(jiān)聽器回調(diào)函數(shù)正確的寫法應(yīng)是如下代碼:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
if (context == SubViewControllerBalanceObserverContext)
{
NSLog(@"SubViewController NewAge: %lf", self.person.age);
}
else
{
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
這里我們是展示了使用正確使用context會避免很多問題,正如大多數(shù)人平常開發(fā)的時候那樣局荚,如果我們設(shè)置context為nil超凳,會發(fā)生什么情況呢愈污?
僅僅通過keyPath判斷,根本無法得知繼承的父類是否也在監(jiān)聽同一對象轮傍,如果我們繼承的是第三方的框架暂雹,很可能就會產(chǎn)生未知的異常。蘋果也建議我們針對我們監(jiān)聽的每一個屬性都創(chuàng)建一個context创夜,不建議使用keyPath來做字符串的判斷杭跪,并且字符串判斷的效率也很低.
手動觸發(fā)KVO
有時我們可能有一些需求,在屬性值滿足要求下才去觸發(fā)KVO驰吓,我們可以直接在回調(diào)函數(shù)中進(jìn)行判斷就好涧尿,但是當(dāng)我們開發(fā)一些供他人使用的框架時我們不能保證其他用戶能夠按照要求進(jìn)行條件判斷,此時就需要手動觸發(fā)KVO檬贰。
觸發(fā)監(jiān)聽器回調(diào)函數(shù)時需要滿足一個類方法:
//age屬性實現(xiàn)該方法
+ (BOOL)automaticallyNotifiesObserversOfAge
//其他屬性按照以下格式實現(xiàn)類方法
+ (BOOL)automaticallyNotifiesObserversOfName
通過函數(shù)名就可以判斷姑廉,該函數(shù)是用來判斷是否自行進(jìn)行監(jiān)聽器通知,默認(rèn)返回true翁涤,因此默認(rèn)情況下都是自動觸發(fā)KVO的回調(diào)函數(shù)庄蹋,如果要手動觸發(fā)則需要返回false并在需要觸發(fā)KVO回調(diào)函數(shù)的地方執(zhí)行以下方法:
//對需要觸發(fā)回調(diào)函數(shù)的屬性名稱調(diào)用如下方法
[self willChangeValueForKey:@"balance"];
//為其賦新值
_balance = balance;
[self didChangeValueForKey:@"balance"];
注意,上面兩個函數(shù)要寫在觸發(fā)回調(diào)函數(shù)的前后迷雪,即屬性的set方法中限书。
KVO監(jiān)聽NSMutableArray內(nèi)部的變化
簡單理解KVO底層實現(xiàn)原理,其實是重寫了觀察屬性的set方法章咧,詳細(xì)內(nèi)容會在下面一節(jié)解釋倦西。然而對于NSMutableArray來講,添加赁严、刪除元素并沒有調(diào)用set方法扰柠,因此不會觸發(fā)KVO,我們也就監(jiān)聽不到NSMutableArray的變化疼约,有什么辦法呢卤档?
蘋果官方為我們提供了一個方法
-(NSMutableArray *)mutableArrayValueForKey:(NSString *)key;
用一個例子來說明該方法的使用:
myItems是我們進(jìn)行KVO的一個屬性,定義如下:
@property(nonatomic, strong) NSMutableArray *myItems;
按照上面所講的正常方法對其添加觀察者程剥,但是在它添加元素時劝枣,使用如下方法:
[[self mutableArrayValueForKey:@"myItems"] addObject:@"one"];
這樣,我們便用KVO實現(xiàn)了對可變數(shù)組的監(jiān)聽织鲸。
總結(jié)
- 使用靜態(tài)變量的地址作為context舔腾,并且為每一個監(jiān)聽的屬性都創(chuàng)建一個context,盡量不使用keyPath作為區(qū)分條件搂擦。
- addObserver與removeObserver必須要成套出現(xiàn)稳诚,建議在dealloc方法中刪除監(jiān)聽器對象。
- 如果有繼承關(guān)系瀑踢,在監(jiān)聽器回調(diào)函數(shù)中將不是當(dāng)前類處理的context調(diào)用父類的監(jiān)聽器回調(diào)函數(shù)進(jìn)行處理扳还。
- 刪除監(jiān)聽器時需要注意不要重復(fù)刪除才避,盡量使用context刪除。
KVO底層原理
原理剖析
當(dāng)對一個對象的屬性第一次進(jìn)行監(jiān)聽器注冊后氨距,編譯器會默認(rèn)生成一個名稱為NSKVONotifying_原有類名稱的派生中間類桑逝,該類繼承原有類,然后修改原有類對象的isa指針衔蹲,使其指向新生成的中間類肢娘,接著,會在派生類中修改監(jiān)聽屬性的setter和getter方法舆驶,執(zhí)行willChangeValueForKey:和didChangeValueForKey:方法和父類的setter方法橱健,并通知所有監(jiān)聽的對象,監(jiān)聽屬性被修改了沙廉。
因此拘荡,對于使用KVO監(jiān)聽的類來說,isa指針的指向并不一定指向?qū)ο蟮膶嶋H類撬陵。我們不應(yīng)該依賴isa指針去決定類的成員關(guān)系珊皿,而應(yīng)該使用class方法去正確的獲取對象的實際類。
實現(xiàn)自定義的KVO監(jiān)聽
此部分后續(xù)更新...