iOS探索 -- KVO 的原理分析

1,222 阅读11分钟

什么是 KVO?

KVO (key-value-observing) 是一种 键值观察 机制, 它允许当前对象去观察目标对象的某个属性的变化; 当被观察对象的属性发生变化后, 会通过特定方法通知观察者对象属性变化的一些情况内容, 观察者对象拿到变化情况后做出相关操作。

关于 KVO 的一些详细介绍可以去 苹果官方文档 了解一下, 这里就不做过多介绍了。

KVO 的初探

进行探索之前, 首先看看 KVO 是怎么使用的:

 // 方法
 - (void)addObserver:(NSObject *)observer
          forKeyPath:(NSString *)keyPath
             options:(NSKeyValueObservingOptions)options
             context:(nullable void *)context;
 // 日常使用
 [self.person addObserver:self
               forKeyPath:@"name"
                  options:(NSKeyValueObservingOptionNew)
                  context:NULL];
  • self.person : 也就是方法调用者, 就是被观察属性 name 的对象 (被观察者)
  • observer : 观察者, 上面例子中的 self
  • options : 观察的模式, 是个枚举类型, 总共有 4 种观察模式: NSKeyValueObservingOptionNewNSKeyValueObservingOptionOldNSKeyValueObservingOptionInitialNSKeyValueObservingOptionPrior
  • context : 在上面的使用中传的是一个 NULL , 因为它的参数类型是 void * 是一个指针 (虽然传 nil 也没有问题, 但是严格来说的话应该传 NULL 吧)。我们平时好像没有怎么关注过它, 它是用来干什么的呢, 来看看官方文档。

1. context 是什么

首先来看看官方文档里关于 context 的相关介绍:

 The context pointer in the addObserver:forKeyPath:options:context: message contains arbitrary data that will be passed back to the observer in the corresponding change notifications. You may specify NULL and rely entirely on the key path string to determine the origin of a change notification, but this approach may cause problems for an object whose superclass is also observing the same key path for different reasons.
 A safer and more extensible approach is to use the context to ensure notifications you receive are destined for your observer and not a superclass.
 // 大概意思是
 // addObserver:forKeyPath:options:context:message中的上下文指针包含相应的更改通知中将要传递回观察者的任意数据。您可以指定NULL,并完全依赖键路径字符串来确定更改通知的来源,但这种方法可能会导致其父类出于不同原因也在观察同样的键路径的情况出现问题。
 // 一种更安全、更可扩展的方法是使用上下文来确保您收到的通知是针对您的观察者的,而不是父类的。

大概就是在多个观察者的情况下, 有可能不同的类 (上面说的是父类) 拥有相同的 keyPath , 这样在修改信息回来的时候就会导致无法判断到底是那个被观察对象的属性发生了改变。

通过使用 context 字段, 可以更清楚的辨别当前的通知信息是发送给哪一个 观察者 的。当然, 如果不存在上述注释中说的那种情况下, 使用 NULL 是不会有影响的。

