如何優雅地使用 KVO

KVO 作為 iOS 中一種強大並且有效的機制,為 iOS 開發者們提供了很多的便利;我們可以使用 KVO 來檢測對象屬性的變化、快速做出響應,這能夠為我們在開發強交互、響應式應用以及實現視圖和模型的雙向綁定時提供大量的幫助。

但是在大多數情況下,除非遇到不用 KVO 無法解決的問題,筆者都會盡量避免它的使用,這並不是因為 KVO 有性能問題或者使用場景不多,總重要的原因是 KVO 的使用是在是太 麻煩**了。

使用 KVO 時,既需要進行註冊成為某個對象屬性的觀察者,還要在合適的時間點將自己移除,再加上需要覆寫一個又臭又長的方法,並在方法里判斷這次是不是自己要觀測的屬性發生了變化,每次想用 KVO 解決一些問題的時候,作者的第一反應就是頭疼,這篇文章會為各位為 KVO 所苦的開發者提供一種更優雅的解決方案。

使用 KVO

不過在介紹如何優雅地使用 KVO 之前,我們先來回憶一下,在通常情況下,我們是如何使用 KVO 進行鍵值觀測的。

首先,我們有一個 Fizz 類,其中包含一個 number 屬性,它在初始化時會自動被賦值為 @0:

// Fizz.hn@interface Fizz : NSObjectnn@property (nonatomic, strong) NSNumber *number;nn@endnn// Fizz.mn@implementation Fizznn- (instancetype)init {n if (self = [super init]) {n _number = @0;n }n return self;n}nn@endn

我們想在 Fizz 對象中的 number 對象發生改變時獲得通知得到的和的值,這時我們就要祭出 -addObserver:forKeyPath:options:context 方法來監控 number 屬性的變化:

Fizz *fizz = [[Fizz alloc] init];n[fizz addObserver:selfn forKeyPath:@"number"n options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOldn context:nil];nfizz.number = @2;n

在將當前對象 self註冊成為 fizz 的觀察者之後,我們需要在當前對象中覆寫 -observeValueForKeyPath:ofObject:change:context: 方法:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {n if ([keyPath isEqualToString:@"number"]) {n NSLog(@"%@", change);n }n}n

在大多數情況下我們只需要對比 keyPath 的值,就可以知道我們到底監控的是哪個對象,但是在更複雜的業務場景下,使用 context 上下文以及其它輔助手段才能夠幫助我們更加精準地確定被觀測的對象。

但是當上述代碼運行時,雖然可以成功列印出 change 字典,但是卻會發生崩潰,你會在控制台中看到下面的內容:

2017-02-26 23:44:19.666 KVOTest[15888:513229] {n kind = 1;n new = 2;n old = 0;n}n2017-02-26 23:44:19.720 KVOTest[15888:513229] *** Terminating app due to uncaught exception NSInternalInconsistencyException, reason: An instance 0x60800001dd20 of class Fizz was deallocated while key value observers were still registered with it. Current observation info: <NSKeyValueObservationInfo 0x60800003d320> (n<NSKeyValueObservance 0x608000057310: Observer: 0x7fa098f07590, Key path: number, Options: <New: YES, Old: YES, Prior: NO> Context: 0x0, Property: 0x608000057400>n)n

這是因為 fizz 對象沒有被其它對象引用,在脫離 viewDidLoad 作用於之後就被回收了,然而在 -dealloc 時,並沒有移除觀察者,所以會造成崩潰。

我們可以使用下面的代碼來驗證上面的結論是否正確:

// Fizz.hn@interface Fizz : NSObjectnn@property (nonatomic, strong) NSNumber *number;n@property (nonatomic, weak) NSObject *observer;nn@endnn// Fizz.mn@implementation Fizznn- (instancetype)init {n if (self = [super init]) {n _number = @0;n }n return self;n}nn- (void)dealloc {n [self removeObserver:self.observer forKeyPath:@"number"];n}nn@endn

