[iOS]用程式碼探究 KVO 原理(真原創)

NewPan發表於2018-01-05

我在上一篇文章 [iOS]從使用 KVO 監聽 readonly 屬性說起 中談了使用 KVO 一些問題。裡面談到了 KVO 的原理,我在網上搜了一下 KVO 原理,發現大家說的都是一樣的。但是我在實際使用中發現事實並不是網上說的那樣。所以打算用程式碼的形式來一步一步探究 KVO 的原理。

[iOS]用程式碼探究 KVO 原理(真原創)

01.程式碼場景

說一下我們的程式碼場景:一個人類 Person,他的朋友把狗 LeLe 寄養在他那裡,然後他自己有一隻貓 Tom。因為他自己沒有狗,所以狗是 readonly 的。

Person 類的 .h 檔案是這樣的:

@interface Person : NSObject

@property(nonatomic, strong)NSString *aCat;

@property(nonatomic, strong, readonly)NSString *aDog;

// 寄養狗
-(void)careDog:(id)dog;

@end
複製程式碼

Person 類的 .m 檔案是這樣的:

#import "Person.h"

@implementation Person

-(void)careDog:(id)dog{
  [self setValue:dog forKey:@"aDog"];
}

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

@end
複製程式碼

控制器的程式碼是這樣的:

    #import "ViewController.h"
    #import "Person.h"

    @interface ViewController ()

    // 人
    @property(nonatomic, strong)Person *p;

    // 狗
    @property(nonatomic, strong)NSString *d;

    // 貓
    @property(nonatomic, strong)NSString *c;

    @end

    @implementation ViewController

    - (void)viewDidLoad {
        [super viewDidLoad];
    
        self.p = [Person new];
        self.d = @"LeLe";
        self.c = @"Tom";
    
        // 開始監聽:第一個斷點位置 ?
        [self.p addObserver:self forKeyPath:@"aDog" options:NSKeyValueObservingOptionNew context:nil];
        [self.p addObserver:self forKeyPath:@"aCat" options:NSKeyValueObservingOptionNew context:nil];
    }

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

    -(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
        // 寄養狗
        [self.p careDog:self.d];
    
        // 擁有貓
        self.p.aCat = self.c;
    }

    @end
複製程式碼

02.開始監聽

程式碼跑起來,停在第一個斷點還未新增 KVO 監聽的位置,使用 po 命令在控制檯列印如下:

(lldb) po self.p.class
Person
(lldb) po object_getClass(self.p)
Person
複製程式碼

兩個命令拿到的類是一樣樣的,都是 Person。

注意,程式碼往下過一行,為人的 aDog 屬性新增 KVO,再使用同樣的 po 命令列印一次:

(lldb) po self.p.class
Person

(lldb) po object_getClass(self.p)
NSKVONotifying_Person
複製程式碼

使用 self.p.class 去拿人 p 的類仍然是 Person,但是用 object_getClass 去拿的時候就露出小尾巴了。其實這個表現在官方文件裡已經說得很清楚了。我英語不行,我大概翻譯一下,你大概看一下。

Key-Value Observing Implementation Details KVO 實現細節(標題)

Automatic key-value observing is implemented using a technique called isa-swizzling. KVO 採用了一個叫做 isa-swizzling(類指標交換,isa 是指向每個例項物件的類的一個地址) 的技術來實現。

The isa pointer, as the name suggests, points to the object's class which maintains a dispatch table. This dispatch table essentially contains pointers to the methods the class implements, among other data. isa 指標,顧名思義,表明了物件屬於的類,類裡儲存了物件方法列表。這個方法列表的本質就是物件方法的地址的集合。

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. 當一個物件的屬性被監聽的時候,這個物件的 isa 指標將被修改,然後指向一個臨時的類而不是原來的類。事實上 isa 指標不會影響物件的真實型別(意思當我們用 .class 去取物件的類的時候,系統會處理這個 .class 方法,使返回的類仍然是原來的類,偽裝的真好)。

You should never rely on the isa pointer to determine class membership. Instead, you should use the class method to determine the class of an object instance. 你不需要關心 isa 指標而影響你的使用,你就當什麼都沒發生過就好了。

03.監聽值的改變

我們的 Person 類中有一個當屬性值改變的時候,系統自動呼叫通知觀察者的方法:

-(void)willChangeValueForKey:(NSString *)key{
    // 第二個斷點位置 ?
    [super willChangeValueForKey:key];
    NSLog(@"willChangeValueForKey");
}
複製程式碼

3.1、給 readwrite 的屬性 aCat 賦值

我們 Person 類的 aCat 屬性是 readwrite 的。我們先在點選螢幕的時候給貓賦值。

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
 // 寄養狗
 // [self.p careDog:self.d];
    
 // 擁有貓
    self.p.aCat = self.c;
}
複製程式碼

我們在斷點的地方檢視函式呼叫棧:

[iOS]用程式碼探究 KVO 原理(真原創)