2. 移除观察者

 // Asking to be removed as an observer if not already registered as one results in an NSRangeException.
 // 如果尚未注册为观察员,则请求以观察员身份删除会导致NSRangeException。
  1. 在没有添加过观察者的情况下去调用移除观察者方法会造成程序崩溃, 必须添加过之后才能调用移除方法
 // ensuring properly paired and ordered add and remove messages, and that the observer is unregistered before it is freed from memory.
 // 应该确保正确配对并按顺序添加和删除消息,并确保在从内存中释放观察者之前将其注销。
  1. 添加观察者和移除观察者必须是 成对出现并且有先后顺序的 , 也就是在不需要使用后必须保证观察者被移除掉, 下面来举个例子说明一下:
 // 单例类 Person
 @interface Person : NSObject
 @property (nonatomic, copy) NSString *name;
 + (instancetype)shareInstance;
 @end
 // 控制器 A
 [self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
 // 控制器 B (A 跳转到 B)
 [self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];

具体步骤:

  1. 首先有一个单例 Person , 在 控制器 A 中添加观察者观察 name 属性, 控制器 A 跳转到 控制器 B
  2. 控制器 B 也添加观察者观察 name 属性, 在 B 返回上一级页面 (也就是被销毁的时候) 时应该调用 remove 方法将观察者移除掉
  3. 如果 B 在返回的时候没有移除观察者, 在 A 再次修改 name 属性的时候就会引发崩溃触发野指针异常 Thread 1: EXC_BAD_ACCESS (code=EXC_I386_GPFLT)

3. KVO 的开关

 // 自动开关
 + (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key{
     return YES;
 }

KVO 中提供了这样一个方法, 可以通过修改方法的返回结果来决定是否启用 KVO 监听。如果返回结果是 YES 就会启用 KVO 监听, 如果返回为 NO 就会关闭 KVO 的监听。所以假如需要针对某个 keyPath 做特殊处理的话, 可以通过对这里的 key 进行判断处理。

另外, 如果关闭了这里的开关, 也可以自己手动调用相关方法来达到发送通知的目的, 代码如下:

 //
 - (void)setName:(NSString *)name{
     [self willChangeValueForKey:@"name"];
     _name = name;
     [self didChangeValueForKey:@"name"];
 }

4. KVO 组合因素观察

有些时候我们会存在这种情况, 就是需要观察多个因素的变化来确定一个因素的结果, 比如: 通过观察下载总量的变化 和 当前的下载数量 来得出下载进度。KVO 给我们提供了一种方法来方便处理这种情况:

 // 下载进度 -- writtenData/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;
 }

在添加观察者的时候可以通过上面的方法给当前添加属性关联的因素, 当 totalDatawrittenData 的值发生改变时都会触发 downloadProgress 的变化通知。

在上面的基础上还有一步需要做才能完美, 那就是在 downloadProgressgetter 方法里做一些修改:

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

这样就能够在 totalDatawrittenData 发生改变时触发 downloadProgress 的监听通知, 并且返回的值是使用新的 writtenData / totalData 获得的新值。

但是这样做存在一个问题, 那就是如果你要收动去修改 downloadProgress 的话就不允许了, 因为在获取 downloadProgress 的时候永远都是通过 totalData 和 writtenData 计算出来的值。

5. KVO 观察可变数组

如果 KVO 观察的目标是一个可变数组的话, 会和上面稍微有一些区别:

//
[self.person.mArray addObject: @"123"];

原因在于数组在使用 addObject: 方法添加对象时是无法触发 setter 方法的, 也就是无法触发 willChangeValueForKey:didChangeValueForke:y 方法, 那么要如何解决呢。

可以通过调用 mutableArrayValueForKey: 方法获取到数组, 然后再进行 addObject: 操作, 这时就可以出发 KVO 了。代码如下:

[[self.person mutableArrayValueForKey:@"mArray"] addObject: @"123"];

这一方面的内容在上一篇 KVC 原理分析 中的容器类也有提到, 有兴趣的可以去看看。

KVO 的原理

前面探索了 KVO 的一些用法, 接下来来看看 KVO 的实现原理相关内容:

首先来看一下官方文档对于 KVO 实现的相关介绍内容

Automatic key-value observing is implemented using a technique called isa-swizzling.
自动键值观察是使用称为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.
顾名思义,isa指针指向维护调度表的对象类。此调度表本质上包含指向类实现的方法的指针以及其他数据。

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. As a result the value of the isa pointer does not necessarily reflect the actual class of the instance.
当一个观察者为一个对象的属性注册时,被观察对象的isa指针会被修改,指向一个中间类而不是真正的类。因此,isa指针的值不一定反映实例的实际类。

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.
永远不要依赖isa指针来确定类成员身份。相反,您应该使用class方法来确定对象实例的类。

根据文档的解释可以了解到:

  1. KVO 是通过 isa-swizzling 方式实现的
  2. 在对对象的属性添加观察者后, 系统会将被观察对象的 isa 指向一个中间类

接下来通过代码来验证这一过程:

为了方便验证, 先实现两个方法

// 遍历类的所有方法
- (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);
}
// 遍历类和子类
- (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);
}

1. 动态子类

// 打印类
[self printClasses:[Person class]];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
[self printClasses:[Person class]];
// 最后结果
    // 添加观察者前
    classes = (
    	Person,
    	Student
    )
    // 添加观察者后
    classes = (
    	Person,
    	"NSKVONotifying_Person",
    	Student
	)