在 Fizz 類的介面中添加一個 observer 弱引用來持有對象的觀察者,並在對象 -dealloc 時將它移除,重新運行這段代碼,就不會發生崩潰了。

由於沒有移除觀察者導致崩潰使用 KVO 時經常會遇到的問題之一,解決辦法其實有很多,我們在這裡簡單介紹一個,使用當前對象持有被觀測的對象,並在當前對象 -dealloc 時,移除觀察者:

- (void)viewDidLoad {n [super viewDidLoad];n self.fizz = [[Fizz alloc] init];n [self.fizz addObserver:selfn forKeyPath:@"number"n options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOldn context:nil];n self.fizz.number = @2;n}nn- (void)dealloc {n [self.fizz removeObserver:self forKeyPath:@"number"];n}n

這也是我們經常使用來避免崩潰的辦法,但是在筆者看來也是非常的不優雅,除了上述的崩潰問題,使用 KVO 的過程也非常的彆扭和痛苦:

  1. 需要手動移除觀察者,且移除觀察者的時機必須合適
  2. 註冊觀察者的代碼和事件發生處的代碼上下文不同,傳遞上下文是通過 void * 指針;
  3. 需要覆寫 -observeValueForKeyPath:ofObject:change:context: 方法,比較麻煩;
  4. 在複雜的業務邏輯中,準確判斷被觀察者相對比較麻煩,有多個被觀測的對象和屬性時,需要在方法中寫大量的 if 進行判斷;

雖然上述幾個問題並不影響 KVO 的使用,不過這也足夠成為筆者盡量不使用 KVO 的理由了。

優雅地使用 KVO

如何優雅地解決上一節提出的幾個問題呢?我們在這裡只需要使用 Facebook 開源的 KVOController 框架就可以優雅地解決這些問題了。

如果想要實現同樣的業務需求,當使用 KVOController 解決上述問題時,只需要以下代碼就可以達到與上一節中完全相同的效果:

[self.KVOController observe:self.fizzn keyPath:@"number"n options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOldn block:^(id _Nullable observer, id _Nonnull object, NSDictionary<NSString *,id> * _Nonnull change) {n NSLog(@"%@", change);n }];n

我們可以在任意對象上獲得 KVOController 對象,然後調用它的實例方法 -observer:keyPath:options:block: 就可以檢測某個對象對應的屬性了,該方法傳入的參數還是非常容易理解的,在 block 中也可以獲得所有與 KVO 有關的參數。

使用 KVOController 進行鍵值觀測可以說完美地解決了在使用原生 KVO 時遇到的各種問題。

  1. 不需要手動移除觀察者;
  2. 實現 KVO 與事件發生處的代碼上下文相同,不需要跨方法傳參數;
  3. 使用 block 來替代方法能夠減少使用的複雜度,提升使用 KVO 的體驗;
  4. 每一個 keyPath 會對應一個屬性,不需要在 block 中使用 if 判斷 keyPath;

KVOController 的實現

KVOController 其實是對 Cocoa 中 KVO 的封裝,它的實現其實也很簡單,整個框架中只有兩個實現文件,先來簡要看一下 KVOController 如何為所有的 NSObject 對象都提供 -KVOController 屬性的吧。

分類和 KVOController 的初始化

KVOController 不止為 Cocoa Touch 中所有的對象提供了 -KVOController 屬性還提供了另一個 KVOControllerNonRetaining 屬性,實現方法就是分類和 ObjC Runtime。

@interface NSObject (FBKVOController)nn@property (nonatomic, strong) FBKVOController *KVOController;n@property (nonatomic, strong) FBKVOController *KVOControllerNonRetaining;nn@endn

從名字可以看出 KVOControllerNonRetaining 在使用時並不會持有被觀察的對象,與它相比 KVOController 就會持有該對象了。

對於 KVOController 和 KVOControllerNonRetaining 屬性來說,其實現都非常簡單,對運行時非常熟悉的讀者都應該知道使用關聯對象就可以輕鬆實現這一需求。

