在iOS開發中,蘋果提供了許多機制給我們進行回撥。KVO(key-value-observing)
是一種十分有趣的回撥機制,在某個物件註冊監聽者後,在被監聽的物件發生改變時,物件會傳送一個通知給監聽者,以便監聽者執行回撥操作。最常見的KVO運用是監聽scrollView
的contentOffset
屬性,來完成使用者滾動時動態改變某些控制元件的屬性實現效果,包括漸變導航欄、下拉重新整理控制元件等效果。
使用
KVO的使用非常簡單,使用KVO的要求是物件必須能支援kvc機制——所有NSObject的子類都支援這個機制。拿上面的漸變導航欄做,我們為tableView新增了一個監聽者controller,在我們滑動列表的時候,會計算當前列表的滾動偏移量,然後改變導航欄的背景色透明度。
1 2 3 4 5 6 7 8 9 10 11 12 |
//新增監聽者 [self.tableView addObserver: self forKeyPath: @"contentOffset" options: NSKeyValueObservingOptionNew context: nil]; /** * 監聽屬性值發生改變時回撥 */ - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { CGFloat offset = self.tableView.contentOffset.y; CGFloat delta = offset / 64.f + 1.f; delta = MAX(0, delta); [self alphaNavController].barAlpha = MIN(1, delta); } |
毫無疑問,kvo是一種非常便捷的回撥方式,但是編譯器是怎麼完成監聽這個任務的呢?先來看看蘋果文件對於KVO的實現描述
Automatic key-value observing is implemented using a technique called isa-swizzling… 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 ..
簡要的來說,在我們對某個物件完成監聽的註冊後,編譯器會修改監聽物件(上文中的tableView)的isa指標,讓這個指標指向一個新生成的中間類。從某個意義上來說,這是一場騙局。
1 2 3 4 |
typedef struct objc_class *Class; typedef struct objc_object { Class isa; } *id; |
這裡要說明的是isa這個指標,isa是一個Class型別的指標,用來指向類的型別,我們可以通過object_getClass方法來獲取這個值(正常來說,class方法內部的實現就是獲取這個isa指標,但是在kvo中蘋果對監聽物件的這個方法進行了重寫。之前這裡描述有誤,說成是指向父類,多謝夏都為我糾正)。
在oc中,規定了只要擁有isa指標的變數,通通都屬於物件。上面的objc_object表示的是NSObject這個類的結構體表示,因此oc不允許出現非NSObject子類的物件(block是一個特殊的例外)
當然了,蘋果並不想講述更多的實現細節,但是我們可以通過執行時機制來完成一些有趣的除錯。
蘋果的黑魔法
根據蘋果的說法,在物件完成監聽註冊後,修改了被監聽物件的某些屬性,並且改變了isa指標,那麼我們可以在監聽前後輸出被監聽物件的相關屬性來進一步探索kvo的原理。為了保證能夠得到物件的真實型別,我使用了object_getClass方法(class方法本質上是呼叫這個函式),這個方法在runtime.h標頭檔案中
1 2 3 4 5 6 7 8 9 10 |
NSLog(@"address: %p", self.tableView); NSLog(@"class method: %@", self.tableView.class); NSLog(@"description method: %@", self.tableView); NSLog(@"use runtime to get class: %@", object_getClass(self.tableView)); [self.tableView addObserver: self forKeyPath: @"contentOffset" options: NSKeyValueObservingOptionNew context: nil]; NSLog(@"==================================================="); NSLog(@"address: %p", self.tableView); NSLog(@"class method: %@", self.tableView.class); NSLog(@"description method: %@", self.tableView); NSLog(@"use runtime to get class %@", object_getClass(self.tableView)); |
在看官們執行這段程式碼之前,可以先思考一下上面的程式碼會輸出什麼。
1 2 3 4 5 6 7 8 9 |
2015-12-12 23:02:33.216 LXDAlphaNavigationController[1487:63171] address: 0x7f927a81d200 2015-12-12 23:02:33.216 LXDAlphaNavigationController[1487:63171] class method: UITableView 2015-12-12 23:02:33.217 LXDAlphaNavigationController[1487:63171] description method: ; layer = ; contentOffset: {0, 0}; contentSize: {600, 0}> 2015-12-12 23:02:33.217 LXDAlphaNavigationController[1487:63171] use runtime to get class: UITableView 2015-12-12 23:02:33.217 LXDAlphaNavigationController[1487:63171] =================================================== 2015-12-12 23:02:33.218 LXDAlphaNavigationController[1487:63171] address: 0x7f927a81d200 2015-12-12 23:02:33.218 LXDAlphaNavigationController[1487:63171] class method: UITableView 2015-12-12 23:02:33.218 LXDAlphaNavigationController[1487:63171] description method: ; layer = ; contentOffset: {0, 0}; contentSize: {600, 0}> 2015-12-12 23:02:33.230 LXDAlphaNavigationController[1487:63171] use runtime to get class NSKVONotifying_UITableView |
除了通過object_getClass獲取的型別之外,其他的輸出沒有任何變化。class
方法跟description
方法可以重寫實現上面的效果,但是為什麼連地址都是一樣的。
這裡可以通過一句小程式碼來說明一下:
1 |
NSLog(@"%@, %@", self.class, super.class); |
上面這段程式碼不管你怎麼輸出,兩個結果都是一樣的。這是由於super本質上指向的是父類記憶體。這話說起來有點繞口,但是我們可以通過物件記憶體圖來表示:
每一個物件佔用的記憶體中,一部分是父類屬性佔用的;在父類佔用的記憶體中,又有一部分是父類的父類佔用的。前文已經說過isa指標指向的是父類,因此在這個圖中,Son的地址從Father開始,Father的地址從NSObject開始,這三個物件記憶體的地址都是一樣的。通過這個,我們可以猜到蘋果文件中所提及的中間類就是被監聽物件的子類。並且為了隱藏實現,蘋果還重寫了這個子類的class方法跟description方法來掩人耳目。另外,我們還看到了新類相對於父類新增了一個NSKVONotifying_
字首,新增這個字首是為了避免多次建立監聽子類,節省資源
怎麼實現類似效果
既然知道了蘋果的實現過程,那麼我們可以自己動手通過執行時機制來實現KVO。runtime允許我們在程式執行時動態的建立新類、擴充方法、method-swizzling、繫結屬性等等這些有趣的事情。
在建立新類之前,我們應該學習蘋果的做法,判斷當前是否存在這個類,如果不存在我們再進行建立,並且重新實現這個新類的class方法來掩蓋具體實現。基於這些原則,我們用下面的方法來獲取新類
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
- (Class)createKVOClassWithOriginalClassName: (NSString *)className { NSString * kvoClassName = [kLXDkvoClassPrefix stringByAppendingString: className]; Class observedClass = NSClassFromString(kvoClassName); if (observedClass) { return observedClass; } //建立新類,並且新增LXDObserver_為類名新字首 Class originalClass = object_getClass(self); Class kvoClass = objc_allocateClassPair(originalClass, kvoClassName.UTF8String, 0); //獲取監聽物件的class方法實現程式碼,然後替換新建類的class實現 Method classMethod = class_getInstanceMethod(originalClass, @selector(class)); const char * types = method_getTypeEncoding(classMethod); class_addMethod(kvoClass, @selector(class), (IMP)kvo_Class, types); objc_registerClassPair(kvoClass); return kvoClass; } |
另外,在判斷是否需要中間類來完成監聽的註冊前,我們還要判斷監聽的屬性的有效性。通過獲取變數的setter方法名(將首字母大寫並加上字首set),以此來獲取setter實現,如果不存在實現程式碼,則丟擲異常使程式崩潰。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
SEL setterSelector = NSSelectorFromString(setterForGetter(key)); Method setterMethod = class_getInstanceMethod([self class], setterSelector); if (!setterMethod) { @throw [NSException exceptionWithName: NSInvalidArgumentException reason: [NSString stringWithFormat: @"unrecognized selector sent to instance %p", self] userInfo: nil]; return; } Class observedClass = object_getClass(self); NSString * className = NSStringFromClass(observedClass); //如果被監聽者沒有LXDObserver_,那麼判斷是否需要建立新類 if (![className hasPrefix: kLXDkvoClassPrefix]) { observedClass = [self createKVOClassWithOriginalClassName: className]; object_setClass(self, observedClass); } //重新實現setter方法,使其完成 const char * types = method_getTypeEncoding(setterMethod); class_addMethod(observedClass, setterSelector, (IMP)KVO_setter, types); |
在重新實現setter方法的時候,有兩個重要的方法:willChangeValueForKey
和didChangeValueForKey
,分別在賦值前後進行呼叫。此外,還要遍歷所有的回撥監聽者,然後通知這些監聽者:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
static void KVO_setter(id self, SEL _cmd, id newValue) { NSString * setterName = NSStringFromSelector(_cmd); NSString * getterName = getterForSetter(setterName); if (!getterName) { @throw [NSException exceptionWithName: NSInvalidArgumentException reason: [NSString stringWithFormat: @"unrecognized selector sent to instance %p", self] userInfo: nil]; return; } id oldValue = [self valueForKey: getterName]; struct objc_super superClass = { .receiver = self, .super_class = class_getSuperclass(object_getClass(self)) }; [self willChangeValueForKey: getterName]; void (*objc_msgSendSuperKVO)(void *, SEL, id) = (void *)objc_msgSendSuper; objc_msgSendSuperKVO(&superClass, _cmd, newValue); [self didChangeValueForKey: getterName]; //獲取所有監聽回撥物件進行回撥 NSMutableArray * observers = objc_getAssociatedObject(self, (__bridge const void *)kLXDkvoAssiociateObserver); for (LXD_ObserverInfo * info in observers) { if ([info.key isEqualToString: getterName]) { dispatch_async(dispatch_queue_create(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ info.handler(self, getterName, oldValue, newValue); }); } } } |
所有的監聽者通過動態繫結的方式將其儲存起來,但這樣也會產生強引用,所以我們還需要提供釋放監聽的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
- (void)LXD_removeObserver:(NSObject *)object forKey:(NSString *)key { NSMutableArray * observers = objc_getAssociatedObject(self, (__bridge void *)kLXDkvoAssiociateObserver); LXD_ObserverInfo * observerRemoved = nil; for (LXD_ObserverInfo * observerInfo in observers) { if (observerInfo.observer == object & [observerInfo.key isEqualToString: key]) { observerRemoved = observerInfo; break; } } [observers removeObject: observerRemoved]; } |
雖然上面已經粗略的實現了kvo,並且我們還能自定義回撥方式。使用target-action或者block的方式進行回撥會比單一的系統回撥要全面的多。但kvo真正的實現並沒有這麼簡單,上述程式碼目前只能實現物件型別的監聽,基本型別無法監聽,況且還有keyPath可以監聽物件的成員物件的屬性這種更強大的功能。
尾言
對於基本型別的監聽,蘋果可能是通過void *
型別對物件進行橋接轉換,然後直接獲取記憶體,通過type encoding我們可以獲取所有setter物件的具體型別,雖然實現比較麻煩,但是確實能夠達成類似的效果。
鑽研kvo的實現可以讓我們對蘋果的程式碼實現有更深層次的瞭解,這些知識涉及到了更深層次的技術,探究它們對我們的開發視野有著很重要的作用。同時,對比其他的回撥方式,KVO的實現在建立子類、重寫方法等等方面的記憶體消耗是很巨大的,因此博主更加推薦使用delegate、block等回撥方式,甚至直接使用method-swizzling來替換這種重寫setter方式也是可行的。
ps:昨天有人問我說為什麼kvo不直接通過重寫setter方法的方式來進行回撥,而要建立一箇中間類。誠然,method_swizzling是一個很讚的機制,完全能用它來滿足監聽需求。但是,如果我們要監聽的物件是tableView呢?正常而言,一款應用中遠不止一個列表,使用method_swizzling會導致所有的列表都新增了監聽回撥,先不考慮這可能導致的崩潰風險,所有繼承自tableView的檢視(包括自身)的setter都受到了影響。而使用中間類卻避免了這個問題
文章程式碼:自實現KVO