使用Runtime來實現自己的KVO

YDLIN發表於2018-04-25

KVO的概念

Key-value observing(KVO):鍵值觀察,它是一種機制,允許通知物件對其他物件的指定屬性的更改。舉個例子:小明有一個銀行賬號,此時,小明他需要知道他的賬號的變動情況,例如餘額還有多少......也就是說,餘額屬性發生變化了,需要告知小明。這就是KVO。

KVO的使用

我們先來看看KVO的基本使用,先建立一個Person類,並擁有money屬性:

@interface ViewController ()
@property (strong, nonatomic) Person *p;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    Person *p = [[Person alloc] init];
    [p addObserver:self forKeyPath:@"money" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:nil];
    _p = p;
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    NSLog(@"%@物件的%@屬性被修改了。----%@",object, keyPath, change);
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    static int i = 0;
    i++;
    self.p.money = i;
}

- (void)dealloc {
    [self.p removeObserver:self forKeyPath:@"money"];
}
@end
複製程式碼

現在我們每點選一次螢幕,就修改p物件的money屬性的值,列印結果:

2018-04-25 15:27:39.547201+0800 KVO[6320:1760126] <Person: 0x604000007c50>物件的money屬性被修改了。----{
    kind = 1;
    new = 1;
    old = 0;
}
2018-04-25 15:27:40.182808+0800 KVO[6320:1760126] <Person: 0x604000007c50>物件的money屬性被修改了。----{
    kind = 1;
    new = 2;
    old = 1;
}
複製程式碼

我們每次修改p物件的money屬性,都會監聽到,這就是我們KVO的基本使用。

KVO的實現原理

我們先來看看蘋果的官方文件是怎麼解釋的:

Key-Value Observing Implementation Details Automatic key-value observing is implemented using a technique called 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. 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. 當一個觀察者註冊一個物件的屬性時,觀察物件的isa指標被修改,指向一箇中間類而不是真實類。 As a result the value of the isa pointer does not necessarily reflect the actual class of the instance. 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.

關鍵技術就是通過runtimeisa-swizzling來實現的。只要我們新增監聽後,系統會做兩件事:

  • 通過runtime動態的給Person類建立一個子類NSKVONotifying_person 有了這個新的子類後,系統會在在NSKVONotifying_person裡面重寫了money屬性的setter,這樣一來,我們修改p物件的money屬性,首先呼叫的是NSKVONotifying_personsetMoney:方法,而不是PersonsetMoney:方法。
  • 將p物件的“is a”指標指向這個子類,而不再是Person類 經過這一步後,p物件不再是Person型別了,而是NSKVONotifying_person型別了。

“is a”指標定義了物件所屬的類,是物件結構體的Class類的變數,與super_class指標不一樣,前者是描述例項所屬的類,後者確立了繼承關係 以上就是新增監聽後系統所做的兩件事情。 但是這裡有個疑問:為什麼Personmoney屬性被修改了,能呼叫observeValueForKeyPath: ofObject: change: context:方法?因為NSKVONotifying_personsetMoney:方法內部還呼叫了監聽器的observeValueForKeyPath:ofObject:change:context:方法。 這就是KVO的實現原理,如果大家想更加詳細的瞭解原理,推薦一篇部落格,寫得很好,大家可以去看看。

自定義KVO

使用runtime之前,我們最好先去修改一下專案配置:

使用Runtime來實現自己的KVO
將紅框修改為NO,這樣我們使用runtime函式的時候,就會有程式碼補全了。 知道KVO的原理後,我們嘗試去實現我們自己的KVO方法。我們進去看看addObserver:forKeyPath:options:context:方法看看,然後發現該方法是在NSObject的一個分類裡面宣告的:
使用Runtime來實現自己的KVO
所以,我們就有了思路,我們也給NSObject搞個分類,然後再裡面去定義和實現自己的KVO方法。我們在NSObject+KVO.h檔案宣告一個方法: - (void)ot_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context; 來到NSObject+KVO.m檔案,我們需要在方法的實現裡面做幾件事:

  • 動態建立類
  • 修改p物件的型別 我們先完成第一步,建立新的類,由於蘋果的子類的命名方式是NSKVONotifying_xxxx,所以我們也這樣模仿:
    //1.動態建立Person子類
    NSString *superClassName = NSStringFromClass([self class]);
    NSString *subClassName = [@"OTKVONotifying_" stringByAppendingString:superClassName];
    /*
     * 引數一:新增的這個子類的父類
     * 引數二:新增的這個子類的名字
     * 引數三:傳0即可
     */
    Class subClass = objc_allocateClassPair([self class], subClassName.UTF8String, 0);
    //2.註冊新建立的類
    objc_registerClassPair(subClass);
    
    //3.修改呼叫者的型別(Person->OTKVONotifying_Person)
    object_setClass(self, subClass);
