iOS underlying principle 20: KVO analysis

This is the 9th day of my participation in the August Update Challenge. For details of the event, please check: August Update Challenge

Introduction to KVO

KVOThe full name Key-Value Observingmeans that 键值观察; KVOit is a mechanism that allows 允许其他对象的指定属性发生变化时,通知对象; if you want to understand 键值观察, you must first understand 键值编码that is KVC;

KVCYes 键值编码, after the object is created, it can 动态的给对象的属性赋值, KVObut 键值观察a set of monitoring mechanism is provided. When the specified property of the object is modified, the object will receive a notification, so it can be seen KVO是基于KVC的基础上对对象属性的动态变化进行监听;

This may sound NSNotificationCentersimilar, so what's the difference between them?

Difference between KVO and NotificationCenter

  • Same point
    • Both are 观察者模式, both are used 监听;
    • operations that can be achieved 一对多;
  • difference
    • KVOUsed to monitor changes in object properties, and property names are NSStringsearched through strings;
    • NSNotificationCenter监听That is, the operation POSTwe can control, KVObut is 系统controlled by;
    • KVOchanges that can be recorded 新旧值;

Use of KVO

Basic use of KVO

  • Register an observer
[self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:NULL];
复制代码
  • Listen for KVO callbacks
- (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];
    }
}
复制代码
  • remove observer
- (void)dealloc {
    [self.person removeObserver:self forKeyPath:@"nickName" context:NULL];
}
复制代码

Use of context

The introduction in the official KVO document is contextas follows

简要来说就是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的方法列表:

StudentThe method list is not printed (the integrated method cannot be traversed in the method list of the subclass), NSKVONotifying_Personthe method in the description is to override the method of the parent class

Release issue of NSKVONotifying_Xxxx

Since, the system automatically createdNSKVONotifying_Person

After verification, after deallocremoving the observer in , the isapointer re-points to the Personclass, so NSKVONotifying_Personis it destroyed?

PersonLet 's check the situation of the printed subclass in the previous interface of the current interface :

It can be seen that even if deallocthe method is executed and 观察者has been removed, it NSKVONotifying_Personstill exists after returning to the upper-level interface;

Once the intermediate class is generated, considering the reuse problem, it will always exist and will not be destroyed;

Setter method ownership problem

We have verified before that KVOthe method is monitored , and the setterintermediate class NSKVONotifying_Personalso rewrites the method, so is the method setterwe finally modified true or true?setterNSKVONotifying_PersonPerson

It can be seen that at 移除观察者时, isahas been pointed to Person, and nickNamethe value has also changed, then the settermethod at this time is yes Person;

Next, let's 观察变量值改变verify it by:

Run the project, breakpoints, and watch _nickNamethe value change:

Continue to run the project and trigger the listener:

btPrint stack info:

So the method that is finally called setteris ;PersonsetNickName

Guess you like

Origin juejin.im/post/6994349205632319496