- (FBKVOController *)KVOController {n id controller = objc_getAssociatedObject(self, NSObjectKVOControllerKey);n if (nil == controller) {n controller = [FBKVOController controllerWithObserver:self];n self.KVOController = controller;n }n return controller;n}nn- (void)setKVOController:(FBKVOController *)KVOController {n objc_setAssociatedObject(self, NSObjectKVOControllerKey, KVOController, OBJC_ASSOCIATION_RETAIN_NONATOMIC);n}nn- (FBKVOController *)KVOControllerNonRetaining {n id controller = objc_getAssociatedObject(self, NSObjectKVOControllerNonRetainingKey);n if (nil == controller) {n controller = [[FBKVOController alloc] initWithObserver:self retainObserved:NO];n self.KVOControllerNonRetaining = controller;n }n return controller;n}nn- (void)setKVOControllerNonRetaining:(FBKVOController *)KVOControllerNonRetaining {n objc_setAssociatedObject(self, NSObjectKVOControllerNonRetainingKey, KVOControllerNonRetaining, OBJC_ASSOCIATION_RETAIN_NONATOMIC);n}n

兩者的 setter 方法都只是使用 objc_setAssociatedObject 按照鍵值簡單地存一下,而 getter 中不同的其實也就是對於 FBKVOController 的初始化了。

到這裡這個整個 FBKVOController 框架中的兩個實現文件中的一個就介紹完了,接下來要看一下其中的另一個文件中的類 KVOController。

KVOController 的初始化

KVOController 是整個框架中提供 KVO 介面的類,作為 KVO 的管理者,其必須持有當前對象所有與 KVO 有關的信息,而在 KVOController 中,用於存儲這個信息的數據結構就是 NSMapTable。

為了使 KVOController 達到線程安全,它還必須持有一把 pthread_mutex_t 鎖,用於在操作 _objectInfosMap 時使用。

再回到上一節提到的初始化問題,NSObject 的屬性 FBKVOController 和 KVOControllerNonRetaining 的區別在於前者會持有觀察者,使其引用計數加一。

- (instancetype)initWithObserver:(nullable id)observer retainObserved:(BOOL)retainObserved {n self = [super init];n if (nil != self) {n _observer = observer;n NSPointerFunctionsOptions keyOptions = retainObserved ? NSPointerFunctionsStrongMemory|NSPointerFunctionsObjectPointerPersonality : NSPointerFunctionsWeakMemory|NSPointerFunctionsObjectPointerPersonality;n _objectInfosMap = [[NSMapTable alloc] initWithKeyOptions:keyOptions valueOptions:NSPointerFunctionsStrongMemory|NSPointerFunctionsObjectPersonality capacity:0];n pthread_mutex_init(&_lock, NULL);n }n return self;n}n

在初始化方法中使用各自的方法對 KVOController 對象持有的所有實例變數進行初始化,KVOController 和 KVOControllerNonRetaining 的區別就體現在生成的 NSMapTable 實例時傳入的是 NSPointerFunctionsStrongMemory 還是 NSPointerFunctionsWeakMemory 選項。

KVO 的過程

使用 KVOController 實現鍵值觀測時,大都會調用實例方法 -observe:keyPath:options:block 來註冊成為某個對象的觀察者,監控屬性的變化:

- (void)observe:(nullable id)object keyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options block:(FBKVONotificationBlock)block {n _FBKVOInfo *info = [[_FBKVOInfo alloc] initWithController:self keyPath:keyPath options:options block:block];nn [self _observe:object info:info];n}n

數據結構 _FBKVOInfo

這個方法中就涉及到另外一個私有的數據結構 _FBKVOInfo,這個類中包含著所有與 KVO 有關的信息:

_FBKVOInfo 在 KVOController 中充當的作用僅僅是一個數據結構,我們主要用它來存儲整個 KVO 過程中所需要的全部信息,其內部沒有任何值得一看的代碼,需要注意的是,_FBKVOInfo 覆寫了 -isEqual: 方法用於對象之間的判等以及方便 NSMapTable 的存儲。

