iOS KVO详细总结,使用、Options、原理

「这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战」。

KVO

KVO,全程Key-Value observing,即键值观察,Apple官方文档,它可以将其他对象指定属性的更改通知给观察者,在iOS开发中,经常使用kvo监听属性的变化,并做出响应(例如UI刷新等)。 测试Demo地址

特点

  • 一对多
  • 只能监听对象属性的变化
  • 通过NSString查找,编写时不会查错补全
  • 发送通知由系统控制
  • 可以记录新旧值得变化

使用

一 注册观察者(addObserver:forKeyPath:options:context)

  • self.person 被观察对象
  • observer 响应对象
  • keyPath 观察的属性
  • options 定义观察选项,下面回详细讲
  • context 个人理解一个标记,用来区分在回调里区分通知来源,不使用则NULL
static void *NameContext = &NameContext;
self.person = CQPerson.new;
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NameContext];
[self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:NULL];
复制代码

二 在addObserver:forKeyPath:options:context函数监听观察通知回调

  • keyPath 属性
  • object 对象
  • change 新旧值集合,和注册的options有关
  • context 可根据context直接区分,例如不同对象的同名属性,当然用obj配合keyPath也可以
// object
- (void)observeValueForKeyPath:(**NSString** *)keyPath ofObject:(id)object change:(**NSDictionary**<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if (context == NameContext) {

    }
    NSLog(@"对象-%@,属性-%@",object, keyPath);
}
复制代码

三 移除观察者(removeObserver:forKeyPath:context)

  • 必须注册了才能移除,否则NSRangeException崩溃,可使用try
  • 注册和移除成对出现,如果不移除,释放后依然会有通知,可能导致野指针崩溃
  • 典型使用,在init或viewDidLoad中注册为观察者,在dealloc 中移除
[self.person removeObserver:self forKeyPath:@"name" context:NULL];
[self.person removeObserver:self forKeyPath:@"nickName" context:NULL];
复制代码

自动触发和手动触发

  • 默认为自动触发,通过重写automaticallyNotifiesObserversForKey可关闭
+ (BOOL)automaticallyNotifiesObserversForKey:(**NSString** *)key {
    return NO;
}
复制代码
  • 如果属性不可见或只读,通过公开函数间接调用私有set函数,可以触发
  • 如果属性不可见或只读,通过performSelector或IMP调用set函数,均可触发
  • 通过kvc修改属性名,可以触发
// 通过公开函数间接调用私有set函数,可以监听到*
[self.person reloadName];
// 通过performSelector 调用私有set函数,可以监听到*
[self.person performSelector:@selector(setName:) withObject:@"1"];
// 通过IMP 调用私有set函数,可以监听到*
SEL sel = NSSelectorFromString(@"setName:");
IMP imp = [self.person methodForSelector:sel];
void(*func)(id, SEL, **NSString** *) = (void *)imp;
func(self.person, sel, @"1");
// 简写
((void(*)(id, SEL, **NSString** *))[self.person methodForSelector:sel])(self.person, sel, @"1");
// 通过kvc直接修改属性名,会触发KVO
[self.person setValue:@"1" forKey:@"nickName"];
复制代码
  • 通过kvc直接修改私有成员变量,不会触发KVO
  • 通过公开函数间接修改私有成员变量,也不会触发KVO
  • 总之,对成员变量修改不会触发,只有属性的修改才能触发
// 通过kvc直接修改私有成员变量,不会触发KVO
[self.person setValue:@"100" forKey:@"_nickName"];
复制代码
  • 关闭自动触发后,可手动触发
  • willChangeValueForKey 修改前触发
  • didChangeValueForKey 修改后触发
  • options为Old和New时两个函数必须全部调用才能触发
  • options为Prior时,只调用willChangeValueForKey可触发修改前的监听,必须两个都实现才能触发修改后的监听
- (void)setName:(**NSString** * _Nonnull)name {
    [self willChangeValueForKey:@"name"];
    _name = name;
    [self didChangeValueForKey:@"name"];
}
复制代码

合并触发

例如C属性依赖A和B,只要AB有一个变C就会变,可以重写keyPathsForValuesAffectingValueForKey函数

+ NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"mergeName"]) {
        NSArray *affectingKeys = @[@"name", @"nickName"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    }
    return keyPaths;
}
复制代码
  • 当然,在AB的set函数里调用C的set也是OK的

观察容器类

  • 对容器类添加数据是不会调用set函数的,所以也触发不了kvo
  • 需要使用对应的函数,例如数组mutableArrayValueForKey
[self.person.testArr addObject:@"1"]; // 不会触发
[[self.person mutableArrayValueForKey:@"testArr"] addObject:@"1"]; // 可以触发
复制代码

# NSKeyValueObservingOptions

一共有四种,可同时使用,分别是

  • NSKeyValueObservingOptionNew:在回调change里提供更改后的新值(key-@"new"),值被修改后触发回调

  • NSKeyValueObservingOptionOld:在回调change提供更改前的值(key-@"old"),值被修改后触发回调

  • NSKeyValueObservingOptionInitial:观察最初的值(在注册观察服务时会调用一次触发方法)

  • NSKeyValueObservingOptionPrior:分别在值修改前后触发方法(即一次修改有两次触发)在回调change里有key@"notificationIsPrior"

  • 注意如果使用了手动触发

    • options为Old和New时willChangeValueForKey/didChangeValueForKey必须全部调用才能触发
    • options为Prior时,只调用willChangeValueForKey可触发修改前的监听,必须两个都实现才能触发修改后的监听
  • 四种Option可同时使用

原理解析

  • 保证自动触发没有关闭
  • 注册前后打印isa指针的指向,可以发现注册后指向了派生类NSKVONotifying_CQPerson
(lldb) po object_getClassName(self.person)**
"CQPerson"
(lldb) po object_getClassName(self.person)**
"NSKVONotifying_CQPerson"

复制代码
  • 继续探究中间类,发现中间类重写了观察属性的setter方法、class、dealloc、_isKVOA方法,隐藏对象真实类信息
  • 重写dealloc做了一些 KVO 内存释放
  • 在setter方法内部调用了Foundation 的 _NSSetObjectValueAndNotify 函数
      • a) 首先会调用 willChangeValueForKey
      • b) 然后给属性赋值
      • c) 最后调用 didChangeValueForKey
      • d) 最后调用 observer 的 observeValueForKeyPath 去告诉监听器属性值发生了改变 .
- (void)printClass:(Class)cls {
    // 注册类的总数*
    int count = objc_getClassList(NULL, 0);
    NSMutableArray *mArray = [**NSMutableArray array];
    // 获取所有已注册的类
    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]];
            [self printClassAllMethod:cls];
        }
    }
    free(classes);
    NSLog(@"class = %@", mArray);
}
- (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);
}

复制代码
  • 移除KVO观察者之后,实例对象isa指向由中间类更改为原有类
  • 中间类从创建后,就一直存在内存中,不会被销毁

自定义KVO

  • 跟系统kvo流程基本一致,做一些优化处理,例如block回调
  • 大致步骤
    • 注册观察者以及响应
      • 1、验证set函数是否存在
      • 2、保存信息
      • 3、动态生成子类,重写classsetter方法
      • 4、在子类的set函数中向父类发消息,即自定义消息发送
      • 5、让观察者响应
    • 移除观察者
    • 1、更改isa指向为原有类
    • 2、重写子类的dealloc方法

参考facebookarchive/KVOController

おすすめ

転載: juejin.im/post/7066417372386557960