iOS底層原理總結 - 探尋KVO本質

xx_cc發表於2018-04-21

對小碼哥底層班視訊學習的總結與記錄。面試題部分,通過對面試題的分析探索問題的本質內容。

問題

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

首先需要了解KVO基本使用,KVO的全稱 Key-Value Observing,俗稱“鍵值監聽”,可以用於監聽某個物件屬性值的改變。

- (void)viewDidLoad {
    [super viewDidLoad];
    Person *p1 = [[Person alloc] init];
    Person *p2 = [[Person alloc] init];
    p1.age = 1;
    p1.age = 2;
    p2.age = 2;
    // self 監聽 p1的 age屬性
    NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;

    [p1 addObserver:self forKeyPath:@"age" options:options context:nil];
    p1.age = 10;
    [p1 removeObserver:self forKeyPath:@"age"];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
    NSLog(@"監聽到%@的%@改變了%@", object, keyPath,change);
}

// 列印內容
監聽到<Person: 0x604000205460>的age改變了{
    kind = 1;
    new = 10;
    old = 2;
}
複製程式碼

上述程式碼中可以看出,在新增監聽之後,age屬性的值在發生改變時,就會通知到監聽者,執行監聽者的observeValueForKeyPath方法。

探尋KVO底層實現原理

通過上述程式碼我們發現,一旦age屬性的值發生改變時,就會通知到監聽者,並且我們知道賦值操作都是呼叫 set方法,我們可以來到Person類中重寫age的set方法,觀察是否是KVO在set方法內部做了一些操作來通知監聽者。

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

說明KVO在執行時獲取對p1物件做了一些改變。相當於在程式執行過程中,對p1物件做了一些變化,使得p1物件在呼叫setage方法的時候可能做了一些額外的操作,所以問題出在物件身上,兩個物件在記憶體中肯定不一樣,兩個物件可能本質上並不一樣。接下來來探索KVO內部是怎麼實現的。

KVO底層實現分析

首先我們對上述程式碼中新增監聽的地方打斷點,看觀察一下,addObserver方法對p1物件做了什麼處理?也就是說p1物件在經過addObserver方法之後發生了什麼改變,我們通過列印isa指標如下圖所示

addObserver對p1物件的處理

通過上圖我們發現,p1物件執行過addObserver操作之後,p1物件的isa指標由之前的指向類物件Person變為指向NSKVONotifyin_Person類物件,而p2物件沒有任何改變。也就是說一旦p1物件新增了KVO監聽以後,其isa指標就會發生變化,因此set方法的執行效果就不一樣了。

那麼我們先來觀察p2物件在內容中是如何儲存的,然後對比p2來觀察p1。 首先我們知道,p2在呼叫setage方法的時候,首先會通過p2物件中的isa指標找到Person類物件,然後在類物件中找到setage方法。然後找到方法對應的實現。如下圖所示

未使用KVO監聽的物件放大實現路徑

但是剛才我們發現p1物件的isa指標在經過KVO監聽之後已經指向了NSKVONotifyin_Person類物件,NSKVONotifyin_Person其實是Person的子類,那麼也就是說其superclass指標是指向Person類物件的,NSKVONotifyin_Person是runtime在執行時生成的。那麼p1物件在呼叫setage方法的時候,肯定會根據p1的isa找到NSKVONotifyin_Person,在NSKVONotifyin_Person中找setage的方法及實現。

經過查閱資料我們可以瞭解到。 NSKVONotifyin_Person中的setage方法中其實呼叫了 Fundation框架中C語言函式 _NSsetIntValueAndNotify,_NSsetIntValueAndNotify內部做的操作相當於,首先呼叫willChangeValueForKey 將要改變方法,之後呼叫父類的setage方法對成員變數賦值,最後呼叫didChangeValueForKey已經改變方法。didChangeValueForKey中會呼叫監聽器的監聽方法,最終來到監聽者的observeValueForKeyPath方法中。

那麼如何驗證KVO真的如上面所講的方式實現?

首先經過之前打斷點列印isa指標,我們已經驗證了,在執行新增監聽的方法時,會將isa指標指向一個通過runtime建立的Person的子類NSKVONotifyin_Person。 另外我們可以通過列印方法實現的地址來看一下p1和p2的setage的方法實現的地址在新增KVO前後有什麼變化。

// 通過methodForSelector找到方法實現的地址
NSLog(@"新增KVO監聽之前 - p1 = %p, p2 = %p", [p1 methodForSelector: @selector(setAge:)],[p2 methodForSelector: @selector(setAge:)]);
    
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:)]);
複製程式碼

setage的方法實現的地址在新增KVO前後的變化

我們發現在新增KVO監聽之前,p1和p2的setAge方法實現的地址相同,而經過KVO監聽之後,p1的setAge方法實現的地址發生了變化,我們通過列印方法實現來看一下前後的變化發現,確實如我們上面所講的一樣,p1的setAge方法的實現由Person類方法中的setAge方法轉換為了C語言的Foundation框架的_NSsetIntValueAndNotify函式。

