OC底层原理(18)KVO

228 阅读8分钟

1. KVO定义

1.1 KVO简介

KVO 全称是 Key-value Observing,翻译过来就是:键值观察官文中对KVO进行了详细介绍,键值观察是一种机制,它允许对象在其他对象的指定属性发生更改时收到通知。它对于应用程序中模型层和控制器层之间的通信特别有用。

要使用KVO,首先必须确保被观察对象符合KVO。通常,如果您的对象继承自NSObject并且您以通常的方式创建属性,则您的对象及其属性将自动符合KVO

Xcode中找到 KVO的定义,可以看到是对NSObject的扩展来实现的:

Xnip2022-07-25_20-49-41.png

1.2 KVO提供的API

  • 监听注册

    使用方法addObserver:forKeyPath:options:context:向被观察对象注册观察者。必须执行以下步骤才能使对象能够接收KVO兼容属性的键值观察通知:

    - (void)addObserver:(NSObject *)observer 
             forKeyPath:(NSString *)keyPath 
                options:(NSKeyValueObservingOptions)options 
                context:(nullable void *)context;
    

    观察者指定一个选项参数options和一个上下文指针context来管理通知的各个方面

  • 接收通知

    在观察者内部实现observeValueForKeyPath:ofObject:change:context:以接受更改通知消息

     - (void)observeValueForKeyPath:(nullable NSString *)keyPath 
                           ofObject:(nullable id)object 
                             change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change
                            context:(nullable void *)context;
    
  • 移除监听

    使用方法- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;来移除观察者

    - (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
    

2. KVO使用

2.1 context参数

参考 官文,关于context相关定义:

Xnip2022-07-25_21-02-59.png

注册方法addObserver:forKeyPath:options:context:中的context可以传入任意数据,并且可以在监听方法中接收到这个数据。

  • context作用:标签-区分,可以更精确的确定被观察对象属性,用于继承、 多监听;也可以用来传值。 说白了就是:当有多个监听时,在监听回调方法observeValueForKeyPath:ofObject:change:context: 中你可以通过 context 直接判断你监听的属性,不用在通过 objectkeyPath 来判断

  • 苹果的推荐用法:用context来精确的确定被观察对象属性,使用唯一命名的静态变量的地址作为context的值。可以为整个类设置一个context,然后在监听方法中通过objectkeyPath来确定被观察属性,这样存在继承的情况就可以通过context来判断;也可以为每个被观察对象属性设置不同的context,这样使用context就可以精确的确定被观察对象属性。 如:

  • context优点:嵌套少、性能高、更安全、扩展性强。

  • context注意点:

    • 如果传的是一个对象,必须在移除观察之前持有它的强引用,否则在监听方法中访问context就可能导致Crash
    • 空传NULL而不应该传nil

2.2 移除观察者

关于KVO,网上有人说iOS9以后就不用移除了,这样说对吗,我们打开 方文 ,可以找到下面这段内容:

Xnip2022-07-25_21-32-17.png

简单解释一下就是:当观察者被释放时它并不会主动移除自己。观察对象还是会继续发送通知,对已经释放的内存发送消息,会抛出异常导致Crash。因此观察者要在释放前移除它们自己,下面用示例来说明

创建个 YJDetailViewControllerpush 到这个控制器:

static void *YJPersonNameContext = &YJPersonNameContext;
@interface YJDetailViewController ()
@property (nonatomic, strong) YJPerson *person;
@end

@implementation YJDetailViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    self.title = @"详情页";
    static int enterCount = 0;
    enterCount++;
    NSLog(@"第 %d 次进来 YJDetailViewController", enterCount);
    // 注意,这里 self.person 是个单例
    self.person = [YJPerson shareInstance];
    [self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:YJPersonNameContext];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"%s", __func__);
    self.person.name = @"ZhangSan";
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if (context == YJPersonNameContext) {
        NSLog(@"change = %@", change);
    }
}
- (void)dealloc  {
//    [self.person removeObserver:self forKeyPath:@"name" context:YJPersonNameContext];
}
@end

pop离开页面,再次push进入,这时touchesBegan时程序报错:

Xnip2022-07-25_21-57-33.png

正如苹果所说:当观察者(这里是YJDetailController) 被释放时它并不会主动移除自己。观察对象(这里是self.person单例)还是会继续发送通知,对已经释放的内存发送消息,会抛出异常导致Crash

dealloc 方法中的注释松开:

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

再次运行,看效果:

Xnip2022-07-25_22-10-15.png

这样就ok了

2.3 KVO 的自动触发

可以在被观察对象的类中重写+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key方法来控制KVO的自动触发。

假如我们只允许外界观察YJPersonname属性,其它属性都不允许观察,可以在YJPerson类如下操作。这样外界就只能观察name 属性,即使外界注册了对YJPerson对象其它属性的监听,那么在属性发生改变时也不会触发KVO

// 返回值代表允不允许触发 KVO
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    BOOL automatic = NO;
    if ([key isEqualToString:@"name"]) {
        automatic = YES;
    }
    return automatic;
}

测试:

  • YJPerson中,未按上面实现automaticallyNotifiesObserversForKey

  • Xnip2022-07-25_22-25-09.png

  • YJPerson中,按上面实现automaticallyNotifiesObserversForKey

    Xnip2022-07-25_22-26-29.png