如果再有點別的什麼特別作用的就是,其中的 state 表示當前的 KVO 狀態,不過在本文中不會具體介紹。

typedef NS_ENUM(uint8_t, _FBKVOInfoState) {n _FBKVOInfoStateInitial = 0,n _FBKVOInfoStateObserving,n _FBKVOInfoStateNotObserving,n};n

observe 的過程

在使用 -observer:keyPath:options:block: 監聽某一個對象屬性的變化時,該過程的核心調用棧其實還是比較簡單:

我們從棧底開始簡單分析一下整個封裝 KVO 的過程,其中棧底的方法,也就是我們上面提到的 -observer:keyPath:options:block: 初始化了一個名為 _FBKVOInfo 的對象:

- (void)observe:(nullable id)object keyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options block:(FBKVONotificationBlock)block {n _FBKVOInfo *info = [[_FBKVOInfo alloc] initWithController:self keyPath:keyPath options:options block:block];n [self _observe:object info:info];n}n

在創建了 _FBKVOInfo 之後執行了另一個私有方法 -_observe:info::

- (void)_observe:(id)object info:(_FBKVOInfo *)info {n pthread_mutex_lock(&_lock);n NSMutableSet *infos = [_objectInfosMap objectForKey:object];nn _FBKVOInfo *existingInfo = [infos member:info];n if (nil != existingInfo) {n pthread_mutex_unlock(&_lock);n return;n }nn if (nil == infos) {n infos = [NSMutableSet set];n [_objectInfosMap setObject:infos forKey:object];n }n [infos addObject:info];n pthread_mutex_unlock(&_lock);nn [[_FBKVOSharedController sharedController] observe:object info:info];n}n

這個私有方法通過自身持有的 _objectInfosMap 來判斷當前對象、屬性以及各種上下文是否已經註冊在表中存在了,在這個 _objectInfosMap 中保存著對象以及與對象有關的 _FBKVOInfo 集合:

在操作了當前 KVOController 持有的 _objectInfosMap 之後,才會執行私有的 _FBKVOSharedController 類的實例方法 -observe:info::

- (void)observe:(id)object info:(nullable _FBKVOInfo *)info {n pthread_mutex_lock(&_mutex);n [_infos addObject:info];n pthread_mutex_unlock(&_mutex);nn [object addObserver:self forKeyPath:info->_keyPath options:info->_options context:(void *)info];nn if (info->_state == _FBKVOInfoStateInitial) {n info->_state = _FBKVOInfoStateObserving;n } else if (info->_state == _FBKVOInfoStateNotObserving) {n [object removeObserver:self forKeyPath:info->_keyPath context:(void *)info];n }n}n

_FBKVOSharedController 才是最終調用 Cocoa 中的 -observe:forKeyPath:options:context: 方法開始對屬性的監聽的地方;同時,在整個應用運行時,只會存在一個 _FBKVOSharedController 實例:

+ (instancetype)sharedController {n static _FBKVOSharedController *_controller = nil;n static dispatch_once_t onceToken;n dispatch_once(&onceToken, ^{n _controller = [[_FBKVOSharedController alloc] init];n });n return _controller;n}n

這個唯一的 _FBKVOSharedController 實例會在 KVO 的回調方法中將事件分發給 KVO 的觀察者。

- (void)observeValueForKeyPath:(nullable NSString *)keyPathn ofObject:(nullable id)objectn change:(nullable NSDictionary<NSString *, id> *)changen context:(nullable void *)context {n _FBKVOInfo *info;n pthread_mutex_lock(&_mutex);n info = [_infos member:(__bridge id)context];n pthread_mutex_unlock(&_mutex);nn FBKVOController *controller = info->_controller;n id observer = controller.observer;nn if (info->_block) {n NSDictionary<NSString *, id> *changeWithKeyPath = change;n if (keyPath) {n NSMutableDictionary<NSString *, id> *mChange = [NSMutableDictionary dictionaryWithObject:keyPath forKey:FBKVONotificationKeyPathKey];n [mChange addEntriesFromDictionary:change];n changeWithKeyPath = [mChange copy];n }n info->_block(observer, object, changeWithKeyPath);n } else if (info->_action) {n [observer performSelector:info->_action withObject:change withObject:object];n } else {n [observer observeValueForKeyPath:keyPath ofObject:object change:change context:info->_context];n }n}n