可以看到,當點選螢幕後,系統呼叫了一個 _NSSetObjectValueAndNotify 的方法,這個方法會調起 willChangeValueForKey: 方法。

3.2、給 readonly 的 aDog 賦值

我們都知道 readonly 的屬性是沒有 setter 方法的。所以我們嘗試採用 KVC 的方式來給屬性賦值。 保持斷點不要動,這回我們這樣寫:

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    // 寄養狗
   [self.p careDog:self.d];
    
    // 擁有貓
    // self.p.aCat = self.c;
}
複製程式碼

寄養狗的方法會使用 KVC 給 aDog 屬性賦值。我們再來看一下函式呼叫棧:

[iOS]用程式碼探究 KVO 原理(真原創)

進入 careDog: 方法以後,系統先是呼叫 KVC 的賦值方法,然後再呼叫 _NSSetValueAndNotifyForKeyIvar,這個方法會調起 willChangeValueForKey: 方法。

04.監聽到值的改變

4.1、監聽到 readwrite 的 aCat 的值的改變

接下來我們斷點打在監聽結果方法裡:

-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
   // 第三個斷點位置 ?
   NSLog(@"監聽到了 %@ 的改變", keyPath);
}
複製程式碼

同時在這麼寫:

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    // 寄養狗
    // [self.p careDog:self.d];
    
    // 擁有貓
    self.p.aCat = self.c;
}
複製程式碼

程式碼跑起來,點選螢幕,程式停在斷點位置,我們來看函式呼叫棧:

[iOS]用程式碼探究 KVO 原理(真原創)

先是呼叫 _NSSetObjectValueAndNotify 方法,接下來這個方法會調起監聽者 NSKeyValueNotifyObserver,這個監聽者再調起 -observeValueForKeyPath: 方法。

4.2、監聽到 readonly 的 aDog 的值的改變

保持斷點不要動,現在這麼寫:

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    // 寄養狗
    [self.p careDog:self.d];
    
    // 擁有貓
    // self.p.aCat = self.c;
}
複製程式碼

程式碼跑起來,點選螢幕,程式停在斷點位置,我們來看函式呼叫棧:

[iOS]用程式碼探究 KVO 原理(真原創)

系統先是呼叫 KVC 的賦值方法,這個方法會觸發 NSKeyValueNotifyObserver,然後調起 -observeValueForKeyPath: 方法。

05.移除 KVO 監聽

我們在控制器中新增如下程式碼來移除所有 KVO 監聽:

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
   // 寄養狗
   // [self.p careDog:self.d];
    
   // 擁有貓
   // self.p.aCat = self.c;
    
   [self.p removeObserver:self forKeyPath:@"aDog"];
   [self.p removeObserver:self forKeyPath:@"aCat"];
   // 第四個斷點位置 ?
}
複製程式碼

然後使用 po 命令來檢視 p 的類:

(lldb) po self.p.class
Person

(lldb) po object_getClass(self.p)
Person
複製程式碼

發現在移除對 p 的所有屬性的監聽以後,系統會自動將物件的 isa 指標從那個以 NSKVONotifying_ 打頭的臨時的類又重新調轉到 p 的原來的類。

06.總結

  • 01.開始監聽。當一個物件的屬性被使用 KVO 監聽的時候,系統會自動生成一個以 NSKVONotifying_ 打頭的臨時的類,然後將這個物件的 isa 指標指向這個臨時的類;

  • 02.監聽過程

  • 2.1.當監聽的屬性是 readwrite 的時候,並不會往這個屬性的 setter 方法裡插入 -willChangeValueForKey: 和 -didChangeValueForKey: 等方法,而是系統會在設定屬性的值的時候呼叫 _NSSetObjectValueAndNotify 方法,這個方法會傳送一條通知,然後 NSKeyValueNotifyObserver 這個監聽者監聽到值的改變的時候,會傳送一個通知調起  -observeValueForKeyPath:。

  • 2.2.當監聽的屬性是 readonly 的時候,當使用 KVC 給屬性賦值的時候,系統先是呼叫 KVC 的賦值方法,這個方法會觸發 NSKeyValueNotifyObserver,然後調起 -observeValueForKeyPath: 方法。

  • 移除監聽。移除所有 KVO 監聽的時候,系統會自動將物件的 isa 指標從那個以 NSKVONotifying_ 打頭的臨時的類又重新調轉到 p 的原來的類。

注意:如果你沒有重寫 -willChangeValueForKey: 和 -didChangeValueForKey: 方法,這兩個方法就不會被調起。

NewPan 的文章集合

下面這個連結是我所有文章的一個集合目錄。這些文章凡是涉及實現的,每篇文章中都有 Github 地址,Github 上都有原始碼。

NewPan 的文章集合索引

如果你有問題,除了在文章最後留言,還可以在微博 @盼盼_HKbuy 上給我留言,以及訪問我的 Github

相關文章