iOS Objective-C KVO 詳解
1. KVO
KVO
即Key-Value Observing
是蘋果提供給開發者的一套鍵值觀察的API,KVO
是一種機制,它允許將其他對象的指定屬性的更改通知給對象。KVO
是建立在KVC
的基礎上的,對于KVC
的原理及應用可以查看我的上一篇文章。下面我們來詳細的介紹KVO
1.1 KVO 可以觀察什么屬性?
根據KVO
官方文檔的定義,我們可以知道可觀察的屬性分為以下三種:
-
attributes: 簡單屬性,比如基本數據類型,字符串布爾值等,諸如
NSNumber
和其他一些不可變類型,比如NSColor
也被認為是簡單屬性。 - to-one relationships: 一對一關系,一個屬性的值取決于另一個值。比如一個人的全名由姓氏和名字組成,其中任一一個改變都會影響全名的改變;其實下載進度也跟這個類似,下載量和總量的任一改變都會改變下載進度。
-
to-many relationships: 一對多關系,在KVO中不支持多對多的關系鍵值路徑。一對多關系主要是集合對象屬性,通常就是
NSArray
或者NSSet
等,但是涉及改變就是他們的可變類型。比如有一個部門,有一個員工數組,員工有薪資屬性,部門有個總工資屬性,我想監聽總工資的變化,其實就是監聽員工數組的改變,再其改變后可以通過KVC
的數組操作符計算總工資的變化,然后手動調用willchange
去觸發總工資改變的監聽
對于一對一和一對多的關系可以查看蘋果官方文檔,進一步了解和示例代碼的查看。
1.2 KVO 的三個步驟
舉個例子,如上圖所示Person
對象有個Account
屬性,而Account
對象又有balance
和interestRate
兩個屬性。現在我們想實現一個功能:當余額和利率變化的時候需要通知到用戶,其實用戶可以通過輪詢的方式定期去查詢Account
對象中的balance
和interestRate
,但是這種方式不僅不及時而且效率低,消耗大,更好的方式是使用KVO
,使Person
對象像收到通知一樣能及時的知道余額和利率的變動。
另外要實現KVO
的前提是被觀察對象時符合KVO
機制的,一般來說,繼承于NSObject
根類的對象及其屬性都自動符合KVO
機制。當然我們也可以自己去實現,使其同樣符合KVO
機制,這就是Manual Change Notification
(手動變更通知),所以KVO
包含Automatic Change Notification
(自動變更通知)和Manual Change Notification
(手動變更通知)兩種機制。
- 首先是注冊觀察者
將觀察者實例Person
與觀察實例Account
注冊在一起。Person
對每個觀察到的鍵路徑向Account
發送一個addObserver:forKeyPath:options:context:消息,將自己命名為觀察者。這里observer
(監聽者)、keyPath
(被監聽者)、options
(監聽策略)、context
(上下文)。
- 被觀察者觸發回調
為了接收Account
的變更通知,Person
需要實現observeValueForKeyPath:ofObject:change:context:
方法。Account
將在任何改變的時候想Person
發送該消息,Person
可以根據通知做出相應的措施。
- 移除觀察
最后,當不需要監聽的時候就可以通過removeObserver:forKeyPath:
方法移除監聽,但是移除必須在監聽者對象銷毀前執行。
1.3 KVO
三個方法解析
1.3.1 注冊觀察者
- (void)addObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(nullable void *)context;
-
observer: 觀察者,一般都是
self
- keyPath: 被觀察者的屬性
-
options:
NSKeyValueObservingOptions
的組合,它指定觀察通知中會回調什么值 -
context: 上下文,這里是一個
nullable void *
類型的參數,我們通常會傳nil
,其實應該傳NULL
,官方文檔也說應該傳NULL
。其實這里我們可以傳一個void *
類型的指針,用來區分相同path
的不同對象的觀察。傳值示例:static void *PersonNameContext = &PersonNameContext;
NSKeyValueObservingOptions:的四個枚舉值
- NSKeyValueObservingOptionNew: 表明通知中的更改字典應該提供新的屬性值,如果有的話。
- NSKeyValueObservingOptionOld: 表明通知中的更改字典應該包含舊的屬性值,如果有的話。
-
NSKeyValueObservingOptionInitial: 在屬性發生變化后立即通知觀察者,這個過程甚至早于觀察者注冊是時候。如果在注冊的時候配置了
NSKeyValueObservingOptionNew
,那么在通知的更改字典中也會包含NSKeyValueChangeNewKey
,但是不會包括NSKeyValueChangeOldKey
。(在初始通知中,觀察到的屬性值可能是舊的,但是對于觀察者來說是新的)其實簡單來說就是這個枚舉值會在屬性變化前先觸發一次observeValueForKeyPath
回調。 -
NSKeyValueObservingOptionPrior: 這個會先后連續出發兩次
observeValueForKeyPath
回調。同時在回調中的可變字典中會有一個布爾值的key - notificationIsPrior
來標識屬性值是變化前還是變化后的。如果是變化后的回調,那么可變字典中就只有new
的值了,如果同時制定了NSKeyValueObservingOptionNew
的話。如果你需要啟動手動KVO
的話,你可以指定這個枚舉值然后通過willChange
實例方法來觀察屬性值。在出發observeValueForKeyPath
回調后再去調用willChange
可能就太晚了。
下面我們來驗證一下NSKeyValueObservingOptions
幾個key
會有什么樣的結果。
初始實現代碼:
static void *PersonNameContext = &PersonNameContext;
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [LGPerson new];
self.person.name = @"nameA";
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:PersonNameContext];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
if (context == PersonNameContext) {
NSLog(@"person name change %@ - %@",self, change);
} else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
self.person.name = @"nameB";
}
NSKeyValueObservingOptionNew:
NSKeyValueObservingOptionOld:
NSKeyValueObservingOptionInitial:
NSKeyValueObservingOptionInitial
會觸發兩次回調,第一次是在屬性改變前,第二次是在屬性改變后。但是并沒有返回任何的舊值和新值。其實第一次是在我們調用addObserver:forKeyPath:
后就打印了的。這與name
是否賦初始值沒有關系,有沒有初值都會打印。
Initial | New | Old:
此時還是觸發了兩次回調,只不過第一次返回的新值其實就是舊值,就是我們初始化時的值,第二次返回即包含了新值,也包含了舊值。其實我們包含new
在第二次就會返回新值,包含old
就會返回舊值,如果不包含就不會返回。如果不包含new
第一次就不會返回新值。
NSKeyValueObservingOptionPrior:
這是也是觸發了兩次回調,不過這兩次回調是在值改變后觸發的,并且第一次多返回了一個notificationIsPrior
值。
Prior | New | Old:
此時還是觸發了兩次回調,同樣在第一次回調中包含notificationIsPrior
值。并且第一次回調中多了舊值,第二次回調中即包含舊值也包含新值。同樣我們包含new
在第二次就會返回新值,包含old
就會返回舊值,如果不包含就不會返回。如果不包含old
第一次就不會返回舊值。
1.3.2 觀察者接收通知
- (void)observeValueForKeyPath:(nullable NSString *)keyPath
ofObject:(nullable id)object
change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change
context:(nullable void *)context;
除了change
其他參數跟上面注冊觀察時的相同。
change
包含五個key
,如下:
key | value | 描述 |
---|---|---|
NSKeyValueChangeKindKey | NSNumber類型 | 1:Setting,2:Insertion,3:Removal,4:Replacement |
NSKeyValueChangeNewKey | id | 變化后的新值 |
NSKeyValueChangeOldKey | id | 變化后的舊值 |
NSKeyValueChangeIndexesKey | NSIndexSet | 插入、刪除或替換的對象的索引 |
NSKeyValueChangeNotificationIsPriorKey | NSNumber boolValue | Option為Prior時標識屬性值是變化前和還是變化后的 |
NSKeyValueChangeKindKey對應的枚舉:
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
NSKeyValueChangeSetting = 1,
NSKeyValueChangeInsertion = 2,
NSKeyValueChangeRemoval = 3,
NSKeyValueChangeReplacement = 4,
};
1.3.3 移除觀察
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
移除和注冊時一一對應的,有兩個方法一個是帶context
參數的,另一個是不帶的。在觀察者生命周期結束前,一定要移除觀察,如果沒有移除,KVO
機制會給一個不存在的對象發送變化回調消息導致野指針錯誤。另外也不能重復移除注冊,重復移除會導致crash
,當然為了避免crash
我們可以把移除放在@try里面去執行。
1.4 自動觀察與手動觀察
默認情況下,我們只需要按照上面的步驟就可以實現屬性的觀察,其實這是由系統完全控制的,屬于自動觀察。其實KVO
還給我們提供了手動觀察的選項。
如果我們想要開啟手動觀察就要通過重寫類方法+ (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key
,如果返回YES
就是自動觀察,返回NO
就是手動觀察,根據方法的我們還可以判斷key
值對不同的key
分別實現自動觀察和手動觀察。
// 自動開關
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey {
BOOL automatic = NO;
if ([theKey isEqualToString:@"balance"]) {
automatic = NO;
}
else {
automatic = [super automaticallyNotifiesObserversForKey:theKey];
}
return automatic;
}
對于需要手動觀察的key
在改變前需要調用willChangeValueForKey
方法,在改變后需要調用didChangeValueForKey
方法,如果不調用,就不會觸發KVO
的監聽。
示例代碼:
- (void)setBalance:(double)theBalance {
[self willChangeValueForKey:@"balance"];
_balance = theBalance;
[self didChangeValueForKey:@"balance"];
}
- 我們可以通過提前檢查是否已更改來最大程度的減少發送不必要的通知:
官方示例:
- (void)setBalance:(double)theBalance {
if (theBalance != _balance) {
[self willChangeValueForKey:@"balance"];
_balance = theBalance;
[self didChangeValueForKey:@"balance"];
}
}
- 如果單個操作導致更改多個鍵,則必須嵌套更改通知
官方示例:
- (void)setBalance:(double)theBalance {
[self willChangeValueForKey:@"balance"];
[self willChangeValueForKey:@"itemChanged"];
_balance = theBalance;
_itemChanged = _itemChanged+1;
[self didChangeValueForKey:@"itemChanged"];
[self didChangeValueForKey:@"balance"];
}
- 對于有序的一對多關系,不僅必須指定已更改的鍵,還必須指定更改的類型和所涉及對象的索引。
- 改變的類型的鍵值是一個
NSKeyValueChange
類型的枚舉值,有三個分別是:NSKeyValueChangeInsertion
、NSKeyValueChangeRemoval
和NSKeyValueChangeReplacement
。 - 受影響對象的索引作為NSIndexSet對象傳遞。
- 改變的類型的鍵值是一個
示例代碼:
- (void)removeTransactionsAtIndexes:(NSIndexSet *)indexes {
[self willChange:NSKeyValueChangeRemoval
valuesAtIndexes:indexes forKey:@"transactions"];
// Remove the transaction objects at the specified indexes.
[self didChange:NSKeyValueChangeRemoval
valuesAtIndexes:indexes forKey:@"transactions"];
}
1.5 Registering Dependent Keys(注冊從屬關系的鍵值)
在許多情況下,一個屬性的值取決于另一對象中一個或多個其他屬性的值。如果一個屬性的值發生更改,則派生屬性的值也應標記為更改。如何確保為這些從屬屬性發布鍵值觀察通知取決于關系的基數。 這個在上面已經有所提到,這里在通過舉例進行詳細的說明。
1.5.1 一對一關系
要自動觸發一對一關系的通知,您應該重寫 keyPathsForValuesAffectingValueForKey:
或實現遵循其定義的用于注冊從屬鍵的模式的合適方法。
例如,一個人的全名取決于名字和姓氏。返回全名的方法可以編寫如下:
- (NSString *)fullName {
return [NSString stringWithFormat:@"%@ %@",firstName, lastName];
}
fullName當firstName或lastName屬性更改時,必須通知觀察該屬性的應用程序,因為它們會影響屬性的值。
第一種方法是我們通過重寫keyPathsForValuesAffectingValueForKey:
指定fullName
的屬性取決于lastName
和firstName
屬性。通常我們應該調用super
并返回一個集合,該集合包括這樣做所導致的集合中的其他任何成員免受干擾。
+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
if ([key isEqualToString:@"fullName"]) {
NSArray *affectingKeys = @[@"lastName", @"firstName"];
keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
}
return keyPaths;
}
第二種方法是通過實現遵循命名約定的類方法keyPathsForValuesAffecting<Key>
來實現相同的結果,其中<Key>
是依賴值的屬性名稱(首字母大寫)。實現代碼如下:
+ (NSSet *)keyPathsForValuesAffectingFullName {
return [NSSet setWithObjects:@"lastName", @"firstName", nil];
}
對于分類我們只能以第二種方法進行實現因為我們不能再分類中覆蓋keyPathsForValuesAffectingValueForKey:
的實現。
1.5.2 一對多關系
keyPathsForValuesAffectingValueForKey:
方法不支持包含多對多關系的鍵路徑。那么對于這種關系的鍵值路徑我們該如何處理呢?
例如我們有個Department
(部門),他又一個employees
(員工數組)對象,部門跟員工有很多關系,但是Employee
(員工)具有salary
(薪資)屬性,這時我們希望部門有個totalSalary
(總工資)屬性,那么這個屬性取決于員工數組中所有員工的薪資,我們也不能使用keyPathsForValuesAffectingTotalSalary
和employees.salary
作為鍵返回。
此時我們可以使用鍵值觀察將父項(在此示例中為Department
)注冊為所有子項(在此示例中為employees
)的相關屬性的觀察者。您必須作為觀察者添加和刪除父對象,因為要在關系中添加或刪除子對象。在該observeValueForKeyPath:ofObject:change:context:
方法中,您將響應更改來更新從屬值,如以下代碼片段所示:
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (context == totalSalaryContext) {
[self updateTotalSalary];
}
else
// deal with other observations and/or invoke super...
}
- (void)updateTotalSalary {
[self setTotalSalary:[self valueForKeyPath:@"employees.@sum.salary"]];
}
- (void)setTotalSalary:(NSNumber *)newTotalSalary {
if (totalSalary != newTotalSalary) {
[self willChangeValueForKey:@"totalSalary"];
_totalSalary = newTotalSalary;
[self didChangeValueForKey:@"totalSalary"];
}
}
- (NSNumber *)totalSalary {
return _totalSalary;
}
另外如果您使用的是Core Data
,則可以將父項注冊到應用程序的通知中心,作為其托管對象上下文的觀察者。父母應以類似于觀察鍵值的方式響應孩子發布的相關變更通知。
2. KVO 底層原理探索
由于KVO的實現并沒有開源,我們首先看看官方文檔是怎么說的:
Automatic key-value observing is implemented using a technique called isa-swizzling. 【譯:】自動鍵值觀察使用的是一種叫做
isa-swizzling
的技術。
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
的值不一定反映的是實例的實際的類。
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
指針來確定類成員,所以我們應該使用class
方法確定對象實例的類。
2.1 中間類(派生類)
根據官方文檔的內容我們可以知道,在KVO
的底層實現中會生成一個中間類,此時我們實例對象的isa
就指向了這個中間類,那么我們就來驗證一下:
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [[LGPerson alloc] init];
NSLog(@"注冊KVO前%@---%s", NSStringFromClass([self.person class]), object_getClassName(self.person));
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
NSLog(@"注冊KVO后%@---%s", NSStringFromClass([self.person class]), object_getClassName(self.person));
}
打印結果如下:
通過打印結果可以看出在注冊KVO
觀察后通過Objective-C
方法打印的的類名仍然是LGPerson
,但是在注冊后通過Runtime API
打印的確有不同了,所以說Objective-C
方法對class
方法進行了封裝,讓我們在開發過程中對中間類無感知,但是底層確實是實現了一個中間類就是NSKVONotifying_xxx
。其實我們也可以通過打印對象的isa
來驗證,至此我們就驗證了官方文檔所說的內容。
那么這個中間類跟我們的類有什么關系呢?我們不妨打印一下類和它的子類來看看。
打印類實現代碼:
NSLog(@"注冊KVO前");
[self printClasses:[LGPerson class]];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
NSLog(@"注冊KVO后");
[self printClasses:[LGPerson class]];
- (void)printClasses:(Class)cls{
/// 注冊類的總數
int count = objc_getClassList(NULL, 0);
/// 創建一個數組, 其中包含給定對象
NSMutableArray *mArray = [NSMutableArray arrayWithObject:cls];
/// 獲取所有已注冊的類
Class* classes = (Class*)malloc(sizeof(Class)*count);
objc_getClassList(classes, count);
for (int i = 0; i<count; i++) {
if (cls == class_getSuperclass(classes[i])) {
[mArray addObject:classes[i]];
}
}
free(classes);
NSLog(@"classes = %@", mArray);
}
可以看到在LGPerson
的子類中有這個中間類,所以說這個中間類是類的子類。
2.2 KVO 觀察
我們知道KVO
是觀察屬性的變化,那么屬性的本質是成員變量+getter
+setter
,getter
是取值的,并不會修改值,值的變化發生在setter
和給成員變量賦值兩種情況。那么我們分別測試一下這兩種情況哪一種會觸發KVO
的觀察。
聲明代碼:
@interface LGPerson : NSObject{
@public
NSString *name;
}
@property (nonatomic, copy) NSString *nickName;
@end
驗證代碼和結果:
通過上圖我們可以看到直接給實例變量賦值并不會觸發KVO
的監聽,但是直接給屬性賦值就觸發了KVO
的監聽,其實給屬性賦值就是調用setter
方法,所以說KVO
底層是觀察的setter
方法。
2.3 中間類都有哪些方法
我們分別打印原始類和中間類中的方法進行查看:
實現代碼:
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [[LGPerson alloc] init];
NSLog(@"原始類中的方法");
[self printClassAllMethod:[LGPerson class]];
[self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL];
NSLog(@"派生類中的方法");
[self printClassAllMethod:NSClassFromString(@"NSKVONotifying_LGPerson")];
}
printClassAllMethod 代碼:
- (void)printClassAllMethod:(Class)cls{
unsigned int count = 0;
Method *methodList = class_copyMethodList(cls, &count);
for (int i = 0; i<count; i++) {
Method method = methodList[i];
SEL sel = method_getName(method);
IMP imp = class_getMethodImplementation(cls, sel);
NSLog(@"%@-%p",NSStringFromSelector(sel),imp);
}
free(methodList);
}
打印結果:
我們可以看到中間類中有屬性的setter
方法,class
方法,dealloc
方法以及_isKVOA
方法。這里的setter
方法是重寫了原始類的方法,其余的都是重寫的NSObject
方法。
- 對于重寫
setter
應該是在setter
方法中觸發監聽回調,已經給原始類中屬性賦值 - 對于重寫
class
,這里也就驗證了我們在上面打印class
時為什么都是原始類的名稱。這樣是為了隱藏中間類的存在,讓開發者在使用過程中保持一致性。 - 對于重寫
dealloc
應該是移除監聽時需要處理一些邏輯 - 對于重寫
_isKVOA
方法應該是返回是否是KVO
的值
2.3 isa 何時指回原始類
其實這很容易想到,當我們移除所有觀察后就意味著我們不需要觀察了,此時在指向中間類也就沒什么意義了。下面我們進行驗證。
驗證代碼:
- (void)dealloc{
NSLog(@"移除觀察前%@",object_getClass(self.person));
[self.person removeObserver:self forKeyPath:@"nickName"];
NSLog(@"移除觀察后%@",object_getClass(self.person));
[self printClasses:[LGPerson class]];
}
打印結果:
我們通過代碼和lldb
進行了驗證在移除觀察后isa
即指回了原始的類。另外我們也驗證了指回后是否銷毀中間類,顯然中間類并沒有被銷毀。其實這也很正常,因為創建一個類還是非常耗費性能的,雖然移除了觀察,但是也不能保證不再重新開始觀察,既然創建了就讓它留著吧,如果下次繼續開始監聽就不用重新創建了,也就提高了性能。
3. 自定義KVO
至此我們就基本分析完畢了KVO
,那么我們可以自己來實現以下。
擱置了!!!
- FaceBook 的 KVOController
- 根據原生的
KVC
和KVO
反匯編而編寫的 DIS_KVC_KVO - 開源的
GNUStep
的libs-base
(最接近APPLE源碼的)gnustep/libs-base
4.總結
-
KVO
是蘋果提供給開發者的一套鍵值觀察的API -
KVO
由注冊觀察者,監聽通知,移除觀察三個步驟組成 - 有自動觀察和手動觀察兩種模式
- 對于可變集合需要通過
mutableXXXValueForKey
的相關方法觸發更改 - 我們還可以注冊從屬關系的鍵值觀察,
KVO
支持一對一和一對多兩種 -
KVO
本質是isa-swizzling
技術,通過生成中間類(派生類)來實現屬性的觀察 - 中間類會重寫屬性的
setter
方法以及重寫class
方法,dealloc
方法和_isKVOA
方法