// 通过 `object_getClass()` 打印
    // 添加观察者前
        Person
    // 添加观察者后
        NSKVONotifying_Person

可以发现, 在添加过观察者后类 Person 多了一个子类 NSKVONotifying_Person 。然后如果在添加完观察者后如果通过 object_getClass 打印当前对象的类, 也会得到 NSKVONotifying_Person 类。

object_getClass 的实现就是 isa 的指向:

Class object_getClass(id obj)

{

if (obj) return obj->getIsa();

else return Nil;

}

也就是说在添加完观察者后被观察对象的 isa 指向了一个动态添加的类 NSKVONotify_Person , 然后跟打印逻辑也可以发现, 这个动态添加的类是元类的类 Person 的子类。

再来看一看这个新增加的子类的方法, 试着判断一下它的内部到底做了什么:

// 方法打印
[self printClassAllMethod:[Person class]];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
[self printClassAllMethod:NSClassFromString(@"NSKVONotifying_Person")];
// 打印结果
	// Person 类
	.cxx_destruct-0x10d067540
    name-0x10d067510
    setName:-0x10d0674c0
    // 动态添加的类
    setName:-0x7fff207a3203
    class-0x7fff207a1d0d
    dealloc-0x7fff207a1abd
    _isKVOA-0x7fff207a1ab5

发现在动态子类里面实现了 4 个方法:

  1. setName: 我们知道 KVO 的监听是通过 setter 方法进行监听的, 所以这里重写的 setName 方法应该就是为了实现 KVO 的相关细节的。比如: 添加我们前面说到的 willChangeValue:didChangeValue 方法
  2. class : 在生成动态子类之后假如我们通过去打印对象的类 (也就是 self.person.class) 会发现得到的结果仍然是 Person 。这时候的 isa 其实是指向了动态子类的, 所以系统通过重写 class 方法来返回原来的类, 应该是为了不影响我们的正常流程。
  3. dealloc : 重写 dealloc 方法为了在移除观察者之后将这一切恢复到原来的样子

2. 移除观察者

上面说到在移除观察者之后, isa 的指向会恢复到原来的样子, 这里来进行验证一下:

// 移除观察者
// 打印类
[self printClasses:[Person class]];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
[self.person removeObserver:self forKeyPath:@"name"];
[self printClasses:[Person class]];
// 得到结果
    // 添加观察者前
    classes = (
    	Person,
    	Student
    )
    // 移除观察者后
    classes = (
    	Person,
    	"NSKVONotifying_Person",
    	Student
	)
// 通过 `object_getClass()` 打印
    // 添加观察者前
        Person
    // 移除观察者后
        Person

可以发现:

  1. 观察者被移除之后, isa 恢复了正常情况下的指向
  2. 虽然观察者被移除了, 但是生成的动态子类 NSKVONotifying_Person 仍然存在。这是为什么呢? 这也算是一种缓存机制吧, 因为动态生成类本身会有很大的开销, 假如在移除动态子类后又进行添加观察者, 那岂不是又要动态生成一次。保留动态子类的话对于这种情况来说就降低了成本

3. 成员变量添加观察者

通过上面的研究我们知道了 KVO 的观察者模式其实主要是通过重写 setter 方法来实现的, 但是对于成员变量来说它默认是没有 setter 方法的。 所以如果要通过 KVO 去观察一个成员变量, 需要给他添加一个 setter 方法。

最后

KVO 的原理分析就到这里了, 东西不是很多, 所以内容可能不是很好, 后面会继续研究一下 自定义KVO 的相关内容。下面总结一下关于 KVO 的几点内容:

  1. KVO 与 KVC 的关系是非常密切的, 所以研究 KVO 之前可以先去熟悉一下 KVC 的相关内容 (这一点在官方文档也有提到)
  2. KVO 本质上是对观察属性的 setter 方法的监听, 所以对于成员变量需要去添加相应的 setter 方法
  3. 对于可变数组等容器类对象, 直接使用 addObject: 添加数据并不能触发监听, 需要做出特殊处理 (KVC 相关)
  4. KVO 的原理是动态生成了一个子类, 将被观察对象的 isa 指向生成的子类, 然后在子类中还重写了一些相关方法等
  5. 移除观察者后生成的动态子类并没有被销毁, 而是保留了下来备用