探索KVO觸發通知的過程

這兩天在研究KVO,首先要吐槽的當然是官方提供的api,用起來實在是麻煩,所以想著封裝一下,增加一個block回調什么的。這是成果:ZNKVOManager

寫完之后發現FB在幾年前就已經寫好了。。。

關于KVO的基本知識,這里就不介紹了,對KVO一竅不通的可以先看看蘋果的官方文檔。這幾天研究的主要內容是:憑什么只要調用一下屬性的set方法,觀察者就能收到通知呢?

參考一下notification,起碼還有一個NSNotificationCenter負責轉發,KVO可什么都沒有提供,這么想來,一定是:

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

這個方法里面做了一些手腳,下面就是記錄一下研究的過程。內容不多。


在看蘋果的官方文檔時,有這么一段話:

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.

翻譯:在addObserver的時候,會生成一個中間類,被觀察對象的isa指針實際指向這個中間類。

中間類究竟是什么樣?在KVO中起到什么作用?通過代碼看一看。首先是被觀察的對象,很簡單,就一個屬性:

// ZNObject.h
@interface ZNObject : NSObject
@property (nonatomic, copy) NSString *name;
@end

//ZNObject.m
@implementation ZNObject
@end

再看看具體測試的代碼:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.obj = [ZNObject new];
    NSLog(@"%p", self.obj);
    NSLog(@"%p", [ZNObject class]);

    self.kvoManager = [[ZNKVOManager alloc] initWithObserver:self];
    [self.kvoManager observe:self.obj
                  forKeyPath:@"name"
                    callBack:^(id observer, id object, NSString *keyPath, NSDictionary<NSKeyValueChangeKey,id> *change) {
                           NSLog(@"new value is: %@", change[NSKeyValueChangeNewKey]);
                           NSLog(@"old value is: %@", change[NSKeyValueChangeOldKey]);
                       }];

    NSLog(@"%@", [self.obj class]);
    NSLog(@"%@", object_getClass(self.obj));
    
    self.obj.name = @"haha";
}

這里使用了ZNKVOManager,實現代碼可以參考文章開始給出的鏈接。

其中這個方法:

- (void)observe:(id)object
     forKeyPath:(NSString *)keyPath
       callBack:(ZNKVOCallBack)callBack;

內部調用了addObserver方法,所以在調用完之后,獲取了一下className,看看中間類是什么。最后一行設置了name的值,會進入block中,打印出新值和舊值。

這里需要關注的是類名打印的結果:

...
2017-03-17 10:59:12.655055 TestOSX[88627:4489127] ZNObject
2017-03-17 10:59:12.655117 TestOSX[88627:4489127] NSKVONotifying_ZNObject
...

通過class方法獲取到的類依然是ZNObject,但是通過runtime提供的object_getClass(id obj)方法獲取到的卻是一個叫做NSKVONotifying_ZNObject的類??雌饋磉@就是所謂的中間類。

看看class方法的源碼:

// class方法
- (Class)class {
    return object_getClass(self);
}

class方法內部就是調用的object_getClass(id obj)。這就引出了一個疑問:為什么通過class方法和object_getClass(id obj)方法獲取到的類不同呢?

在這之前,先看看這個中間類的信息,根據上面的官方信息,對象的isa指針指向的就是這個中間類,把代碼稍微修改一下,獲取中間類的地址:

NSLog(@"%p", object_getClass(self.obj));

2017-03-17 11:23:54.737805 TestOSX[89053:4504224] 0x6000001033c0

先看看這個類有哪些方法:

如果這一段看不懂,請先看一看Runtime源碼 —— 對象、類和isa以及Runtime源碼 —— 方法加載的過程

