使用Runtime来实现自己的KVO

343 阅读9分钟

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之前,我们最好先去修改一下项目配置:

将红框修改为NO,这样我们使用runtime函数的时候,就会有代码补全了。 知道KVO的原理后,我们尝试去实现我们自己的KVO方法。我们进去看看addObserver:forKeyPath:options:context:方法看看,然后发现该方法是在NSObject的一个分类里面声明的:
所以,我们就有了思路,我们也给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类型:

一旦添加了监听之后,p就变成了OTKVONotifying_Person类型了:

所以,到这一步,我们已经成功的修改了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的底层理解。

附上源码供参考。

参考