複製程式碼

這句程式碼就完成了p物件由Person類到OTKVONotifying_ Person類的轉變。 那麼我們來驗證一下,我們將原來控制器viewDidLoad方法裡面的程式碼改為這樣:

- (void)viewDidLoad {
    [super viewDidLoad];
    Person *p = [[Person alloc] init];
    [p ot_addObserver:self forKeyPath:@"money" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:nil];
    _p = p;
}
複製程式碼

我們通過斷點除錯來看看p物件的型別,在新增監聽之前,p依然是Peron型別:

使用Runtime來實現自己的KVO
一旦新增了監聽之後,p就變成了OTKVONotifying_Person型別了:

使用Runtime來實現自己的KVO
所以,到這一步,我們已經成功的修改了p的型別了。雖然我們成功修改了p物件的型別,但是還有很多東西未完成,我們現在點選螢幕,發現observeValueForKeyPath:ofObject:change:context:方法不呼叫了,這是一個問題。這裡還有一個疑問,我們執行self.p.money = i這句程式碼的時候,setMoney:方法到底是OTKVONotifying_Person類的還是,Person類的?其實,OTKVONotifying_Person類並沒有setMoney:方法,我們能呼叫,是因為OTKVONotifying_Person類繼承於Person類,子類沒有該方法,就去父類裡面找,找到則執行,所以結合之前的原理分析,我們需要在OTKVONotifying_Person類裡面重寫setMoney:方法,並在setMoney:方法內部呼叫監聽器的observeValueForKeyPath:ofObject:change:context:方法。但是這裡我們又有了幾個疑問:

  • 怎樣去呼叫外面的observeValueForKeyPath:ofObject:change:context:方法?
  • 如何把修改的屬性的新舊值傳到外面? 首先是疑問一:因為observeValueForKeyPath:ofObject:change:context:方法是由監聽器去實現的,所以我們需要獲得監聽器才能去呼叫該方法,但是OTKVONotifying_Person類是我們動態建立的,還沒擁有監聽器,所以我們要在建立它的時候,給它繫結一個監聽器,這樣就能在setMoney:方法裡面拿到監聽器去呼叫observeValueForKeyPath:ofObject:change:context:方法了。 疑問二:新值比較好辦,就是在我們的(IMP)setMoney裡面就可以知道了。對於舊值,我們知道,真正去修改money屬性的值還是原來的Person型別,所以我們在修改money之前,呼叫Person的getter,這樣就能獲取到舊值了。 我們在接著在這句程式碼object_setClass(self, subClass);後面去給子類新增若干方法跟繫結監聽器:
    /* 4.重寫setMoney:方法(給子類新增方法)
     * 引數一:給哪個類新增方法
     * 引數二:SEL方法編號
     * 引數三:IMP方法實現
     * 引數四:型別編碼
     此引數可以參考官方文件:https://developer.apple.com/library/content/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html
     */
    class_addMethod(subClass, NSSelectorFromString(@"setMoney:"), (IMP)setMoney, "v@:i");
    
    /* 5.將觀察者繫結到物件上
     * 引數一:給哪個物件繫結屬性
     * 引數二:常量指標,用來標識
     * 引數三:給物件繫結什麼
     * 引數四:OBJC_ASSOCIATION_ASSIGN類似屬性裡面的weak關鍵字,
     因為這裡的observer是ViewController,而在ViewController在外面又持有p物件,
     所以為了防止引用迴圈,所以p物件繫結observer的時候使用OBJC_ASSOCIATION_ASSIGN
     */
    objc_setAssociatedObject(self, (__bridge const void *)@"bindObserver", observer, OBJC_ASSOCIATION_ASSIGN);
    
    //6.新增getter,這一步是為了能獲取到修改屬性前的舊值
    objc_setAssociatedObject(self, (__bridge const void *)@"getter", keyPath, OBJC_ASSOCIATION_COPY_NONATOMIC);
