iOS的KVO基础与原理

前言

KVO(Key-Value Obsering)键值观察。KVO是一种机制,该机制允许将需要被观察的对象的指定属性的更改通知给发送给观察的对象。直接来说就是对某个对象的属性的观察监听,如果被观察的属性有发生了变化会以通知的形式发送给观察的对象。KVO是基于KVC的基础上的,本人之前也写了一篇介绍KVC的文章,具体可以看iOS的Key-Value Coding

KVO的主要好处是,您不必为每次更改属性而写一些其他代码即可发送通知。但是与NSNotificationCenter通知不同,NSNotificationCenter没有中间对象为所有观察者提供更改通知。而是在进行更改时将通知直接发送到观察对象。并且NSObject提供了键值观察的基本实现,只要是继承NSObject就可以实现。

1.KVO的使用

通过实现以下三个步骤可以使对象接收KVO兼容属性的键值观察通知:

1.使用方法addObserver:forKeyPath:options:context:将观察者注册到观察对象。

2.在观察者内部实现方法observeValueForKeyPath:ofObject:change:context:接收更改的通知。

3.当不再接收消息时,可以使用方法removeObserver:forKeyPath:注销观察者,至少在销毁观察者之前调用这个方法。

2.KVO的注册

使用addObserver:forKeyPath:options:context:方法为需要观察的属性注册一个观察者。其中分别对options和context值进行说明。

2.1 Option

options是一个NSKeyValueObservingOptions枚举类型。在使用的时候可以单独使用也可以用|符号多个连接使用。

typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
   NSKeyValueObservingOptionNew = 0x01,
   NSKeyValueObservingOptionOld = 0x02,
   NSKeyValueObservingOptionInitial API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) = 0x04,
   NSKeyValueObservingOptionPrior API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) = 0x08
};
  • NSKeyValueObservingOptionNew:指明接受通知方法参数中的chang字典中包含改变后的新值,默认情况下也是只接收新值。
  • NSKeyValueObservingOptionOld:指明接受通知方法参数中的change字典中包含改变前的旧值。
  • NSKeyValueObservingOptionInitial:当指定了这个选项时,在addObserver:forKeyPath:options:context:消息被发出去后,甚至不用等待这个消息返回,观察者对象会马上收到一个通知。这种通知只会发送一次,你可以利用这种“一次性”的通知来确定要观察属性的初始值。
  • NSKeyValueObservingOptionPrior:当包含这个参数的时候,在被观察的属性的值改变前和改变后,系统各会给观察者发送一个改变的通知;在属性的值改变之前发送的改变的通知中,参数会包含NSKeyValueChangeNotificationIsPriorKey并且值为@YES,但不会包含NSKeyValueChangeNewKey和它对应的值。

2.2 Context

context指针可以是任意数据,这些数据将在相应的更改通知中传递回观察者,也可以将context的值设置为NULL再通过依赖keyPath键值路径字符串来确定更改通知的来源。但是这种方式会引发出问题,比如如果父类和子类都监听了相同的KeyPath键值路径的话,这时就很难区分出来了。可能也有人会说,可以根据observeValueForKeyPath:ofObject:change:context:方法的object来做判断,但是如果这样的就有多层的嵌套,在没有写核心代码的时候就有这样的嵌套就显得代码很不优雅.

注意:为什么是NULL不是nil呢?因为OC是C的超集,并且Context的参数指针类型的,
所以是NULL。什么时候可以是nil呢?一般是实例的时候可以为nil,类的时候Nil,指针的时候为NULL.

为了避免出现这种问题可以使用命名静态变量地址的形式来设置context的值,可以为整个类选择一个上下文,然后依靠通知消息中的keyPath键路径字符串来确定更改的内容。另外,还可以为每个观察属性的keyPath创建一个不同的context,从而完全不需要进行字符串比较,从而可以更有效地进行通知解析例如:

static void *PersonNameContext = &PersonNameContext;
static void *PersonNickNameContext = &PersonNickNameContext;
static void *PersonFullNameContext = &PersonFullNameContext;
static void *PersonDataArrayContext = &PersonDataArrayContext;

observeValueForKeyPath:ofObject:change:context:方法中大概的实现和结果

