【iOS】KVO

发布于:2025-08-03 ⋅ 阅读:(18) ⋅ 点赞:(0)

KVO初识

KVO全称为Key-Value observing,翻译为键值观察,这是一种机制,其允许其他对象的指定属性的更改通知给对象。

这里我们首先要去理解KVC,再来理解KVO。这是由于键值观察是建立在键值编码的基础上的,下面附上笔者学习KVC的笔记:【iOS】KVC

在iOS日常开发过程中,我们会使用KVO来监听对象属性的变化,作出相应;也就是说当指定的被观察的对象的属性被修改之后,KVO会自动通知相应的观察者。在之前的学习中,还学习过NSNotificatioCenter,那么这两者又有什么区别呢?

  • 相同点
    • 二者实现的原理都是观察者模式,都是用于监听
    • 都可以实现一对多的操作
  • 不同点
    • KVO只可以用于监听对象属性的变化,并且属性名都通过NSString来查找,编译器并不会检查对错与补全,纯手敲会比较容易出错
    • NSNotificatioCenter的发送监听的操作我们可以控制,KVO由系统控制
    • KVO可以记录新旧值的变化

KVO的使用

KVO的基本使用

下面我们先来看看KVO使用的三部曲:

  • 注册观察者
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld) context:NULL];//这里observer:添加的监听者的对象,当监听的属性发生改变时会通知这个对象。 keyPath:监听的属性,不能传nil。 options:指明通知发出的时机以及change中的键值。 context:是一个可选的参数,可以传任何数据。

options:

typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
    NSKeyValueObservingOptionNew = 0x01,//更改前的值
    NSKeyValueObservingOptionOld = 0x02,//更改后的值
    NSKeyValueObservingOptionInitial = 0x04,//观察最初的值(在注册观察服务时会调用一次触发方法)
    NSKeyValueObservingOptionPrior  = 0x08 //分别在值修改前后触发方法(即一次修改有两次触发)
};
  • 实现回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:@"name"]) 
      NSLog(@"%@", change);
}
  • 移除观察者
[self.person removeObserver:self forKeypath:@"name"];

context的使用

在我们平时的使用中,我们一般都将 其设置为NULL,那么其究竟由什么用呢?这里我们来探究一下。

context参数主要作用其实是为KVO回调提供一个标识符或标记,有助于区分同一属性的不同观察者或在多个地方注册的同一个观察者

在这里插入图片描述

这是在Apple官方文档中对于context的说明,从其解释来看,使用context是一种更安全、可扩展的方法来确保收到的通知是发送给我们观察者而不是superClass

如果说当我们的父类中已经有了一个name属性,而子类中也有一个name属性,这两者都注册对name的观察,这个时候如果仅仅通过keyPath已经无法区分是哪一个name发生了变化,这个时候就可以使用context

//定义context
static void *PersonNameContext = &PersonNameContext;
static void *StudentNameContext = &StudentNameContext;
//注册观察者
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:PersonNameContext];
[self.child addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:StudentNameContext];
//KVO回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if (context == PersonNameContext) {
        NSLog(@"%@", change);
    } else if (context == StudentNameContext) {
        NSLog(@"%@", change);
    }
}

移除通知的必要性

或许在我们日常的开发中,我们有时候觉得是否移除了通知都无关痛痒,但是不移除通知会带来潜在的风险

这里我们将Person类以单例的形式创建,这个时候如果没有移除通知我们来看看会发生什么呢?

#import "LGDetailsViewController.h"

@interface LGDetailsViewController ()

@property (nonatomic, strong) Person* person;

@end

@implementation LGDetailsViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor orangeColor];
    self.person = [Person sharedInstance];
    [self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld) context:NULL];
    
    
}

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

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    self.person.name = @"wahaha";
    //[self.navigationController popViewControllerAnimated:YES];
}

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

这里我们pop出这个界面再push进这个界面,这个时候就会报错:

在这里插入图片描述

这是由于没有移除观察,单例对象依旧存在,再次进来点击屏幕就会出现野指针报错。

KVO观察可变数组

这里我创建一个Student类,其中有一个可变数组muArr,现在观察他:

- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor whiteColor];
    self.detaiView = [[LGDetailsViewController alloc] init];
    self.stu = [[Student alloc] init];
    [self.stu addObserver:self forKeyPath:@"muArr" options:NSKeyValueObservingOptionNew context:NULL];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.stu.muArr addObject:@"1"];
    NSLog(@"点击屏幕");
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:@"muArr"]) {
        NSLog(@"%@", change);
    }
}

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

这时候点击屏幕是不会打印的,这是为什么呢?

  • 首先我们要知道KVO是建立在KVC的基础上的,而可变数组直接添加方法是不会调用Setter方法的
  • 在KVC官方文档中,针对可变数组的集合类型来说,访问集合对象需要通过调用mutableArrayValueForKey以达到将元素添加到可变数组中的目的

这里我们应该使用mutableArrayValueForKey方法:

[[self.stu mutableArrayValueForKey:@"muArr"] addObject:@"1"];

KVO原理

官方解释

这里我们先来看看Apple官方对于KVO的解释:

在这里插入图片描述

  • KVO使用的是isa-swizzling技术来实现的
  • isa指针指向维护分配表的对象的类,该分派表实质上包含指向该类实现的方法的指针以及其他数据
  • 在为对象的属性注册观察者时,将修改观察对象的isa指针,指向中间类而不是真实类。isa指针的值不一定反映实例的实际类
  • 您永远不应依靠isa指针来确定类成员身份。相反,您应该使用class方法来确定对象实例的类

代码实现

这里我们来看看注册观察者前后的isa的指向:

在这里插入图片描述

通过这两张图片我们可以看出,在观察者注册前后Student类并没有发生变化,但是实例对象的isa指向发生了变化。

那么动态生成的中间类NSKVONotifying_StudentStudent是一个什么关系呢?

我们在注册观察者前后来分别调用打印一下子类的方法,看看有什么结果:

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

当我们在注册观察者前后分别调用这个方法的时候,我们来看看打印结果:

在这里插入图片描述

这里我们可以发现NSKVONotifying_StudentStudent的子类。

在这里插入图片描述

动态子类的方法

下面我们来看看动态子类中有哪些方法,我们通过rutime-API来打印一下动态子类和观察类的方法:

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

这里我直接给出打印StudentNSKVONotifying_Student的结果:

在这里插入图片描述

这里我们可以看到,中间类重写了setmuArrclassdealloc这几个方法,并且添加了一个叫_isKVOA的方法,来区分是不是系统通过KVO自动生成的

观察者被移除isa指向

上述我们看了当注册观察者之后会生成一个中间类,那么当我们移除观察者之后,这里又会指回原本的类

如何预防KVO崩溃

导致KVO崩溃的原因

通常我们在KVO的日常使用中会遇到以下几个原因造成KVO崩溃:

  1. KVO添加次数和移除次数并不匹配:
    • 移除未注册的观察者,造成崩溃
    • 重复移除了多次,移除次数多于添加的次数,从而导致崩溃
    • 重复添加了多次,这种情况并不会导致KVO崩溃,但当观察对象发生改变的时候,会被观察多次
    • 添加和删除的顺序不一致会导致崩溃
  2. 添加了观察者,但是并没有实现observerValueForKeyPath:ofObject:context方法,这会导致崩溃
  3. 添加或者移除时keypath==nil,导致崩溃
  4. 被观察者提前释放,被观察者在dealloc的时候还注册着KVO,导致崩溃的产生

当我们面对后两种情况的时候:

  • SwizzleobserveValueForKeyPath:ofObject:change:context:方法,在调用原方法外包一层try catch就可以解决第二个的崩溃
  • swizzleaddObserver:forKeyPath:options:context:removeObserver:forKeyPath:removeObserver:forKeyPath:context方法,在里面对keypath判空或者直接在原方法外包装一次try catch就可以解决第三点

try catch:一种常见的防护崩溃而被广泛使用的错误处理机制

防止崩溃的方法

有很多中方法去预防KVO崩溃,这里记录我学习的预防崩溃的方法:

  1. NSObject创建一个分类,使用方法交换,实现自定义的BMP_addObserver:forKeyPath:options:context:BMP_removeObserver:forKeyPath:BMP_removeObserver:forKeyPath:context:BMPKVO_dealloc 方法,用来替换系统原生的添加移除观察者方法的实现。
  2. 使用一个自定义的对象,在该对象中使用{keypath : [observer1, observer2 , ...](NSHashTable)}结构的关系哈希表对keyPathobserver之间进行一个维护
  3. 在分类中自定义dealloc的实现,移除了多余的观察者

参考博客:iOS 开发:『Crash 防护系统』(二)KVO 防护

KVO遇到的问题

直接修改成员变量的值,会不会触发KVO?

不会触发KVO,首先,本质上*KVO是替换掉setter*方法的实现,所以只有通过set方法修改才会触发KVO

KVC修改属性是否会触发KVO呢

是会触发的,虽然我们知道KVC使用setValue:firKey不一定触发instance实例对象的setter方法,但是该方法在修改成员变量的值的时候会调用willchangevalueforkeydidchangevalueforKey两个方法,这会触发监听器的回调方法

[!IMPORTANT]

willchangevalueforKeydidchangevalueforKey是KVO的两个核心方法

KVO的具体实现流程

KVO的实现是依赖于OC运行时的动态特性,通过使用isa-swizzling技术动态的创建一个当前类的派生类NSKVONotifying_XXX,动态的修改当前实例对象的isa指针,使其指向派生类,并重写了setXXXclassdealloc这几个方法,并且添加了一个叫_isKVOA的方法,来区分是不是系统通过KVO自动生成的。在OC发送消息的时候,通过isa指针找到当前对象所属的类对象。类对象中保存着当前对象的实例方法,在向该对象发送消息的时候,实际上是发送到了派生类对象的方法。

这里要注意一点,当移除观察者之后,isa指针会指向原来的类,但是并不销毁中间类

在这里插入图片描述

子类继承父类的属性,属性改变时KVO是否可以监听?

由于继承关系Father <- Son <- KVOSOn,当监听一个父类属性keyPath的时候,Son实例同样也可以通过继承链找到父类的Setter方法,将其加入到KVOSon中去。


网站公告

今日签到

点亮在社区的每一天
去签到