2.4 KVO的手动触发

为了尽量减少不必要的触发通知操作,或者当多个更改同时具备的时候才调用属性改变的监听方法。

可以通过在赋值的前后手动调用willChangeValueForKey:didChangeValueForKey:两个方法来手动触发KVO,示例:

// YJPerson.m
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    return NO;
}

// YJDetailViewController.m
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    NSLog(@"%s", __func__ );
    [self.person willChangeValueForKey:@"nick"];
    self.person.nick = @"san san";
    [self.person didChangeValueForKey:@"nick"];
}

运行代码,输出:

Xnip2022-07-25_22-33-07.png

2.5 一对多关系

有些情况下,一个属性的改变依赖于别的一个或多个属性的改变,也就是说当别的属性改了,这个属性也会跟着改变。

比如我们在YJPerson类中添加fullName属性,该属性的改变依赖于namenick属性的改变。如下:

// YJPerson.m
- (NSString *)fullName {
    return [NSString stringWithFormat:@"%@-%@", self.name, self.nick];
}

观察者监听fullName,当namenick属性值改变时,观察者也应该被通知。

重写keyPathsForValuesAffectingValueForKey方法,来指明fullName依赖于namenick

// YJPerson.m 
+ (NSSet<NSString *>)keyPathsForValuesAffectingValueForKey:(NSString *)key
{
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"fullName"]) {
        NSArray *affectingKeys = @[@"name", @"nick"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    }
    return keyPaths;
}

YJDetailViewController 观察 fullName属性:

- (void)viewDidLoad {
    [super viewDidLoad];
    [self.person addObserver:self forKeyPath:@"fullName" options:NSKeyValueObservingOptionNew context:YJPersonFullNameContext];
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    self.person.name = @"ZhangSan";
    self.person.nick = @"san san";
}

运行代码,输出:

Xnip2022-07-25_22-49-42.png

设置 name 时,nick 还是空,所以第一次 输出new = "ZhangSan-(null)"

2.6 对集合的观察

对于集合的KVO,我们需要了解的一点是,KVO旨在观察关系(relationship)而不是集合。对于不可变集合属性,我们更多的是把它当成一个整体来监听,而无法去监听集合中的某个元素的变化;对于可变集合属性,实际上也是当成一个整体,去监听它整体的变化,如添加、删除和替换元素。

监听集合整体的变化,见下图案例:

Xnip2022-07-25_23-08-43.png

对于数组的 增、删、替换 观察都必须通过 KVC-mutableArrayValueForKey 方法才能触发回调

细心的你可能已经发现了,上面输出结果中 kind 值不一样,直接赋值触发回调中kind = 1,添加元素触发回调中kind = 2。这是因为,KVO机制能在集合改变的时候把详细的变化放进change字典中。kind说明:

typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
    NSKeyValueChangeSetting = 1,
    NSKeyValueChangeInsertion = 2,
    NSKeyValueChangeRemoval = 3,
    NSKeyValueChangeReplacement = 4,
};

补充:集合(Set)也有一套对应的方法来实现集合代理对象,包括无序集合与有序集合;而字典则没有,对于字典属性的监听,还是只能作为一个整理来处理。

3. KVO原理分析

我们先打开 官文 看一下它是怎么介绍KVO的实现的:

Xnip2022-07-25_23-21-31.png

这里提到KVO的实现用到了isa-swizzling技术,isa会指向一个中间类。因此,isa 指针的值不一定反映实例的实际类。永远不要依赖isa指针来确定类成员关系。相反,您应该使用类方法来确定对象实例的类。

3.1 KVO 动态生成子类

在添加观察前后分别通过object_getClassName获取self.person类名:

Xnip2022-07-25_23-28-04.png

在添加观察后self.person的类名变成了NSKVONotifying_YJPerson,那么它和YJPerson是什么关系呢?运行项目,使用lldb 调试

Xnip2022-07-26_09-55-36.png

由调试结果可以看出中间类NSKVONotifying_YJPersonsuperCls指向了 YJPerson,即 NSKVONotifying_YJPersonYJPerson 的子类

3.1 动态子类中有哪些方法

使用 runtime API 来获取类的方法列表

// 遍历方法-ivar-property
- (void)printClassAllMethod:(Class)cls {
    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);
}

运行代码,输出:

Xnip2022-07-26_10-28-25.png

  • setNickName 是重写的父类YJPerson 的方法

  • class 重写 class 返回父类 YJPerson,这样在外部看来,是没有变化的

  • _isKVOA就是判断当前的类是不是KVO创建的。

    Xnip2022-07-26_10-30-20.png

  • dealloc中实例变量的isa重新指回到原来的YJPerson

总结

  • Objective-C依托于强大的runtime机制来实现KVO
  • 当我们第一次观察某个对象的属性时,runtime会创建一个中间类继承自观察对象所属类
  • 在这个中间类中,它会重写所有被观察的keysetter,然后将观察对象isa指针指向中间类(这个指针告诉Objective-C运行时某个对象到底是什么类型的)。所以观察对象神奇地变成了中间类的实例。
  • 中间类setKey方法中,调用父类 的setKey方法进行赋值,同时发出改变回调(即kvo的监听回调)
  • 在进行监听移除后,观察对象isa恢复到原来的类上