iOS底层(十二)-KVO探索

337 阅读7分钟

一、KVO简介

KVO全称是 key-value Observing. 就是我们日常中经常用到的观察者模式.

在之前的KVC探索里, 官方文档中有提到过这个机制. 它建立在理解KVC的基础上, 在键值编码后才能到达键值观察.

二、KVO初探

首先来建立一个类用于观察:

@interface Person : NSObject
@property (nonatomic, copy) NSString *name;
@end

@interface Student : NSObject
@property (nonatomic, copy) NSString *name;
@end

@interface ViewController ()
@property (nonatomic, strong) Person  *person;
@property (nonatomic, strong) Student *student;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person  = [Person new];
    self.student  = [Student new];

    [self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
    [self.student addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];

}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{

    NSLog(@"ViewController - %@",change);
}

@end
 

添加观察着的前面几个参数很好理解, 这里来看一下context. 官方文档中说明, 如果使用了相同的keyPath, 不便于区分, 这个时候使用context会更直接一些.

再来到观察者实现. 一般我们会在观察者实现中来写自己想要的代码逻辑. 但是有一个问题: 当有两个对象有着同样的一个属性, 同时观察这个属性, 那么在观察着实现中我们为了区分是哪一个对象带来的观察着实现, 就会写下很多的ifelse判断, 这对于我们的开发来说, 是极大的不便的. 这个时候我们应该使用context会更加便捷.

2.1、context的使用

例如我们需要观察person的name属性, 那么我们应该对它用字符串匹配的方式来进行标记.

写入一个标记用来记录此时观察的是这个属性:

static void *PersonNameContext = &PersonNameContext ;

用一个指针变量来当作context. 那么观察着就应该改为:

[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:PersonNameContext];

此刻我们就可以直接通过context来判断观察着实现观察的是哪个对象的哪个属性, 这样会更加便利更加安全.

2.2、移除观察者

使用观察者有三个步骤:

  1. 添加观察者
  2. 观察者回调
  3. 在合适的地方移除观察者

假如我们在dealloc里面进行观察者销毁

- (void)dealloc{
    [self.person removeObserver:self forKeyPath:@"name"];
}

假如Person是一个单例, 我们在当前的控制器以及第二个控制器中添加观察者, 此时push到下一个控制器中, 在下一个控制器中, 上一个控制器还没有释放掉. 同样的操作观察者, 观察者就会执行两次.

假如第二个控制器没有移除操作,进入这个控制器后返回到第一个控制器, 会造成观察者没有移除. 此时第二个控制器被释放, 但是person还记录第二个控制器的观察者, 此时修改person的name属性, 会造成野指针.

2.3、观察者的启动开关

我们在开发中, 会碰到某些需求导致需要对某一个属性停止观察.此时我们一般都会去注释掉添加观察者移除观察者这两行代码. 这种办法是可行的, 但是还是会感觉到不是很便利.

在观察者中, 有一观察者的自动开关, 当我们把这个自动开关关闭后, 就可以手动调节是否对这个属性进行观察.

// 自动开关
+ (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key{
    return NO;
}
- (void)setName:(NSString *)name{
    [self willChangeValueForKey:@"name"];
    _name = name;
    [self didChangeValueForKey:@"name"];
}

此时若要开启观察, 我们直接将自动开关返回YES就可以.

2.4、观察者依赖

在某些情况下, 造成一个属性的变化会是因为另外某个属性的变化. 例如: 下载进度百分比的显示会因为 已下载大小与总大小的因素所影响.

给Person新加一些属性

@property (nonatomic, copy) NSString *downloadProgress;//进度
@property (nonatomic, assign) double writtenData;//写入
@property (nonatomic, assign) double totalData;//总量

- (NSString *)downloadProgress{
    if (self.writtenData == 0) {
        self.writtenData = 10;
    }
    if (self.totalData == 0) {
        self.totalData = 100;
    }
    return [[NSString alloc] initWithFormat:@"%f",1.0f*self.writtenData/self.totalData];
}

重写属性依赖:

+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
    
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"downloadProgress"]) {
        NSArray *affectingKeys = @[@"totalData", @"writtenData"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    }
    return keyPaths;
}

这样当我们对downloadProgress进行添加观察者后, 改变writtenData与totalData, 此时仍会观察和到downloadProgress变化.、

2.4、可变数组的KVO

当我们对一个可变数组进行观察的时候, 这个时候对数组进行addObject. 此时并不会走观察者. 因为可变数组添加成员并没有走set方法.

KVO对于可变数组有特殊的处理. KVO是建立在KVC的基础上的. 所以我们需要用另一种方法来对数组进行操作:

[[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"1"];

用这种方法来访问可变数组. 此时就会观察到可变数组的观察回调. 并且回调的change中的NSKeyValueChangeKey为2. NSKeyValueChangeKey进去后可以看到代表的是 NSKeyValueChangeInsertion, 也就是插入变化. 此时它走的是可变数组的- (void)insertObject:(id)object inDateArrayAtIndex:(NSUInteger)index; 方法

三、KVO的底层

在官方文档中, 有一段写的是KVO的实现细节, 大致意思是:

自动键值观察是使用称为isa-swizzling的技术实现的。
该isa指针,顾名思义,指向对象的类,它保持一个调度表。该分派表实质上包含指向该类实现的方法的指针以及其他数据。
在为对象的属性注册观察者时,将修改观察对象的isa指针,指向中间类而不是真实类。结果,isa指针的值不一定反映实例的实际类。
您永远不要依靠isa指针来确定类成员。相反,您应该使用该class方法确定对象实例的类。

也就是创建观察的时候, 底层创建来一个处于中间的动态类, 用来处理观察者的实现. 修改的是原来的类生成的对象的isa.

改动前: person -> Person -> 元类

改动后: person -> NSKVONotifying_Person -> 根元类

来调试看一下它生成的中间动态类:

推测是生成了一个 NSKVONotifying_类名 的中间类.

那么这个中间类与原来的类是什么关系呢? 来写一个方法遍历所有的子类:

- (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);
}

调用结果:

表明新生成的中间动态类就是原来的类的子类.

3.1、NSKVONotifying_xxx

我们知道类的结构是 isa superclass cache_t bit,观察者主要是通过属性的setter的方法来实现的, 所以我们研究这个中间类也是从方法上入手.

写一个方法来遍历当前类中的所有方法:

- (void)printClassAllMethod:(Class)cls{
    NSLog(@"-----");
    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是重写了class方法. dealloc释放. _isKVOA观察者相关.

由此想起来无论在观察者添加的前后, 通过 object_getClassName([Person class]) 得到的类都是Person. 所以中间类中所包含的class方法应该就是为中间类做的一层伪装, 返回的依旧是Person()

再来看一下移除观察者之后, isa是变化成什么:

当移除观察者后, 此时对象当isa会重新指向之前的类.

那么移除后中间类是否被销毁类呢? 答案是否定的.

依旧存在, 方便再一次的调用.

四、总结:

KVO原理:

  1. 动态生成一个子类: NSKVONotifying_xxx, 对象的isa指向这个类
  2. 观察setter
  3. NSKVONotifying_xxx 重写了 set class dealloc _isKVOA方法
  4. 移除KVO后, 对象的isa重新指向原来的类
  5. 移除后NSKVONotifying_xxx依旧存放, 方便下一次调用.