-(void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change 
context:(void *)context{
    if(context == PersonNameContext){
        NSLog(@"处理name的代码:%@",change);
    }else if(context == PersonNickNameContext){
        NSLog(@"处理nickName的代码:%@",change);
    }else{
        [super observeValueForKeyPath:keyPath 
        ofObject:object change:change context:context];
    }
}

3.KVO的移除

在注册使用完KVO了,就需要对KVO移除,实现调用removeObserver:forKeyPath:context:方法。因为观擦对象不会自动移除已经注册的KVO,所以注册和删除KVO这两个是需要成对出现的,一般都是在init或者viewDidLoad方法中进行注册,在delloc方法中进行删除,如果没有移除会引发野指针错误。

4.手动更改通知

一般情况下,我们使用KVO的时候都是调用的系统的自动更改通知的。但是,KVO也可以是手动设置的,需要观察的对象里实现类方法automaticallyNotifiesObserversForKey默认是返回YES的。如果设置返回NO,并且在观察属性值之前调用willChangeValueForKey:和观察值之后调用didChangeValueForKey:就改为手动的更改通知了。下面创建一个Person对象来简单验证一下。

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface Person : NSObject

@property(nonatomic,copy) NSString *fullName;
@property(nonatomic,copy) NSString *name;
@property(nonatomic,copy) NSString *nickName;
@property (nonatomic, strong) NSMutableArray *dateArray;

@end

NS_ASSUME_NONNULL_END


@implementation Person


+(BOOL)automaticallyNotifiesObserversForKey:(NSString *)key{
    return NO;
}

@end

这是实现的部分代码:

- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor redColor];
    self.person = [[Person alloc] init];
    self.person.dateArray = [NSMutableArray arrayWithCapacity:1];
    [self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:PersonNameContext];
    [self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:PersonNickNameContext];
}

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    [self.person willChangeValueForKey:@"name"];
    self.person.name = @"jason";
    [self.person didChangeValueForKey:@"name"];
    self.person.nickName = @"烟火";
}

