KVC、KVO的本質

這篇文章介紹KVC、KVO的本質。如果你對KVC、KVO不瞭解,推薦先查看其用法:KVC和KVO學習筆記

1. KVO的本質

KVO 是 Key Value Observing 的縮寫,稱爲健值觀察。用於監聽對象屬性值的改變。

1.1 KVO的實現

觀察者模式使用示例如下:

@interface ViewController ()
@property (nonatomic, strong) Child *child1;
@property (nonatomic, strong) Child *child2;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.child1 = [[Child alloc] init];
    self.child1.age = 1;
    
    self.child2 = [[Child alloc] init];
    self.child2.age = 2;
    
    // 添加觀察者
    [self.child1 addObserver:self
                  forKeyPath:@"age"
                     options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
                     context:@"123context"];
}

// 觀察到鍵值發生改變
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    NSLog(@"監聽到 %@ 的 %@ 屬性值發生改變 - %@ - %@", object, keyPath, change, context);
}

- (void)dealloc {
    // 移除觀察者
    [self.child1 removeObserver:self
                     forKeyPath:@"age"];
}
@end

1.2 runtime動態創建NSKVONotifying_XXX類

通過上述代碼可以看到,age屬性發生改變後,會通知監聽者,即觸發observeValueForKeyPath:ofObject:change:context:方法。我們知道賦值操作是通過調用set方法實現,進入Child類,重寫setAge:方法,查看KVO是否通過修改set方法實現。

@implementation Child
- (void)setAge:(int)age {
    _age = age;
    
    NSLog(@"KVO是否通過重寫setAge:方法實現?age:%d", age);
}
@end

測試後發現,修改child1child2都會觸發setAge:方法,但child1會額外觸發KVO。說明KVO在運行時對child1進行了修改,使得child1在調用setAge:時,進行了額外的操作。

根據 runtime 的原理,向實例對象發送消息時,先根據實例對象的 isa 查找到類對象,在類對象的方法列表中查找方法實現。因此,可以查看child1child2是否指向同一個類對象:

    // 打印添加觀察者前實例對象的isa
    NSLog(@"添加Observer前 child1: %@ - child2: %@", object_getClass(self.child1), object_getClass(self.child2));
    
    // 添加觀察者
    [self.child1 addObserver:self
                  forKeyPath:@"age"
                     options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
                     context:@"123context"];
    
    // 打印添加觀察者後實例對象的isa
    NSLog(@"添加Observer後 child1: %@ - child2: %@", object_getClass(self.child1), object_getClass(self.child2));

打印結果如下:

添加Observer前 child1: Child - child2: Child
添加Observer後 child1: NSKVONotifying_Child - child2: Child

如果你對 runtime、isa不瞭解,可以查看我的另一篇文章:Runtime從入門到進階一

child1添加觀察者後,isa指針由之前的指向Child,變爲了NSKVONotifying_Child,而child2實例對象 isa 的指向沒有發生改變。

添加觀察者之前,child1實例在內存中的結構如下:

添加觀察者後,child1對象的isa指針指向了NSKVONotifying_Child類。NSKVONotifying_Childsuperclass指針指向Child類。

NSKVONotifying_ChildsetAge:方法調用了_NSSetIntValueAndNotify函數,該函數依次執行以下三步:

  1. 先調用willChangeValueForKey:
  2. 後調用[super setAge:]
  3. 最後調用didChangeValueForKey:觸發監聽

下面打印添加觀察者前後setAge:方法實現的變化:

    NSLog(@"添加Observer前 child1: %p - child2: %p", [self.child1 methodForSelector:@selector(setAge:)], [self.child2 methodForSelector:@selector(setAge:)]);
    
    // 添加觀察者
    ...
    
    NSLog(@"添加Observer後 child1: %p - child2: %p", [self.child1 methodForSelector:@selector(setAge:)], [self.child2 methodForSelector:@selector(setAge:)]);

控制檯輸出如下:

添加Observer前 child1: 0x10b247190 - child2: 0x10b247190
添加Observer後 child1: 0x7fff207bb2b7 - child2: 0x10b247190

可以看到,添加觀察者後,child1setAge:地址發生了變化,child2setAge:地址沒有發生變化。繼續打印其具體實現:

(lldb) p (IMP)0x10b247190
(IMP) $0 = 0x000000010b247190 (KVC&KVO的本質`-[Child setAge:] at Child.m:12)
(lldb) p (IMP)0x7fff207bb2b7
(IMP) $1 = 0x00007fff207bb2b7 (Foundation`_NSSetIntValueAndNotify)

可以看到,添加觀察者後,child1setAge:方法實現轉換成了C語言Foundation框架中的_NSSetIntValueAndNotify函數。如果setAge:參數類型是double,其會自動調用_NSSetDoubleValueAndNotify函數,也就是會根據類型自動轉換。

