iOS底层原理20:KVO分析

这是我参与8月更文挑战的第9天,活动详情查看: 8月更文挑战

KVO简介

KVO全称为Key-Value Observing,意思就是键值观察KVO是一种机制,它允许允许其他对象的指定属性发生变化时,通知对象;想要了解键值观察,必须先要理解键值编码也就是KVC;

KVC键值编码,在对象创建完成后,可以动态的给对象的属性赋值,而KVO键值观察,提供了一套监听机制,当对象的指定的属性被修改后,对象会收到通知,所以可以看出KVO是基于KVC的基础上对对象属性的动态变化进行监听

这听起来好像跟NSNotificationCenter有些类似,那么他们有什么区别呢?

KVO和NotificationCenter区别

  • 相同点
    • 两者都是观察者模式,都用来监听;
    • 都能实现一对多的操作;
  • 不同点
    • KVO 用于监听对象属性的变化,并且属性名是通过字符串NSString来进行查找的;
    • NSNotificationCenter监听也就是POST操作我们可以控制,而KVO是由系统控制的;
    • KVO可以记录新旧值的变化;

KVO的使用

KVO基本使用

  • 注册观察者
[self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:NULL];
复制代码
  • 监听KVO回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    if ([keyPath isEqualToString:@"nickName"]) {
        NSLog(@"%@发生变化:%@", keyPath, [change objectForKey:NSKeyValueChangeNewKey]);
    } else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}
复制代码
  • 移除观察者
- (void)dealloc {
    [self.person removeObserver:self forKeyPath:@"nickName" context:NULL];
}
复制代码

context的使用

KVO官方文档中关于context的介绍如下

简要来说就是context是一个包含任意数据的指针,这些数据会在相应的更改通知中回传给观察者。我们可以指定contextNULL,从而使用keyPath键路径来确定更改的通知的来源,但是这种方法可能会导致某些对象发生问题,比如该对象的父类也监听了相同的keyPath;所以我们可以为每一个需要观察的keyPath传建一个不同的context,从而完全跳过对keyPath的比较,直接使用context进行更有效的通知解析;context更安全也更具可扩展性,从而大大提升性能和代码的可读性;

我们通过代码来直观的感受一下:

  • 未使用context时代码逻辑:
- (void)viewDidLoad {
    [super viewDidLoad];

    self.person = [[Person alloc] init];
    self.student = [[Student alloc] init];

    [self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:NULL];
    [self.student addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:NULL];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    if ([keyPath isEqualToString:@"nickName"] && object == self.person) {
        NSLog(@"Person:%@发生变化:%@", keyPath, [change objectForKey:NSKeyValueChangeNewKey]);
    } else if ([keyPath isEqualToString:@"nickName"] && object == self.student) {
        NSLog(@"Student:%@发生变化:%@", keyPath, [change objectForKey:NSKeyValueChangeNewKey]);
    } else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

- (void)dealloc {
    [self.person removeObserver:self forKeyPath:@"nickName" context:NULL];
    [self.student removeObserver:self forKeyPath:@"nickName" context:NULL];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    self.person.nickName = @"昵称";
    self.student.nickName = @"王同学";
}
复制代码

为了监听PersonStudent两个类的相同的nickName属性,我们在接收监听回调时,为了区分nickName来自于哪个对象,除了使用keyPath只要,还需要使用object来判断来源对象,才能准确区分是谁的nickName发生了变化;

  • 使用context时代码逻辑:
//定义context
static void *PersonNameNickContext = &PersonNameNickContext;
static void *StudentNameNickContext = &StudentNameNickContext;

- (void)viewDidLoad {
    [super viewDidLoad];

    self.person = [[Person alloc] init];
    self.student = [[Student alloc] init];

    [self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:PersonNameNickContext];
    [self.student addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:StudentNameNickContext];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    if (context == PersonNameNickContext) {
        NSLog(@"Person:%@发生变化:%@", keyPath, [change objectForKey:NSKeyValueChangeNewKey]);
    } else if (context == StudentNameNickContext) {
        NSLog(@"Student:%@发生变化:%@", keyPath, [change objectForKey:NSKeyValueChangeNewKey]);
    } else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

- (void)dealloc {
    [self.person removeObserver:self forKeyPath:@"nickName" context:PersonNameNickContext];
    [self.student removeObserver:self forKeyPath:@"nickName" context:StudentNameNickContext];
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    self.person.nickName = @"昵称";
    self.student.nickName = @"王同学";
}
复制代码

上述方法是不会触发KVO的;

仅仅通过context就能判断是哪个对象的nickName发生了变化;

KVO使用细节

移除观察者的必要性

KVO官方文档中关于removeObserver的说明如下:

解释:

通过向被观察者发送removeObserver:forKeyPath:context:消息,指定观察对象键路径context,可以删除一个键值观察者;

在接收到removeObserver:forKeyPath:context:消息后,观察对象将不再接收指定键路径和对象的任何observeValueForKeyPath:ofObject:change:context消息;

当移除观察者时,需要注意以下几点:

  • 如果没有注册观察者,被移除时会导致NSRangeException,可以通过调用一次removeObserver:forKeyPath:context:来应对addObserver:forKeyPath:options:context:;如果在项目中这种方式不可行时,可以把removeObserver:forKeyPath:context: call放在try/catch块中来处理潜在的异常;
  • 当被释放时,观察者不会自动释放自己。被观察的对象继续发送通知,而忽略了其状态。但是,与发送到已释放的对象的其他消息一样,更改通知也会触发内存访问异常。因此,应该确保观察者在从内存中消失之前将自己删除。
  • 该协议无法询问对象是观察者还是被观察者。构造代码以避免与发布相关的错误,一种典型的模式就是在观察者初始化期间(init或者viewDidLoad中)注册为观察者,并在释放过程中(通常是在dealloc中)注销,以确保添加和删除消息是成对的,并确保观察者在注册之前被取消注册,并从内存中释放。

总的来说就是,KVO注册观察者和移除观察者需要是成对出现的,如果只注册而不移除,会出现崩溃

图中的Person采用单例是为了防止释放,演示崩溃现象

崩溃的原因是,由于第一次注册KVO观察者之后没有移除,再次进入界面,会导致第二次注册KVO观察者,由于之前注册的对象并没有释放,导致重复的注册观察者,此时会收到属性值变化的通知,会出现找不到通知对象。

所以,为了防止出现这种情况,建议在dealloc中移除观察者。需要注意的是,如果使用了context,那么移除时也要使用相同的context,否则将会崩溃,抛出名为NSRangeException的异常:

KVO的自动触发和手动触发

  • 自动触发
// 自动开关
+ (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key{
    return YES;
}
复制代码

方法返回YES时,表示可以监听,返回NO,表示不可以监听;

  • 手动触发
- (void)setNickName:(NSString *)nickName {
    [self willChangeValueForKey:@"nickName"];
    _nickName = nickName;
    [self didChangeValueForKey:@"nickName"];
}
复制代码

自动触发方法返回NO时,我们可以通过这种方式实现手动触发

KVO观察:一对多

KVO观察中的一对多,意思是通过注册一个观察者,可以监听到多个属性的变化;

比如,下载文件的时候,我们经常需要根据总下载量totalData和当前下载量writtenData来计算出下载进度downloadProgress,有两种方法都可以达到目的:

  • 第一种,分别给两个属性添加观察者,当其中任何一个发生变化时,计算当前的下载进度downloadProgress;
  • 第二种,实现keyPathsForValuesAffectingValueForKey方法,将两个观察者合二为一,即观察当前的downloadProgress,当totalDatawrittenData任意一个值发生改变即会发送通知:
@implementation Person

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

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


@implementation SecondViewController

static void *PersondownloadProgressContext = &PersondownloadProgressContext;

- (void)viewDidLoad {
    [super viewDidLoad];
    self.navigationItem.title = NSStringFromClass(self.class);
    self.view.backgroundColor = [UIColor whiteColor];
    
    self.person = [[Person alloc] init];
    
    [self.person addObserver:self forKeyPath:@"downloadProgress" options:NSKeyValueObservingOptionNew context:PersondownloadProgressContext];

}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    if (context == PersondownloadProgressContext) {
        NSLog(@"Person:%@发生变化:%@", keyPath, [change objectForKey:NSKeyValueChangeNewKey]);
    } else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

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

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    self.person.writtenData += 10;
}

@end
复制代码

KVO观察可变数组

KVO是基于KVC基础之上的,所以可变数组添加元素,直接调用addObject方法是不会触发setter方法的,所以通过addObject这种方法添加元素时无法监听到数组的变化的;

@implementation SecondViewController

static void *PersondateArrayContext = &PersondateArrayContext;

- (void)viewDidLoad {
    [super viewDidLoad];
    self.navigationItem.title = NSStringFromClass(self.class);
    self.view.backgroundColor = [UIColor whiteColor];
    
    self.person = [[Person alloc] init];
    self.person.dataArray = [[NSMutableArray alloc] initWithCapacity:0];
    
    [self.person addObserver:self forKeyPath:@"dataArray" options:NSKeyValueObservingOptionNew context:PersondateArrayContext];

}

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

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

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.person.dataArray addObject:@"1"];
}
复制代码

KVC官方文档中针对可变数组类型进行了说明,需要通过mutableArrayValueForKey方法:

修改代码如下:

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [[self.person mutableArrayValueForKey:@"dataArray"] addObject:@"1"];
}
复制代码

运行结果:

数组改变时,可以被监听到;

其中kind表示键值变化的类型,是一个枚举类型:

typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
    NSKeyValueChangeSetting = 1,    // 设置值
    NSKeyValueChangeInsertion = 2,  // 插入
    NSKeyValueChangeRemoval = 3,    // 移除
    NSKeyValueChangeReplacement = 4, // 替换
};
复制代码

所以,一般属性kind1;

KVO原理探索

KVO官方文档中对KVO的原理描述如下:

解析:

  • KVO自动键值观察是使用isa-swizzling技术实现的;
  • isa指针,顾名思义,指向维护调度表的对象的。这个调度表本质上包含指向类实现的方法的指针,以及其他数据
  • 对象属性注册为观察者时,将会修改被观察对象isa指针,指向一个中间类而不是真正的类。因此,isa指针的值不一定反映实例的实际类;
  • 不应该依赖isa指针来决定类的成员。相反,应该使用类方法来确定对象实例的类

KVO代码调试

属性观察

在上文中,我们测试了属性nickName的修改可以被KVO监听到,那么成员变量是否也能监听到呢?

Person类添加名为name成员变量:

@interface Person : NSObject {
    @public
    NSString *name;
}
@property (nonatomic, copy) NSString *nickName;
@end
复制代码

分别为namenickName都添加KVO监听:

[self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:PersonNameNickContext];
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:PersonNameContext];
复制代码

运行结果:

KVO只能监听属性,不能对成员变量进行监听;而属性成员变量的区别在于属性比成员变量多一个setter方法,而KVO监听的就是setter方法;

中间类NSKVONotifying_Xxxx

根据KVO官方文档的描述,在注册观察者之后,观察对象的isa指针发生了变化,接下来通过代码来验证一下:

在注册成为观察者之前,实例对象personisa指向Person;

在注册成为观察者之后,实例对象personisa指向NSKVONotifying_Person;

在注册成功观察者之后,实例对象的isa指向了一个中间类NSKVONotifying_Xxxxisa的指针指向确实发生了变化

NSKVONotifying_Xxxx 研究

NSKVONotifying_Xxxx与观察者的关系

那么NSKVONotifying_Xxxx究竟是什么呢?

通过上述两张图,我们可以确定NSKVONotifying_Person确实是,在person对象注册成为观察者之后,系统在底层自动生成的,那么这个类和Person有什么关系呢?

我们首先来遍历一下Person在注册观察者前后的子类是否有变化:

注册为观察者之前,Person只有一个子类Student,注册观察者之后,Person多了一个名为NSKVONotifying_Person子类,而NSKVONotifying_Person没有子类;

NSKVONotifying_Xxxx的方法列表

那么,NSKVONotifying_Person中都有那些方法呢:

可以看到,自动生成的类NSKVONotifying_Person中,有四个方法,分别是setNickNameclassdealloc_isKVOA

  • setNickName 观察对象的setter方法
  • class 类型
  • dealloc 是否释放(该dealloc执行时,将isa重新指向Person)
  • _isKVOA 判断是否是KVO生成的一个辨识码

那么这些方法是继承来的还是重写了父类的方法呢?

创建一个继承与类Person的类Student

@interface Student : Person

@end

@implementation Student

@end
复制代码

然后,分别打印StudentNSKVONotifying_Person的方法列表:

Student的方法列表没有打印(集成的方法无法在子类的方法列表中遍历出来),说明NSKVONotifying_Person中的方法是重写了父类的方法

NSKVONotifying_Xxxx的释放问题

既然,系统自动创建了NSKVONotifying_Person

经过验证,在dealloc中移除观察者之后,isa指针重新指向了Person类,那么NSKVONotifying_Person是否被销毁了呢?

我们在当前界面的上一级界面,打印Person的子类的情况查看一下:

可以看到,即使dealloc方法执行了,观察者已经被移除,回到上级界面之后,NSKVONotifying_Person依然存在;

中间类一旦生成,考虑到重用问题,之后会一直存在,并不会销毁;

setter方法归属问题

在之前我们已经验证过,KVO监听的是setter方法,中间类NSKVONotifying_Person也重写了setter方法,那么我们最终修改的setter方法究竟是NSKVONotifying_Person的还是Person的呢?

可以看到,在移除观察者时isa已经指向了Person,而且nickName的值也改变了,那么此时的setter方法是Person的;

接下来,我们通过观察变量值改变验证一下:

运行项目,断点,观察_nickName值的改变:

继续运行项目,触发监听:

bt打印堆栈信息:

所以最终调用的setterPersonsetNickName方法;

猜你喜欢

转载自juejin.im/post/6994349205632319496