KVO使用進(jìn)階和底層原理

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ù)更新...

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末巨税,一起剝皮案震驚了整個濱河市蟋定,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌草添,老刑警劉巖驶兜,帶你破解...
    沈念sama閱讀 206,723評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異远寸,居然都是意外死亡抄淑,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,485評論 2 382
  • 文/潘曉璐 我一進(jìn)店門驰后,熙熙樓的掌柜王于貴愁眉苦臉地迎上來肆资,“玉大人,你說我怎么就攤上這事灶芝〖嗍穑” “怎么了?”我有些...
    開封第一講書人閱讀 152,998評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長驹愚,這世上最難降的妖魔是什么逢捺? 我笑而不...
    開封第一講書人閱讀 55,323評論 1 279
  • 正文 為了忘掉前任志于,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好屿附,可當(dāng)我...
    茶點故事閱讀 64,355評論 5 374
  • 文/花漫 我一把揭開白布优训。 她就那樣靜靜地躺著揣非,像睡著了一般。 火紅的嫁衣襯著肌膚如雪搞监。 梳的紋絲不亂的頭發(fā)上棍矛,一...
    開封第一講書人閱讀 49,079評論 1 285
  • 那天抛杨,我揣著相機(jī)與錄音够委,去河邊找鬼。 笑死怖现,一個胖子當(dāng)著我的面吹牛茁帽,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播屈嗤,決...
    沈念sama閱讀 38,389評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼潘拨,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了饶号?” 一聲冷哼從身側(cè)響起铁追,我...
    開封第一講書人閱讀 37,019評論 0 259
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎茫船,沒想到半個月后琅束,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,519評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡算谈,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,971評論 2 325
  • 正文 我和宋清朗相戀三年涩禀,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片然眼。...
    茶點故事閱讀 38,100評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡艾船,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情丽声,我是刑警寧澤礁蔗,帶...
    沈念sama閱讀 33,738評論 4 324
  • 正文 年R本政府宣布觉义,位于F島的核電站雁社,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏晒骇。R本人自食惡果不足惜霉撵,卻給世界環(huán)境...
    茶點故事閱讀 39,293評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望洪囤。 院中可真熱鬧徒坡,春花似錦、人聲如沸瘤缩。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,289評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽慰枕。三九已至罩阵,卻和暖如春曼玩,著一層夾襖步出監(jiān)牢的瞬間我磁,已是汗流浹背鹦肿。 一陣腳步聲響...
    開封第一講書人閱讀 31,517評論 1 262
  • 我被黑心中介騙來泰國打工讹挎, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留如筛,地道東北人牺丙。 一個月前我還...
    沈念sama閱讀 45,547評論 2 354
  • 正文 我出身青樓则涯,卻偏偏與公主長得像,于是被迫代替她去往敵國和親冲簿。 傳聞我的和親對象是個殘疾皇子粟判,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,834評論 2 345

推薦閱讀更多精彩內(nèi)容