KVO 原理詳解
KVO(Key-Value Observing)是 iOS 開發中常用的一種用於監聽某個物件屬性值變化的技術。下文將以一段示例程式碼來分析 KVO 的底層原理。 ofollow,noindex">原始碼地址
示例原始碼
- (void)viewDidLoad { [super viewDidLoad]; [self setupSubviews]; BAOPerson *p1 = [[BAOPerson alloc] init]; BAOPerson *p2 = [[BAOPerson 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)setupSubviews { [self setupHeaderView]; } - (void)setupHeaderView { self.headerView.title = @"KVO"; } - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context { NSLog(@"監聽到 %@ 的 %@ 改變了 %@", object, keyPath,change); }
示例對 p1
進行了 KVO 監聽,當 p1
發生改變,即呼叫 observeValueForKeyPath
方法,從而列印以下資訊。
監聽到 <BAOPerson: 0x600003750200> 的 age 改變了 { kind = 1; new = 10; old = 2; }
KVO 實現原理
通過上述程式碼可以發現,一旦 age
屬性的值發生改變,就會通知到監聽者。我們知道賦值操作都是呼叫 set
方法,我們可以重寫 BAOPerson
類中 age
的 set
方法,觀察 KVO 是否是在 set
方法內部做了一些操作來通知監聽者。
- (void)setAge:(NSInteger)age { NSLog(@"override setAge"); _age = age; }
我們發現即使重寫了 set
方法, p1
除了呼叫 set
方法之外還會執行監聽者的 observeValueForKeyPath
方法。
根據上述實驗推測:KVO 在執行時對 p1
物件進行了改動,使 p1
物件在呼叫 setAge
方法時做了一些額外的操作。所以問題出在物件身上,兩個物件可能本質上並不一樣。下面我們來探索一下 KVO 內部是如何實現的。
KVO 實現分析
首先分別在新增 KVO 前後打上斷點,以觀察新增 KVO 前後 p1
物件有何不同。
通過列印物件的 isa
指標,我們發現, p1
物件的 isa
指標由之前的指向類物件 BAOPerson
變成了指向類物件 NSKVONotifying_BAOPerson
。相應地, p2
物件沒有改變。因此我們可以推測, p1
物件的 isa
發生改變後,其執行的 setAge
也發生了改變。
我們知道, p2
在呼叫 setAge
方法時,首先會通過 p2
物件的 isa
指標找到 BAOPerson
類物件,然後在類物件中找到 setAge
方法,最終找到方法對應的實現。如下圖所示:
但是, p1
物件的 isa
在新增 KVO 之後已經指向了 NSKVONotifying_BAOPerson
類物件, NSKVONotifying_BAOPerson
則是 BAOPerson
的子類。 NSKVONotifying_BAOPerson
是 runtime 在執行時生成的。因此, p1
物件在呼叫 setAge
方法時必然會根據 p1
的 isa
找到 NSKVONotifying_BAOPerson
,並在 NSKVONotifying_BAOPerson
中找到 setAge
方法及其實現。
經查閱資料瞭解到, NSKVONotifying_BAOPerson
中的 setAge
方法中其實呼叫了 Foundation 框架中 C 語言函式 _NSsetIntValueAndNotify
, _NSsetIntValueAndNotify
內部的操作大致是:首先呼叫 willChangeValueForKey
方法,然後呼叫父類的 setAge
方法對成員變數賦值,最後呼叫 didChangeValueForKey
方法。 didChangeValueForKey
方法中會呼叫監聽者的監聽方法,最終呼叫監聽者的 observeValueForKeyPath
方法。
KVO 原理驗證
前面我們已經通過斷點列印 isa
指標的方式驗證了: p1
物件在新增 KVO 後,其 isa
指標會指向一個通過 runtime 建立的 BAOPerson
的子類 NSKVONotifying_BAOPerson
。
下面我們可以通過列印方法實現的地址來看一下 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:)]);
執行上述程式碼,可以發現:在新增 KVO 之前, p1
和 p2
的 setAge
方法實現的地址是相同的;在新增 KVO 之後, p1
的 setAge
方法實現的地址發生了改變。通過列印方法實現可以證明, p1
的 setAge
方法的實現由 BAOPerson
類方法中的 setAge
方法轉換成了 Foundation 框架中的 C 函式 _NSSetIntValueAndNotify
。
事實上,Foundation 框架中很多例如 _NSSetBoolValueAndNotify
、 _NSSetCharValueAndNotify
、 _NSSetFloatValueAndNotify
、 _NSSetLongValueAndNotify
等函式。
為了檢視 Foundation 框架中的相關函式,我們找到 Foundation 檔案,通過命令列查詢:
nm Foundation | grep ValueAndNotify
中間類內部結構
NSKVONotifying_BAOPerson
作為 BAOPerson
的子類,其 superclass
指標指向 BAOPerson
類,其內部對 setAge
方法做了單獨的實現,那麼 NSKVONotifying_BAOPerson
同 BAOPerson
類的差別可能就在於其記憶體儲的物件方法及實現不同。我們通過 runtime 分別列印 BAOPerson
類物件和 NSKVONotifying_BAOPerson
類物件記憶體儲的物件方法。
- (void)viewDidLoad { [super viewDidLoad]; BAOPerson *p1 = [[BAOPerson alloc] init]; BAOPerson *p2 = [[BAOPerson alloc] init]; p1.age = 1; p1.age = 2; p2.age = 2; NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld; [p1 addObserver:self forKeyPath:@"age" options:options context:nil]; [self printMethods: object_getClass(p2)]; [self printMethods: object_getClass(p1)]; p1.age = 10; [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); }
上述程式碼的列印結果如下:
可以發現, NSKVONotifying_BAOPerson
中有 4 個物件方法,分別是:
setAge: class dealloc _isKVOA
NSKVONotifying_BAOPerson
重寫 class
方法是為了隱藏 NSKVONotifying_BAOPerson
不被外界看到。我們在 p1
新增 KVO 之後,分別列印 p1
和 p2
物件的 class
,可以發現它們都返回 BAOPerson
。
NSLog(@"%@, %@", [p1 class], [p2 class]); // 列印結果 BAOPerson, BAOPerson
綜上,我們可以畫出 NSKVONotifying_BAOPerson
的內部結構及方法呼叫順序。
驗證 didChangeValueForKey:
內部呼叫 observeValueForKeyPath:ofObject:change:context:
方法
在 BAOPerson
類中重寫 willChangeValueForKey:
和 didChangeValueForKey:
方法,模擬它們的實現。
- (void)setAge:(NSInteger)age { NSLog(@"override 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:
方法內部呼叫了監聽者的 observeValueForKeyPath:ofObject:change:context:
方法。
根據上述原理,可以通過呼叫 willChangeValueForKey:
和 didChangeValueForKey:
來手動觸發 KVO。