KVO和KVC的本質

一、KVO

問題

  1. iOS用什么方式實現對一個對象的KVO?(KVO的本質是什么?)
  2. 如何手動觸發KVO?

1. KVO使用

KVO的全稱Key-Value Observing,俗稱“鍵值監聽”,可以用于監聽某個對象屬性值的改變。通過方法(void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context給對象添加監聽

Person *p1 = [[Person alloc] init];
Person *p2 = [[Person alloc] init];
p1.age = 2;
p2.age = 5;
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[p1 addObserver:self forKeyPath:@"age" options:options context:nil];
p1.age = 10;
p2.age = 20;

當對象某個屬性的值發生了改變之后會回調方法:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
    
}
監聽到<Person: 0x6000000b4300>對象的age屬性改變了,{
    kind = 1;
    new = 10;
    old = 2;
}

上述代碼中可以看出,在添加監聽之后,age屬性的值在發生改變時,就會通知到監聽者,執行監聽者的observeValueForKeyPath方法。

記得在合適的時機移除對對象的監聽(void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath

2. KVO實現原理

2.1 分析

通過上述代碼我們發現,一旦age屬性的值發生改變時,就會通知到監聽者(觀察者),并且我們知道賦值操作都是調用set方法,我們可以來到Persn類中重寫ageset方法,觀察是否是KVOset方法內部做了一些操作來通知監聽者。

我們發現即使重寫了set方法,p1對象和p2對象調用同樣的set方法,但是我們發現p1除了調用set方法之外還會另外執行監聽器的observeValueForKeyPath方法。

說明KVO在運行時會對p1對象做了一些改變。相當于在程序運行過程中,對p1對象做了一些變化,使得p1對象在調用setage方法的時候可能做了一些額外的操作,所以問題出在對象本身。

2.2 本質

首先我們對上述代碼中添加監聽的地方打斷點,看觀察一下,addObserver方法對p1對象做了什么處理?也就是說p1對象在經過addObserver方法之后發生了什么改變,我們通過打印isa指針如下所示

// 添加監聽之前的isa
(lldb) po p1->isa
Person

(lldb) po p2->isa
Person

// 添加監聽之后的isa
(lldb) po p1->isa
NSKVONotifying_Person

(lldb) po p2->isa
Person

我們發現,p1對象執行過addObserver: forKeyPath:操作之后,p1實例對象的isa指針由之前的指向類對象Person變為指向NSKVONotifyin_Person類對象,而p2對象沒有任何改變。也就是說一旦p1對象添加了KVO監聽以后,其isa指針就會發生變化,因此類對象里面的的set方法的執行效果就不一樣了。

那么我們先來觀察p2對象在內容中是如何存儲的,然后對比p2來觀察p1。

p2在調用setage方法的時候,首先會通過p2實例對象中的isa指針找到Person類對象,然后在類對象中找到setage方法。然后找到方法對應的實現。如下圖所示

kvo_set_before

2.3 添加KVO之后的實例對象isa指向

p1實例對象的isa指針在經過KVO監聽之后已經指向了NSKVONotifyin_Person類對象。

NSKVONotifyin_Person其實是Person的子類,那么也就是說其super_class指針是指向Person類對象的,NSKVONotifyin_Person類是runtime在運行時生成的。

那么p1實例對象在調用setage實例方法的時候,會根據p1isa找到NSKVONotifyin_Person類對象,在NSKVONotifyin_Person中找setage的方法及實現。

2.4 NSKVONotifyin_xxx內部做的操作

NSKVONotifyin_Person中的setage方法中其實調用了Fundation框架中C語言函數 _NSsetIntValueAndNotify。

_NSsetIntValueAndNotify函數內部做的操作相當于:

  1. 調用willChangeValueForKey 將要改變方法,類似于[self willChangeValueForKey:]
  2. 調用父類的setage方法對成員變量賦值,類似于[super setage:]
  3. 最后調用didChangeValueForKey已經改變方法,類似于[self didChangeValueForKey:]

didChangeValueForKey中會調用監聽者的監聽方法,最終來到監聽者的observeValueForKeyPath方法中。

3. 驗證KVO的內部實現

我們已經驗證了,在執行添加監聽的方法時,會將isa指針指向一個通過runtime創建的Person的子類NSKVONotifyin_Person的類對象。

另外我們可以通過打印方法實現的地址來看一下p1p2setage:的方法實現的地址在添加KVO前后有什么變化。

// 通過methodForSelector找到方法實現的地址
NSLog(@"添加KVO監聽之前 p1 = %p, p2 = %p", [p1 methodForSelector: @selector(setAge:)],[p2 methodForSelector: @selector(setAge:)]);
    
// self 監聽 p1的 age屬性
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[p1 addObserver:self forKeyPath:@"age" options:options context:nil];
    
NSLog(@"添加KVO監聽之后 p1 = %p, p2 = %p", [p1 methodForSelector:@selector(setAge:)],[p2 methodForSelector: @selector(setAge:)]);

進入lldb

2020-01-09 16:27:09.786420+0800 KVO的本質[98033:1408550] 添加KVO監聽之前 p1 = 0x109df9040, p2 = 0x109df9040
2020-01-09 16:27:09.786912+0800 KVO的本質[98033:1408550] 添加KVO監聽之后 p1 = 0x7fff2564d626, p2 = 0x109df9040
(lldb) p (IMP)0x109df9040
(IMP) $0 = 0x0000000109df9040 (KVO的本質`-[Person setAge:] at Person.h:15)
(lldb) p (IMP)0x7fff2564d626
(IMP) $1 = 0x00007fff2564d626 (Foundation`_NSSetIntValueAndNotify)
(lldb) 

我們發現在添加KVO監聽之前,p1p2setAge:方法實現的地址相同,而經過KVO之后,p1setAge:方法實現的地址發生了變化。

我們通過打印方法實現來看一下前后的變化發現,確實如我們上面所講的一樣,p1setAge:方法的實現由Person類對象中的setAge:方法轉換為了C語言Foundation框架的_NSsetIntValueAndNotify函數。

Foundation框架中會根據屬性的類型,調用不同的方法。例如我們之前定義的int類型的age屬性,那么我們看到Foundation框架中調用的_NSsetIntValueAndNotify函數。那么我們把age的屬性類型變為double重新打印一遍:

2020-01-09 16:30:30.633548+0800 KVO的本質[98184:1412183] 添加KVO監聽之前 p1 = 0x10567c010, p2 = 0x10567c010
2020-01-09 16:30:30.633898+0800 KVO的本質[98184:1412183] 添加KVO監聽之后 p1 = 0x7fff2564d3a8, p2 = 0x10567c010
(lldb) p (IMP)0x10567c010
(IMP) $0 = 0x000000010567c010 (KVO的本質`-[Person setAge:] at Person.h:15)
(lldb) p (IMP)0x7fff2564d3a8
(IMP) $1 = 0x00007fff2564d3a8 (Foundation`_NSSetDoubleValueAndNotify)
(lldb) 

我們發現調用的函數變為了_NSSetDoubleValueAndNotify,那么這說明Foundation框架中有許多此類型的函數,通過屬性的不同類型調用不同的函數。
那么我們可以推測Foundation框架中還有很多例如_NSSetBoolValueAndNotify、_NSSetCharValueAndNotify、_NSSetFloatValueAndNotify、_NSSetLongValueAndNotify等等函數。

我們可以找到Foundation框架文件,通過命令行查詢關鍵字找到相關函數

foundation_kvo

4. Foundation框架的_NSSet*ValueAndNotify函數的內部邏輯

我們在Person類中重寫willChangeValueForKey:didChangeValueForKey:方法,模擬他們的實現。

- (void)setAge:(int)age
{
    NSLog(@"setAge:");
    _age = age;
}
- (void)willChangeValueForKey:(NSString *)key
{
    NSLog(@"willChangeValueForKey: - begin");
    [super willChangeValueForKey:key];
    NSLog(@"willChangeValueForKey: - end");
}
- (void)didChangeValueForKey:(NSString *)key
{
    NSLog(@"didChangeValueForKey: - begin");
    [super didChangeValueForKey:key];
    NSLog(@"didChangeValueForKey: - end");
}

再次運行來查看didChangeValueForKey:的方法內運行過程,通過打印的順序可以看到,確實在didChangeValueForKey:方法內部已經調用了observerobserveValueForKeyPath:ofObject:change:context:方法。

2020-01-09 17:07:17.044803+0800 KVO的本質[890:1450834] willChangeValueForKey: - begin
2020-01-09 17:07:17.044941+0800 KVO的本質[890:1450834] willChangeValueForKey: - end
2020-01-09 17:07:17.045039+0800 KVO的本質[890:1450834] 調用了setAge
2020-01-09 17:07:17.045133+0800 KVO的本質[890:1450834] didChangeValueForKey: - begin
2020-01-09 17:07:17.045323+0800 KVO的本質[890:1450834] 監聽到<Person: 0x6000002f0570>對象的age屬性改變了,{
    kind = 1;
    new = 249421248;
    old = 249421248;
}
2020-01-09 17:07:17.045423+0800 KVO的本質[890:1450834] didChangeValueForKey: - end

5. NSKVONotifyin_Person類對象存儲的實例方法

NSKVONotifyin_Person作為Person的子類,其super_class指針指向Person類對象,并且NSKVONotifyin_Person內部一定對setAge:方法做了單獨的實現,那么NSKVONotifyin_PersonPerson類的差別可能就在于其存儲的實例方法及方法實現不同。

我們通過runtime分別打印Person類對象和NSKVONotifyin_Person類對象內存儲的實例方法

// 通過runtime打印類對象的方法名
- (void)printMethods: (Class)cls {
    unsigned int count;
    Method *methods = class_copyMethodList(cls, &count);
    
    NSMutableString *methodNames = [NSMutableString string];
    [methodNames appendFormat:@"%@類的方法 - ", cls];
    
    for (int i = 0 ; i < count; i++) {
        Method method = methods[i];
        NSString *methodName  = NSStringFromSelector(method_getName(method));
        
        [methodNames appendString: methodName];
        [methodNames appendString:@","];
    }
    
    NSLog(@"%@",methodNames);
    free(methods);
}

// 通過methodForSelector找到方法實現的地址
NSLog(@"添加KVO監聽之前 - p1 = %p, p2 = %p", [p1 methodForSelector: @selector(setAge:)],[p2 methodForSelector: @selector(setAge:)]);
    
// self 監聽 p1的 age屬性
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[p1 addObserver:self forKeyPath:@"age" options:options context:nil];
    
NSLog(@"添加KVO監聽之后 - p1 = %p, p2 = %p", [p1 methodForSelector:@selector(setAge:)],[p2 methodForSelector: @selector(setAge:)]);
    
[self printMethods: object_getClass(p2)];
[self printMethods: object_getClass(p1)];

結果

2020-01-09 16:48:16.718818+0800 KVO的本質[99651:1433359] Person類的方法 - age,setAge:,
2020-01-09 16:48:16.718958+0800 KVO的本質[99651:1433359] NSKVONotifying_Person類的方法 - setAge:,class,dealloc,_isKVOA,

通過上述代碼我們發現NSKVONotifyin_Person中有4個對象方法。分別為setAge:classdealloc_isKVOA,那么至此我們可以畫出NSKVONotifyin_Person的內存結構以及方法調用順序。

nskvonotifyin

這里NSKVONotifyin_Person重寫class方法是為了隱藏NSKVONotifyin_Person不被外界所看到。我們在p1添加過KVO監聽之后,分別打印p1p2對象的class方法可以發現他們都返回Person

NSLog(@"%@,%@",[p1 class],[p2 class]);
// Person,Person

如果NSKVONotifyin_Person不重寫class方法,那么當對象要調用class對象方法的時候就會一直向上找來到NSObject,而NSObjectclass的實現大致為返回自己真實isa指向的類,就是p1isa指向的類那么打印出來的類就是NSKVONotifyin_Person.

但是官方不希望將NSKVONotifyin_Person類暴露出來,并且不希望我們知道NSKVONotifyin_Person內部實現,所以在內部重寫了class類,直接返回Person類,所以外界在調用p1class對象方法時是Person類。這樣p1給外界的感還是Person類,并不知道NSKVONotifyin_Person子類的存在。

那么我們可以猜測NSKVONotifyin_Person內重寫的class方法內部實現大致為:

- (Class) class {
     // 得到自己的類對象,再找到類對象父類
     return class_getSuperclass(object_getClass(self));
}

6. 手動觸發KVO

通過手動觸發對象的didChangeValueForKey:方法可以觸發KVO:

NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[p1 addObserver:self forKeyPath:@"age" options:options context:nil];
    
[p1 willChangeValueForKey:@"age"];
[p1 didChangeValueForKey:@"age"];
    
[p1 removeObserver:self forKeyPath:@"age"];

打印結果:

監聽到<Person: 0x600003053790>對象的age屬性改變了,{
    kind = 1;
    new = 2;
    old = 2;
}

通過打印我們可以發現,didChangeValueForKey方法內部成功調用了observeValueForKeyPath:ofObject:change:context:,并且age的值并沒有發生改變。

7. 問題:

iOS用什么方式實現對一個對象的KVO?(KVO的本質是什么?)

當一個對象使用了KVO監聽,iOS系統會修改這個對象的isa指針,改為指向一個全新的通過Runtime動態創建的子類,子類擁有自己的set方法實現,set方法實現內部會調用Foundation_NSSet*ValueAndNotify方法。

_NSSet*ValueAndNotify方法內部順序調用willChangeValueForKey方法、原來父類的setter方法實現、didChangeValueForKey方法,而didChangeValueForKey方法內部又會調用監聽器的observeValueForKeyPath:ofObject:change:context:監聽方法。

如何手動觸發KVO

被監聽的屬性的值被修改時,就會自動觸發KVO。如果想要手動觸發KVO,則需要我們自己調用willChangeValueForKeydidChangeValueForKey方法即可在不改變屬性值的情況下手動觸發KVO,并且這兩個方法缺一不可。

直接修改成員變量的值會觸發KVO嗎?

KVO是重寫了set方法,直接修改成員變量不會觸發set方法,所以不會觸發KVO

person->_age = 2;

二、KVC

問題

  1. 通過KVC修改屬性會觸發KVO嗎?
  2. KVC的賦值和取值過程是怎么樣的?原理是什么?

1. 使用

KVC的全稱是Key-Value Coding,俗稱“鍵值編碼”,可以通過一個key來訪問某個屬性。

常見的API有:

- (void)setValue:(id)value forKeyPath:(NSString *)keyPath;
- (void)setValue:(id)value forKey:(NSString *)key;
- (id)valueForKeyPath:(NSString *)keyPath;
- (id)valueForKey:(NSString *)key; 
Person *person = [[Person alloc]init];
        
[person setValue:@10 forKey:@"age"];
      
// person對象的cat屬性的weight屬性
person.cat = [[Cat alloc] init];
[person setValue:@20 forKeyPath:@"cat.weight"];
        
NSLog(@"%@",[person valueForKey:@"age"]);
NSLog(@"%@",[person valueForKeyPath:@"cat.weight"]);

2. setValue:forKey:設值的原理

2.1 KVC會觸發KVO - 通過訪問屬性

Observer *observer = [[Observer alloc]init];
Person *person = [[Person alloc]init];
            
[person addObserver:observer forKeyPath:@"age" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:nil];
            
// 通過KVC修改age屬性的值
[person setValue:@10 forKey:@"age"];
            
[person removeObserver:observer forKeyPath:@"age"];

我們發現是觸發了KVO的:

2020-04-10 21:34:18.284166+0800 KVC的本質[29149:8625107] observeValueForKeyPath - {
    kind = 1;
    new = 10;
    old = 0;
}

需要注意的是這里Person類是有屬性age``的`,所以系統會自動生成set```方法。

2.2 原理

通過KVC的方式賦值的時候其實通過訪問屬性的set方法和訪問成員變量來達到設值的。

  1. 首先會調用屬性的set方法來賦值:
- (void)setAge:(int)age {
    NSLog(@"setAge: %d", _age);
}
  1. 如果沒有找到屬性的set方法,那么會來到_setAge方法來賦值:
- (void)_setAge:(int)age {
    NSLog(@"_setAge: %d", _age);
}
  1. 如果沒有找到_setAge方法,那么會來到accessInstanceVariablesDirectly方法,來詢問是否可以訪問成員變量來賦值:
+ (BOOL)accessInstanceVariablesDirectly {
    return YES;
}

如果返回NO,那么會拋出異常,如果返回YES,則表示可以通過訪問成員變量來賦值。

  1. 訪問成員變量會按照_age、_isAgeage、isAge的順序來逐個訪問賦值,如果這4個成員變量都沒有找到,就拋出異常。
@interface Person : NSObject
{
    @public
    int _age;
    int _isAge;
    int age;
    int isAge;
}

2.3 KVC會觸發KVO - 通過訪問成員變量

現在我們刪除Person的屬性,添加成員變量_age

@interface Person : NSObject
{
    @public
    int _age;
}

我們發現通過KVC改變屬性照樣可以觸發KVO

KVC的本質[30832:8656264] observeValueForKeyPath - {
    kind = 1;
    new = 10;
    old = 0;
}

我們來重寫PersonwillChangeValueForKeydidChangeValueForKey方法:

- (void)willChangeValueForKey:(NSString *)key {
    [super willChangeValueForKey:key];
    NSLog(@"willChangeValueForKey");
}


- (void)didChangeValueForKey:(NSString *)key {
    [super didChangeValueForKey:key];
    NSLog(@"didChangeValueForKey");
}
KVC的本質[30911:8657993] willChangeValueForKey
2020-04-10 22:14:44.426343+0800 KVC的本質[30911:8657993] observeValueForKeyPath - {
    kind = 1;
    new = 10;
    old = 0;
}
2020-04-10 22:14:44.426514+0800 KVC的本質[30911:8657993] didChangeValueForKey

我們發現KVC通過訪問成員變量來賦值的時候,其內部會主動觸發KVO。但是我們自己訪問成員變量不會觸發KVO。原因就是通過KVC訪問成員變量的時候,系統會主動觸發KVO,相當于:

[person willChangeValueForKey:@"age"];
person->_age = 10;
[person didChangeValueForKey:@"age"];

3. valueForKey:取值的原理

和賦值一樣的,也是按順序訪問方法和成員變量獲得值:

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