-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
    if(context == PersonNameContext){
        NSLog(@"处理name的代码:%@",change);
    }else if(context == PersonNickNameContext){
        NSLog(@"处理nickName的代码:%@",change);
    }else{
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

打印的结果:

2020-04-18 16:29:57.789887+0800 KVODemo[4950:209403] 处理name的代码:{
    kind = 1;
    new = jason;
}

这时候会发现,如果改为手动更改通知的时候,那么例子中nickName这个属性的自动更改通知就不会实现了。

如果一个操作造成了多个key的值的改变,则willChangeValueForKey:和didChangeValueForKey:必须嵌套着调用。官方文档的例子:

- (void)setBalance:(double)theBalance {
    [self willChangeValueForKey:@"balance"];
    [self willChangeValueForKey:@"itemChanged"];
    _balance = theBalance;
    _itemChanged = _itemChanged+1;
    [self didChangeValueForKey:@"itemChanged"];
    [self didChangeValueForKey:@"balance"];
}

5.一对一的关系

例如:在Person中的fullName是依赖于name和nickName来设置值,在Person中获取fullName的方法:

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

这时候为了观察fullName的值变化在Person中可以实现类方法keyPathsForValuesAffectingValueForKey:

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

实现的部分代码:

 //监听的
[self.person addObserver:self forKeyPath:@"fullName" 
options:NSKeyValueObservingOptionNew context:PersonFullNameContext];

//对name和nickName的修改的
self.person.name = @"jason";
self.person.nickName = @"烟火";

//观擦的回调
-(void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change 
context:(void *)context{
    if(context == PersonFullNameContext){
        NSLog(@"fullName:%@",change[@"new"]);
    }
    else{
        [super observeValueForKeyPath:keyPath 
        ofObject:object change:change context:context];
    }
}

//打印的结果
2020-04-18 17:13:55.894622+0800 KVODemo[5486:237940] fullName:jason--烟火

6.多对多的关系

例如:在对Person中可变数组dateArray属性进行观察,如果按照上面的方式来注册,然后对数组添加数据,再监听context的值做操作,部分代码:

//注册数组属性
[self.person addObserver:self forKeyPath:@"dateArray"
options:NSKeyValueObservingOptionNew context:PersonDataArrayContext];

//数组添加数据
[self.person.dateArray addObject:@"1"];

//监听
if(context == PersonDataArrayContext){
     NSLog(@"dataArray:%@",change[@"new"]);
}

但是这时候发现,控制台里什么东西都没有打印出来。这是为什么呢?因为KVO对属性的setter方法进行监听的,可变数组的addObject方法没有setter方法,所以就监听不了。但是是不是说这样就监听不了数组了呢?并不是的,因为KVO是建立在KVC的基础上的,主要改为

[[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"1"];

就可以监听到了。

7.原理

7.1 isa_swizzling

根据苹果的官方文档可以知道,KVO的实现原理是通过对象的isa交换即isa-swizzlingisa指针就是指向对象的类,在对象为属性注册KVO的时候,将修改观察对象的isa指针,指向中间类而不是真实类,所以isa指针的值不一定反映实例的实际类,不能依靠isa指针来确定类成员,应该使用class类方法来确定对象实例的类。

为了验证这个说法,还是用上一篇文章介绍的Person类来,然后打断点,用po的指令,得到的如下图所示。其中,Person在添加观擦者之前的self.person对象的类名和class类方法是一样的。

添加观察者之前

在有注册了观察者之后self.person对象的类名变成了NSKVONotifying_Person这一个中间类名了。

添加观察者之后

所以中间生成的是一个动态类NSKVONotifying_Person,但是修改的是原对象的isa。

7.2中间类与原类的关系

对于动态的生成的中间类NSKVONotifying_PersonPerson这个类的它们之间的关系是怎样的暂时还是不清楚的。为了搞明白它们之间的关系,就写了一个方法来探究一下两者之间的关系

#pragma mark - 遍历类以及子类
- (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);
}

并且在Person类对属性注册观察者之前和之后分别打印当前的注册的类和子类列表,得到的结果

2020-04-18 22:56:39.522750+0800 KVODemo[7411:334483] classes = (
    Person
)
2020-04-18 22:56:42.953337+0800 KVODemo[7411:334483] classes = (
    Person,
    "NSKVONotifying_Person"
)

由此可知,对象Person与动态生成的类NSKVONotifying_Person之间的关系是继承关系。NSKVONotifying_PersonPerson类的子类。但是,并不是说KVO是对所有的要被观察的类的属性和变量都是可以观察的监听的,因为在Person类中添加成员变量,并且修改成员变量的值,发现回调中并没有值返回。

7.2中间类的内部

对于动态生成的中间类NSKVONotifying_xxxx是不是很好奇内部的方法到底是怎样的?下面就添加了这个方法来遍历出类的全部方法

#pragma mark - 遍历方法
- (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);
}

并且分别在添加观察name之前和之后打印出来,只对name属性观察

 [self printClasses:[Person class]];
 [self printClassAllMethod:[Person class]];
 [self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:PersonNameContext];
 [self printClasses:[Person class]];
 [self printClassAllMethod:NSClassFromString(@"NSKVONotifying_Person")];

打印的结果

2020-04-23 10:06:22.678886+0800 KVODemo[3414:59220] classes = (
    Person
)
2020-04-23 10:06:28.331048+0800 KVODemo[3414:59220] *********************
2020-04-23 10:06:28.331264+0800 KVODemo[3414:59220] setDateArray:-0x10fcf3bc0
2020-04-23 10:06:28.331381+0800 KVODemo[3414:59220] .cxx_destruct-0x10fcf3c00
2020-04-23 10:06:28.331506+0800 KVODemo[3414:59220] name-0x10fcf3ac0
2020-04-23 10:06:28.331631+0800 KVODemo[3414:59220] setName:-0x10fcf3af0
2020-04-23 10:06:28.331757+0800 KVODemo[3414:59220] dateArray-0x10fcf3ba0
2020-04-23 10:06:28.331873+0800 KVODemo[3414:59220] fullName-0x10fcf39b0
2020-04-23 10:06:28.332025+0800 KVODemo[3414:59220] setFullName:-0x10fcf3a80
2020-04-23 10:06:28.332420+0800 KVODemo[3414:59220] nickName-0x10fcf3b30
2020-04-23 10:06:28.332799+0800 KVODemo[3414:59220] setNickName:-0x10fcf3b60
2020-04-23 10:06:28.336781+0800 KVODemo[3414:59220] classes = (
    Person,
    "NSKVONotifying_Person"
)
2020-04-23 10:06:28.336950+0800 KVODemo[3414:59220] *********************
2020-04-23 10:06:28.337077+0800 KVODemo[3414:59220] setName:-0x110079c7a
2020-04-23 10:06:28.337197+0800 KVODemo[3414:59220] class-0x11007873d
2020-04-23 10:06:28.337296+0800 KVODemo[3414:59220] dealloc-0x1100784a2
2020-04-23 10:06:28.337383+0800 KVODemo[3414:59220] _isKVOA-0x11007849a

从打印出来的结果可以看到NSKVONotifing_Person的类的方法分别有class,delloc,_isKVOAsetName:方法,因为只对name属性观察,所以只有setName方法,就是说NSKVONotifing_Person虽然是Person类的子类,但是并不是将Person类的全部方法都加进去的,只重写了观察属性的setter方法。

在delloc方法对被观察的属性销毁之后,中间动态类的isa会重新指向原来的对象,并且当生成了一次中间类之后,这个中间类就会一直存在缓存中,并不会被销毁的。

8.最后

至此有关KVO的基础与原理相关的就介绍到这里了,如果想了解更多的详细有关KVO的知识,可以阅读苹果的官方文档键值观察编程指南

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容