(lldb) p (objc_object *)0x6000001033c0
(objc_object *) $0 = 0x00006000001033c0
(lldb) p (class_data_bits_t *)0x00006000001033e0
(class_data_bits_t *) $1 = 0x00006000001033e0
(lldb) p $1->data()
(class_rw_t *) $2 = 0x000060000007c080
(lldb) p (*$2).methods
(method_array_t) $3 = {
  list_array_tt<method_t, method_list_t> = {
     = {
      list = 0x000060000004b1c1
      arrayAndFlag = 105553116574145
    }
  }
}
(lldb) p $3.beginCategoryMethodLists()
(method_list_t **) $4 = 0x000060000004b1c8
(lldb) p $4[0][0]
(method_list_t) $5 = {
  entsize_list_tt<method_t, method_list_t, 3> = {
    entsizeAndFlags = 26
    count = 1
    first = {
      name = "setName:"
      types = 0x00000001000029eb "v24@0:8@16"
      imp = 0x00007fff980d4496 (Foundation`_NSSetObjectValueAndNotify)
    }
  }
}
(lldb) p $4[1][0]
(method_list_t) $6 = {
  entsize_list_tt<method_t, method_list_t, 3> = {
    entsizeAndFlags = 26
    count = 1
    first = {
      name = "class"
      types = 0x0000000100104da8 "#16@0:8"
      imp = 0x00007fff980bfcea (Foundation`NSKVOClass)
    }
  }
}
(lldb) p $4[2][0]
(method_list_t) $7 = {
  entsize_list_tt<method_t, method_list_t, 3> = {
    entsizeAndFlags = 26
    count = 1
    first = {
      name = "dealloc"
      types = 0x0000000100104d52 "v16@0:8"
      imp = 0x00007fff980dc5bf (Foundation`NSKVODeallocate)
    }
  }
}
(lldb) p $4[3][0]
(method_list_t) $8 = {
  entsize_list_tt<method_t, method_list_t, 3> = {
    entsizeAndFlags = 26
    count = 1
    first = {
      name = "_isKVOA"
      types = 0x00007fff983e2b70 "c16@0:8"
      imp = 0x00007fff981e0c68 (Foundation`NSKVOIsAutonotifying)
    }
  }
}
(lldb) p $4[4][0]
error: Couldn't apply expression side effects : Couldn't dematerialize a result variable: couldn't read its memory

一共有4個方法,分別是setName,class,dealloc,_isKVOA.

到這里其實已經可以做出一些猜測了。

  • 之前那個疑問應該就是中間類重寫了class方法導致的,因為這個class方法的imp是:Foundation`NSKVOClass。在這個方法內部肯定做了一些事情。
  • 中間類重寫了setName方法,所以KVO應該在這個方法的imp:Foundation`_NSSetObjectValueAndNotify中做了一些事情來實現通知的功能。

那這個中間類和原有的類有什么關系呢?繼續測試一下:

// 中間類的地址是0x6000001033c0,superclass的地址在此基礎上偏移8
(lldb) p (objc_class *)0x00006000001033c8
(objc_class *) $10 = 0x00006000001033c8
(lldb) p *$10
(objc_class) $11 = {
  isa = ZNObject
}

中間類的superclass就是原類,這么一想還是很合理的。中間類重寫了一些方法,對這些方法的調用會使用重寫之后的版本。而調用其他的方法,在中間類中查找不到就會到父類中查找,所以原類中的方法依然可以正常調用。

關于方法調用的順序,可以看這篇:Runtime源碼 —— 方法調用的過程

現在對中間類已經有一些了解了,再來看看文章開始的那個問題:set方法究竟如何觸發KVO的通知?

先回顧一下中間類的setName方法:

name = "setName:"
types = 0x00000001000029eb "v24@0:8@16"
imp = 0x00007fff980d4496 (Foundation`_NSSetObjectValueAndNotify)

實現是Foundation框架的_NSSetObjectValueAndNotify方法,沒法看到源碼,但是可以通過watchpoint看看調用棧:

添加watchpoint

繼續運行到命中:

watchpoint命中

對name的設置最終還是通過原類(ZNObject)的set方法來進行的,在_NSSetObjectValueAndNotify方法之后,調用了NSObject一個分類中的方法:

-[NSObject(NSKeyValueObservingPrivate) _changeValueForKeys:count:maybeOldValuesDict:usingBlock:]

看這個方法的時候,我發現這么一個方法:

調用NSKeyValueDidChange

這個方法后面會調用NSKeyValueDidChange這個方法,這個方法名字看起來有點眼熟,有點像NSObject提供的這個方法:

// Informs the observed object that the value of a given property has changed.
- (void)didChangeValueForKey:(NSString *)key;

那么NSKeyValueDidChange是不是起這個作用呢?進入這個方法看一看:

NSKeyValueDidChange內部

在這個方法內部又有新發現,有這么一個方法名字引起了我的注意:
NSKeyValueNotifyObserver,看起來就是用來通知觀察者的。

這個時候在這個方法中添加一個斷點,這個方法就是觀察者收到通知的地方,應該都很熟悉:

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

結果如下圖:

觀察者收到通知

果然,上面那個方法在NSKeyValueNotifyObserver方法內部被調用了。這就完成了整個通知的過程。

總結

稍微總結一下KVO方法的調用順序,就是這個樣子的:

  1. self.obj.name = @"haha";
  2. _NSSetObjectValueAndNotify
  3. -[NSObject(NSKeyValueObservingPrivate) _changeValueForKeys:count:maybeOldValuesDict:usingBlock:]
  4. - [ZNObject setName]
  5. NSKeyValueDidChange
  6. NSKeyValueNotifyObserver
  7. - observeValueForKeyPath:ofObject:change:context:

之前還有個疑問,調用class方法和object_getClass(id obj)方法為什么返回結果不同??梢苑抡丈厦娴牧鞒?,研究class方法的調用順序,結果是這樣的:

  1. [self.obj class]
  2. NSKVOClass
  3. object_getClass(id obj)
  4. _NSKVONotifyingOriginalClassForIsa
  5. _isKVOA
  6. NSKVOIsAutonotifying
  7. [ZNObject class]
  8. + [NSObject class]

最后還是會調用ZNObject的class方法,所以返回的結果是ZNObject也就不奇怪了。

如果直接使用object_getClass(id obj),并沒有那么復雜的邏輯,就只調用了這個方法,也就是獲取obj的isa,所以返回的是中間類。


最后再說一下這幾個方法的用法吧:

- (void)willChangeValueForKey:(NSString *)key;
- (void)didChangeValueForKey:(NSString *)key;

正常情況下是不需要使用的,因為前面也看到了,中間類的set方法會幫我們調用NSKeyValueDidChange來觸發通知。

如果不想自動觸發,可以使用上面這兩個方法來手動觸發,但是需要注意的是,如果僅僅是把set方法修改為這個樣子:

- (void)setName:(NSString *)name {
    [self willChangeValueForKey:@"name"];
    _name = name;
    [self didChangeValueForKey:@"name"];
}

這樣會觸發兩次通知,第一次是手動觸發,第二次是中間類自動觸發,這個時候需要關閉自動觸發機制,很簡單,只要配套使用這個方法就可以了:

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    if ([key isEqualToString:@"name"]) {
        return NO;
    }
    return YES;
}

將想要手動觸發的key返回NO就可以了,這個方法默認返回YES。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,825評論 6 546
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,814評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 178,980評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,064評論 1 319
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,779評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,109評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,099評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,287評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,799評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,515評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,750評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,221評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,933評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,327評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,667評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,492評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,703評論 2 380

推薦閱讀更多精彩內容

  • 轉至元數據結尾創建: 董瀟偉,最新修改于: 十二月 23, 2016 轉至元數據起始第一章:isa和Class一....
    40c0490e5268閱讀 1,757評論 0 9
  • 我們常常會聽說 Objective-C 是一門動態語言,那么這個「動態」表現在哪呢?我想最主要的表現就是 Obje...
    Ethan_Struggle閱讀 2,230評論 0 7
  • 《招聘一個靠譜的 iOS》—參考答案(下) 說明:面試題來源是微博@我就叫Sunny怎么了的這篇博文:《招聘一個靠...
    YuWenHaiBo閱讀 4,061評論 0 16
  • 《招聘一個靠譜的 iOS》—參考答案(下) 說明:面試題來源是微博@我就叫Sunny怎么了的這篇博文:《招聘一個靠...
    Mominglaile閱讀 1,237評論 0 1
  • 和朋友去一間口碑很好的糖水店喝糖水,晚上11點多了,店里仍然是熙熙攘攘、熱鬧得很。 朋友去點糖水,我準備去“霸“位...
    Fairo閱讀 416評論 0 0