在這個 -observeValueForKeyPath:ofObject:change:context: 回調方法中,_FBKVOSharedController 會根據 KVO 的信息 _KVOInfo 選擇不同的方式分發事件,如果觀察者沒有傳入 block 或者選擇子,就會調用觀察者 KVO 回調方法。

上圖就是在使用 KVOController 時,如果一個 KVO 事件觸發之後,整個框架是如何對這個事件進行處理以及回調的。

如何 removeObserver

在使用 KVOController 時,我們並不需要手動去處理 KVO 觀察者的移除,因為所有的 KVO 事件都由私有的 _KVOSharedController 來處理;

當每一個 KVOController 對象被釋放時,都會將它自己持有的所有 KVO 的觀察者交由 _KVOSharedController 的 -unobserve:infos: 方法處理:

- (void)unobserve:(id)object infos:(nullable NSSet<_FBKVOInfo *> *)infos {n pthread_mutex_lock(&_mutex);n for (_FBKVOInfo *info in infos) {n [_infos removeObject:info];n }n pthread_mutex_unlock(&_mutex);nn for (_FBKVOInfo *info in infos) {n if (info->_state == _FBKVOInfoStateObserving) {n [object removeObserver:self forKeyPath:info->_keyPath context:(void *)info];n }n info->_state = _FBKVOInfoStateNotObserving;n }n}n

該方法會遍歷所有傳入的 _FBKVOInfo,從其中取出 keyPath 並將 _KVOSharedController 移除觀察者。

除了在 KVOController 析構時會自動移除觀察者,我們也可以通過它的實例方法 -unobserve:keyPath: 操作達到相同的效果;不過在調用這個方法時,我們能夠得到一個不同的調用棧:

功能的實現過程其實都是類似的,都是通過 -removeObserver:forKeyPath:context: 方法移除觀察者:

- (void)unobserve:(id)object info:(nullable _FBKVOInfo *)info {n pthread_mutex_lock(&_mutex);n [_infos removeObject:info];n pthread_mutex_unlock(&_mutex);nn if (info->_state == _FBKVOInfoStateObserving) {n [object removeObserver:self forKeyPath:info->_keyPath context:(void *)info];n }n info->_state = _FBKVOInfoStateNotObserving;n}n

不過由於這個方法的參數並不是一個數組,所以並不需要使用 for 循環,而是只需要將該 _FBKVOInfo 對應的 KVO 事件移除就可以了。

總結

KVOController 對於 Cocoa 中 KVO 的封裝非常的簡潔和優秀,我們只需要調用一個方法就可以完成一個對象的鍵值觀測,同時不需要處理移除觀察者等問題,能夠降低我們出錯的可能性。

在筆者看來 KVOController 中唯一不是很優雅的地方就是,需要寫出 object.KVOController 才可以執行 KVO,如果能將 KVOController 換成更短的形式可能看起來更舒服一些:

[self.kvo observer:keyPath:options:block:];n

不過這並不是一個比較大的問題,同時也只是筆者自己的看法,況且不影響 KVOController 的使用,所以各位讀者也無須太過介意。

Github Repo:iOS-Source-Code-Analyze

Follow: Draveness · GitHub

Source: 如何優雅地使用 KVO

推薦閱讀:

已無力吐槽!iOS 11確實問題很多,尤其是這個不能忍
假如 PC 有一個類似 iOS 的通知系統,不用開某樣軟體也能收到通知,優缺點有哪些?
誰偷了我的熱更新?Mono,JIT,iOS
2017年移動設備界面設計有哪些趨勢?

TAG:iOS开发 | Objective-C | iOS |