複製程式碼

接下來我們還需要提供setMoney:方法的實現,裡面包括了舊值的獲取,呼叫Person類的setter去修改屬性,獲取監聽器去呼叫監聽方法:

/* 子類新增的方法的實現
 * id self 方法呼叫者(必不可少)
 * SEL _cmd 方法編號(必不可少)
 * newValue 就是呼叫該方法時,傳遞的引數,這裡就是表示即將給money屬性賦值的新值
 */
void setMoney(id self, SEL _cmd, int newValue) {
    //1、獲取舊值
    NSString *getterName = objc_getAssociatedObject(self, (__bridge const void *)@"getter");
    //儲存子類型別(OTKVONotifying_Person)
    Class class = [self class];
    //self的”is a“指向父類(Person)
    object_setClass(self, class_getSuperclass(class));
    //呼叫原類get方法,獲取oldValue
    //
    /*
     int 代表返回型別/物件型別需要加*號,如NSString *(*)(id, SEL)
     (*)代表函式指標,相當於block的(^)
     (id, SEL)是引數列表,引數列表可以傳多個。id是訊息接收方,這裡是Person類,SEL是需要呼叫的方法選擇器,也就是這裡的NSSelectorFromString(getterName)
     */
    int oldValue = ((int (*)(id, SEL))objc_msgSend)((id)self, NSSelectorFromString(getterName));
    
    //self的”is a“指向子類(OTKVONotifying_Person)
    object_setClass(self, class);
    
    /* 2、呼叫Person的setter去修改money的值
         結構體的宣告:
         struct objc_super {
             id receiver;
             Class super_class;
         };
         receiver: 型別為id的指標。指定類的例項。
         super_class: 指向Class資料結構的指標。 指定要訊息的例項的父類。
     */
    struct objc_super person = {
        self,
        class_getSuperclass([self class])
    };
    objc_msgSendSuper(&person, _cmd, newValue);
    
    //3、通知監聽者(傳遞新舊值)
    //3.1、通過物件拿到監聽者
    id observer = objc_getAssociatedObject(self, (__bridge const void *)@"bindObserver");
    //3.2、給observer傳送訊息(這裡一定要傳引數,不然會崩潰的)
    objc_msgSend(observer, @selector(observeValueForKeyPath:ofObject:change:context:), @"money", self, @{@"ot_old":@(oldValue),@"ot_new":@(newValue)},nil);
}
複製程式碼

這裡解釋一下objc_super結構體。它是這樣宣告的:

 struct objc_super {
    id receiver;
    Class super_class;
 };
 //receiver: 型別為id的指標。指定類的例項。
 //super_class: 指向Class資料結構的指標。 指定要訊息的例項的父類。
複製程式碼

來到這裡我們就實現了KVO的基本功能了,詳細的解釋已經解除安裝註釋裡面了,我們回到ViewController去驗證一下能否監聽到,我們現在點選一下螢幕,列印結果如下:

2018-04-25 15:38:19.594518+0800 KVO[6478:1850597] <OTKVONotifying_Person: 0x600000009bb0>物件的money屬性被修改了。----{
    "ot_new" = 1;
    "ot_old" = 0;
}
2018-04-25 15:38:20.290300+0800 KVO[6478:1850597] <OTKVONotifying_Person: 0x600000009bb0>物件的money屬性被修改了。----{
    "ot_new" = 2;
    "ot_old" = 1;
}
複製程式碼

為此我們就能監聽到屬性的變化,並獲取到舊值跟新值。當然跟原生的還差很遠,但是不妨礙我們對KVO的底層理解。

附上原始碼供參考。

參考

相關文章