1.3 NSKVONotifying_Child具體實現

NSKVONotifying_Child繼承自ChildNSKVONotifying_Child內部重寫了setAge:方法,通過 runtime 的class_copyMethodList()函數可以查看對象的方法列表:

如下所示:

- (void)printMethodList:(Class)cls {
    unsigned int methodCount;
    // 獲取方法數組
    Method *methodList = class_copyMethodList(cls, &methodCount);
    
    NSMutableString *name = [NSMutableString string];
    for (int i=0; i<methodCount; ++i) {
        // 獲得方法
        Method method = methodList[I];
        // 獲得方法名
        NSString *methodName = NSStringFromSelector(method_getName(method));
        
        [name appendString:methodName];
        [name appendString:@", "];
    }
    
    // C語言中使用copy、create創建的對象需釋放。
    free(methodList);
    NSLog(@"%@ %@", cls, name);
}

添加觀察者後,分別打印child1child2

NSKVONotifying_Child setAge:, class, dealloc, _isKVOA,
Child setAge:, age,

可以看到,NSKVONotifying_Child對象有四個方法,分別爲

  • setAge::觸發觀察者的具體邏輯。
  • class:重寫class方法,直接返回父類名稱。這樣可以屏蔽KVO內部實現,隱藏NSKVONOtifying_Child類的存在。
  • dealloc:釋放時進行收尾工作。
  • _isKVOA:當前類是否是添加KVO後系統使用runtime創建的。

添加觀察者之後,child1實例在內存中的結構如下:

另外,還可以手動創建名稱爲NSKVONotifying_Child、繼承自Child的類。添加後,註冊觀察者時會報以下錯誤:

[general] KVO failed to allocate class pair for name NSKVONotifying_Child, automatic key-value observing will not work for this class

這也從側面證明了runtime會動態創建NSKVONOtifying_Child類。

1.4 驗證didChangeValueForKey:內部會調用observeValueForKeyPath:ofObject:change:context:方法

Child類中重寫willChangeValueForKey:didChangeValueForKey:,查看哪一步調用observeValueForKeyPath:ofObject:change:context:方法:

- (void)setAge:(int)age {
    NSLog(@"begin - setAge:%d", age);
    _age = age;
    NSLog(@"end - setAge:%d", age);
}

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

// 驗證didChangeValueForKey:調用了KVO
- (void)didChangeValueForKey:(NSString *)key {
    NSLog(@"didChangeValueForKey: - begin");
    [super didChangeValueForKey:key];
    NSLog(@"didChangeValueForKey: - end");
}

控制檯輸出如下:

begin - setAge:11
end - setAge:11
didChangeValueForKey: - begin
監聽到 <Child: 0x600000168250> 的 age 屬性值發生改變 - {
    kind = 1;
    new = 11;
    old = 1;
} - 123context
didChangeValueForKey: - end

可以看到,didChangeValueForKey:方法內部調用了observer的observeValueForKeyPath:ofObject:change:context:方法。

如果沒有實現willChangeValueForKey:,只調用didChangeValueForKey:,其並不會調用observer的observeValueForKeyPath:ofObject:change:context:方法。

1.5 KVO 面試題

1.5.1 iOS 使用什麼方式實現對一個對象的kvo?即KVO的本質是什麼。

KVO是利用runtime API 動態生成一個子類,並且讓 instance 對象的isa指向這個全新的子類,該子類的superclass指針指向原來的類。

當修改instance對象的屬性時,會調用Foundation的_NSSetXXValueAndNotify函數。其內部會依次執行以下方法:

  1. 調用willChangeValueForKey:
  2. 調用父類的 setter。
  3. 調用didChangeValueForKey:,該方法內部會觸發監聽器的observeValueForKeyPath:ofObject:change:context:
1.5.2 如何手動觸發KVO?

手動調用willChangeValueForKey:didChangeValueForKey:

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

由於 runtime 是通過創建子類,重寫setter方法實現的監聽值改變,直接修改成員變量並不會調用setter方法。因此,直接修改成員變量不會觸發KVO。

2. KVC的本質

KVC 是 Key Value Coding 的縮寫,稱爲健值編碼。

2.1 設值原理setValue:forKey:

KVC設值方法有以下兩個API:

- (void)setValue:(id)value forKeyPath:(NSString *)keyPath;
- (void)setValue:(id)value forKey:(NSString *)key;

setValue:forKey:原理如下圖所示:

setValue:forKey:原理如下:

  • 先查找setKey:_setKey:方法,如果找到了,直接傳遞參數,調用方法;如果找不到,進入下一步。
  • 查看accessInstanceVariableDirectly方法返回值,如果返回NO,即不允許訪問成員變量,則調用setValue:forUndefinedKey:方法,並拋出NSUnknownKeyException異常;如果返回YES,進入下一步。
  • 按照_key_isKeykeyisKey順序查找成員變量,如果找到了成員變量,直接賦值。如果找不到,則調用setValue:forUndefinedKey:方法,並拋出NSUnknownKeyException異常。
2.1.1 有set方法

有set方法時,直接調用set方法:

@interface Person : NSObject
@property (nonatomic, assign) int age;
@end

@implementation Person
- (void)setAge:(int)age {
    _age = age;
    NSLog(@"%s %d", __PRETTY_FUNCTION__, age);
}
@end

執行後,控制檯輸出如下:

-[Person setAge:] 10
-[Observer observeValueForKeyPath:ofObject:change:context:] - {
    kind = 1;
    new = 10;
    old = 0;
}

可以看到,KVC先調用了set方法,然後觸發了KVO。

如果將屬性設置爲readonly,在其他類中將不能通過訪問器方法修改屬性值,但可以使用KVC修改。這是因爲設置爲readonly後,雖然不會生成set方法,但會生成_key成員變量。此時,KVC直接爲_key賦值,具體原理可以查看後面部分內容。

2.1.2 沒有set方法

沒有set方法時,會先查看accessInstanceVariableDirectly方法返回值,如果返回NO,則調用setValue:forUndefinedKey:方法,並拋出NSUnknownKeyException異常;如果返回YES,則按照_key_isKeykeyisKey順序查找成員變量。如果找到了成員變量,直接賦值;如果找不到,則調用setValue:forUndefinedKey:方法,並拋出NSUnknownKeyException異常。

添加以下成員變量,並移除屬性:

{
    @public
    // 如果沒有set方法,按照下面順序查找。
    int _age;
    int _isAge;
    int age;
    int isAge;
}

使用setValue:forKey:設置成員變量值後,會按順序查找上述成員變量,找到後直接賦值。其順序是固定的,與聲明成員變量的先後次序無關。

2.2 KVC 內部實現willChangeValueForKey: 和didChangeValueForKey:方法

Person類添加以下代碼:

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

- (void)didChangeValueForKey:(NSString *)key {
    NSLog(@"%s - begin - %@", __PRETTY_FUNCTION__, key);
    
    [super didChangeValueForKey:key];
    
    NSLog(@"%s - end - %@", __PRETTY_FUNCTION__, key);
}

再次爲age設值,控制檯輸出如下:

-[Person willChangeValueForKey:] age
-[Person didChangeValueForKey:] - begin - age
- {
    kind = 1;
    new = 10;
    old = 0;
}
-[Person didChangeValueForKey:] - end - age

與KVO類似,其內部也實現了willChangeValueForKey:didChangeValueForKey:方法,並且是在didChangeValueForKey:後觸發觀察者的observeValueForKeyPath:ofObject:change:context:方法。

2.3 取值原理valueForKey:

KVC取值方法有以下兩個API:

- (id)valueForKeyPath:(NSString *)keyPath;
- (id)valueForKey:(NSString *)key;

valueForKey:原理如下圖所示:

valueForKey:原理如下:

  • 按照getKey:keyisKey_key順序查找方法,找到後直接調用方法;如果找不到,則進入下一步。
  • 查看accessInstanceVariableDirectly方法返回值,如果返回NO,則調用setValue:forUndefinedKey:方法,並拋出NSUnknownKeyException異常;如果返回YES,則進入下一步。
  • 按照_key_isKeykeyisKey順序查找成員變量。如果找到了,直接取值;如果找不到,則調用setValue:forUndefinedKey:方法,並拋出NSUnknownKeyException異常。

Person類中,添加以下方法:

- (int)getAge {
    return 22;
}

使用以下方法取值:

NSLog(@"age: %@", [self.person valueForKey:@"age"]);

可以看到其取出的是getAge的值。你可以自行添加ageisAge_age_isAge方法,驗證其取值順序。

2.4 KVC面試題

2.4.1 通過KVC修改屬性會觸發KVO嗎?

會觸發。其內部調用了willChangeValueForKey:didChangeValueForKey:,並在調用didChangeValueForKey:方法後觸發觀察者的observeValueForKeyPath:ofObject:change:context:方法。

2.4.2 KVC取值、賦值過程是怎麼樣的?原理是什麼?

取值、賦值過程和原理即爲上面兩個圖片內容。

Demo名稱:KVC&KVO的本質
源碼地址:https://github.com/pro648/BasicDemos-iOS/tree/master/KVC&KVO的本質

歡迎更多指正:https://github.com/pro648/tips

本文地址:https://github.com/pro648/tips/blob/master/sources/KVC、KVO的本質.md

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章