Foundation框架中會根據屬性的型別,呼叫不同的方法。例如我們之前定義的int型別的age屬性,那麼我們看到Foundation框架中呼叫的_NSsetIntValueAndNotify函式。那麼我們把age的屬性型別變為double重新列印一遍

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

我們可以找到Foundation框架檔案,通過命令列查詢關鍵字找到相關函式

相關函式

NSKVONotifyin_Person內部結構是怎樣的?

首先我們知道,NSKVONotifyin_Person作為Person的子類,其superclass指標指向Person類,並且NSKVONotifyin_Person內部一定對setAge方法做了單獨的實現,那麼NSKVONotifyin_Person同Person類的差別可能就在於其記憶體儲的物件方法及實現不同。 我們通過runtime分別列印Person類物件和NSKVONotifyin_Person類物件記憶體儲的物件方法

- (void)viewDidLoad {
    [super viewDidLoad];

    Person *p1 = [[Person alloc] init];
    p1.age = 1.0;
    Person *p2 = [[Person alloc] init];
    p1.age = 2.0;
    // self 監聽 p1的 age屬性
    NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
    [p1 addObserver:self forKeyPath:@"age" options:options context:nil];

    [self printMethods: object_getClass(p2)];
    [self printMethods: object_getClass(p1)];

    [p1 removeObserver:self forKeyPath:@"age"];
}

- (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);
}
複製程式碼

上述列印內容如下

NSKVONotifyin_Person記憶體儲的物件方法

通過上述程式碼我們發現NSKVONotifyin_Person中有4個物件方法。分別為setAge: class dealloc _isKVOA,那麼至此我們可以畫出NSKVONotifyin_Person的記憶體結構以及方法呼叫順序。

NSKVONotifyin_Person的記憶體結構以及方法呼叫順序

這裡NSKVONotifyin_Person重寫class方法是為了隱藏NSKVONotifyin_Person。不被外界所看到。我們在p1新增過KVO監聽之後,分別列印p1和p2物件的class可以發現他們都返回Person。

NSLog(@"%@,%@",[p1 class],[p2 class]);
// 列印結果 Person,Person
複製程式碼

如果NSKVONotifyin_Person不重寫class方法,那麼當物件要呼叫class物件方法的時候就會一直向上找來到nsobject,而nsobect的class的實現大致為返回自己isa指向的類,返回p1的isa指向的類那麼列印出來的類就是NSKVONotifyin_Person,但是apple不希望將NSKVONotifyin_Person類暴露出來,並且不希望我們知道NSKVONotifyin_Person內部實現,所以在內部重寫了class類,直接返回Person類,所以外界在呼叫p1的class物件方法時,是Person類。這樣p1給外界的感覺p1還是Person類,並不知道NSKVONotifyin_Person子類的存在。

那麼我們可以猜測NSKVONotifyin_Person內重寫的class內部實現大致為

- (Class) class {
     // 得到類物件,在找到類物件父類
     return class_getSuperclass(object_getClass(self));
}
複製程式碼

驗證didChangeValueForKey:內部會呼叫observer的observeValueForKeyPath:ofObject:change:context:方法

我們在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方法內部已經呼叫了observer的observeValueForKeyPath:ofObject:change:context:方法。

didChangeValueForKey內執行順序

回答問題:

  1. iOS用什麼方式實現對一個物件的KVO?(KVO的本質是什麼?) 答. 當一個物件使用了KVO監聽,iOS系統會修改這個物件的isa指標,改為指向一個全新的通過Runtime動態建立的子類,子類擁有自己的set方法實現,set方法實現內部會順序呼叫willChangeValueForKey方法、原來的setter方法實現、didChangeValueForKey方法,而didChangeValueForKey方法內部又會呼叫監聽器的observeValueForKeyPath:ofObject:change:context:監聽方法。
  1. 如何手動觸發KVO 答. 被監聽的屬性的值被修改時,就會自動觸發KVO。如果想要手動觸發KVO,則需要我們自己呼叫willChangeValueForKey和didChangeValueForKey方法即可在不改變屬性值的情況下手動觸發KVO,並且這兩個方法缺一不可。

通過以下程式碼可以驗證

Person *p1 = [[Person alloc] init];
p1.age = 1.0;
   
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[p1 addObserver:self forKeyPath:@"age" options:options context:nil];
    
[p1 willChangeValueForKey:@"age"];
[p1 didChangeValueForKey:@"age"];
    
[p1 removeObserver:self forKeyPath:@"age"];
複製程式碼

列印內容

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


文中如果有不對的地方歡迎指出。我是xx_cc,一隻長大很久但還沒有二夠的傢伙。需要視訊一起探討學習的coder可以加我Q:2336684744

相關文章