iOS 底层探究:KVO

567 阅读12分钟

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

前面文章我们分析了KVC,今天我们来分析一下KVO,附KVO官方文档链接

1. 基本介绍

KVO全称Key-Value Observing(键值观察),是一种机制,允许对象在其它对象的指定属性发生改变时收到通知。KVO是基于KVC基础之上的。

KVO和KVC的差异:

  • KVC是键值编码,一种由NSKeyValueCoding非正式协议启用的机制。在对象创建完成后,可以动态的给对象属性赋值
  • KVO是键值观察,一种监听机制。当指定的对象的属性被修改后,对象会收到通知。所以,KVO是基于KVC的基础上,对属性动态变化的监听。

KVO和NSNotificationCenter的差异

  • 相同点:
    • 都是观察者模式,都用于监听
    • 都能实现一对多操作
  • 不同点
    • KVO只能用于监听对象属性的变化,NSNotificationCenter可以注册任何你感兴趣的东西
    • KVO发出消息又系统控制,NSNotificationCenter由开发者控制
    • KVO自动记录新旧值变化,NSNotificationCenter只能记录发开着传递的参数

2.API介绍

KVO键值观察的API使用,分为三部分:

  • 1.注册观察者
    • 使用方法向被观察对象注册观察者addObserver:forKeyPath:options:context:
  • 2.接收变更通知
    • observeValueForKeyPath:ofObject:change:context:在观察者内部实现以接受更改通知消息
  • 3.移除观察者
    • 当观察者removeObserver:forKeyPath:不再应该接收消息时,应该使用方法取消注册观察者。至少在观察者从内存中释放之后调用这个方法

2.1 注册观察者

观察对象首先通过发送addObserver:forKeyPath:options:context:消息向被观察者对象注册自己,将自身作为观察者和要观察的属性的关键路径传递。观察者另外指定一个选项参数和一个上下文指针来管理通知的各个方面

[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];

2.2 接收变更通知

当对象的被观察属性的值发生变化时,观察者会收到一条observeValueForKeyPath:ofObject:change:context:消息。所有观察者都必须实现这个方法

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

2.3 移除观察者

通过向被观察对象发送removeObserver:forKeyPath:context:消息、指定观察对象、键路径和上下文来移除键值观察器

[self.person removeObserver:self forKeyPath:@"nick" context:NULL];

3. API的使用

3.1 context参数

addObserver:forKeyPath:options:context:消息中的上下文指针包含任意数据,这些数据将在响应的更改通知中传回给观察者。可以指定NULL并完全依赖keyPath字符串来确定更改通知的来源,但是这种方法可能会导致父类由于不同原因也在观察相同键路径的对象出现问题。 更安全、更可扩展的方法,是使用上下文来确保您收到的通知时发送给您的观察者而不是父类的。 类中唯一命名的静态变量的地址是一个很好的上下文。在父类或子类中以类似方式选择的上下文不太可能重叠。你可以为整个类选择一个上下文,并依靠通知消息中的关键路径字符串来确定发生了什么变化。或者,你可以为每个观察到的keyPath创建一个不同的上下文,这完全绕过了字符串比较的需要,从而提高了通知解析的效率。 查看addObserver:forKeyPath:options:context:的定义

- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
  • context参数类型为void *,如果参数缺失,应该传入NULL。如果是id类型,传入nil context传入NULL,表示不使用context上下文,使用keyPath区分通知来源
[self.person addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:NULL]; 
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL]; 
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{ 
    if([object isEqual:self.person]){ 
        if([keyPath isEqualToString:@"nick"]){ 
            NSLog(@"nick:%@",change); 
            return; 
        } 
        if([keyPath isEqualToString:@"name"]){ 
            NSLog(@"name:%@",change); 
            return; 
        } 
    } 
}

设值context上下文,区分通知来源

static void *PersonNickContext = &PersonNickContext; 
static void *PersonNameContext = &PersonNameContext; 
[self.person addObserver:self forKeyPath:@"nick" options:NSKeyValueObservingOptionNew context:PersonNickContext]; 
[self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:PersonNameContext]; 
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{ 
    if (context == PersonNickContext) { 
        NSLog(@"nick:%@",change); 
        return; 
    } 
    if (context == PersonNameContext){ 
        NSLog(@"name:%@",change); 
        return; 
    } 
}

3.2 移除观察者

接收到removeObserver:forKeyPath:context:消息后,观察对象将不再接收observeValueForKeyPath:ofObject:change:context:指定keyPath和对象的任何消息 移除观察者时,请记住以下几点:

  • 如果尚未注册为观察者,将其移除会导致NSRangeException。您可以调用一次addObserver:forKeyPath:options:context:,或者此方案在应用程序中不可行,可以将removeObserver:forKeyPath:context:调用放置在try/catch块中以处理潜在的异常
  • 移除时观察者不会自动将其自身移除。被观察的对象继续发送通知,而忽略了观察者的状态。但是,发送到已释放对象的更改通知与任何其他消息一样,会触发内存访问异常。因此,您要确保观察者在从内存中消失之前将自己移除。 -该协议没有提供询问对象是观察者还是被观察者的方法。构建您的代码以避免与发布相关的错误。一个典型的模式是在观察者初始化期间出册为观察者(例如:initviewDidLoad)并在释放期间取消注册(通常是delloc),确保正确配对和有序添加和删除消息,并且观察者在从内存中释放之前被取消注册 所以,如果观察者注册后不移除,被观察的对象会继续发送通知,当发送到已释放的对象上,会触发内存访问异常 案例:

image.png 将被观察的对象LGStudent设值为单例模式 第一次进入页面,注册观察者。退出页面,观察者未移除,对象为单例模式不会释放,ViewController释放 第二次进入页面,注册观察者。当对象修改,第一次的消息还会发送,并且会发送到已经释放的Viewcontroller中,导致内存访问异常

3.3 KVO的自动/手动触发

系统默认为自动触发,使用触发开关的好处,在需要的时候进行监听,不需要关闭即可,比自动触发更方便灵活

3.3.1 自动触发

在被观察对象LGPerson中,设值automaticallyNotifiesObserversForKeyYES,开启KVO的自动触发

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

3.3.2 手动触发

设值automaticallyNotifiesObserversForKeyNO,开启KVO的手动触发

    return NO;
}

手动通知和自动通知并不相互排斥

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey { 
BOOL automatic = NO; 
    if ([theKey isEqualToString:@"balance"]) { 
        automatic = NO; 
    } else { 
        automatic = [super automaticallyNotifiesObserversForKey:theKey]; 
    } 
    return automatic; 
}

实现手动观察者通知,请willChangeValueForKey:在更改之前和didChangeValueForKey:更改值之后调用

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

3.4 一对多关系

一对多关系,通过注册一个KVO观察者,可以监听多个属性的变化。 例如:完成一个下载进度的需求,下载进度 = 当前下载数currentData / 总下载数totalData,所以currentData和totalData任一值的改变,都会影响到下载进度。 分别观察totalData和currentData两个属性,当其中一个属性的值发生变化,计算当前下载进度downloadProgress。 在被观察对象LGPerson中,实现keyPathsForValueAffectingValueForKey:方法,合并currentData和totalData属性的观察

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

实现downloadProgress属性的getter方法

- (NSString *)downloadProgress{ 
    return [[NSString alloc] initWithFormat:@"%f",1.0f*self.writtenData/self.totalData];
}

KVO代码

self.person.writtenData = 10; 
self.person.totalData = 100; 
[self.person addObserver:self forKeyPath:@"downloadProgress" options:(NSKeyValueObservingOptionNew) context:NULL]; 
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{ 
    self.person.writtenData += 10; 
    self.person.totalData += 1; 
} 
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{ 
    NSLog(@"%@",change); 
} 
- (void)dealloc{ 
    [self.person removeObserver:self forKeyPath:@"downloadProgress" context:NULL]; 
}

3.5 监听可变数组

使用KVO监听可变数组

self.person.dateArray = [NSMutableArray arrayWithCapacity:1]; 
[self.person addObserver:self forKeyPath:@"dateArray" options:(NSKeyValueObservingOptionNew) context:NULL]; 
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{ 
    if(self.person.dateArray.count == 0){ 
        [self.person.dateArray addObject:@"1"]; 
    } else{ 
        [self.person.dateArray removeObjectAtIndex:0]; 
    } 
} 
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{ 
    NSLog(@"%@",change); 
} 
- (void)dealloc{ 
    [self.person removeObserver:self forKeyPath:@"dateArray"];
}

代码运行后,发现监听并不生效,点击屏幕后,数组正常添加、移除元素,但接受不到KVO的回调通知。问题的原因,KVO基于KVC之上,在KVO的文档中,说明了对集合对象访问定义的三种不同的代理方法:

  • mutableArrayValueForKey:mutableArrayValueForKeyPath:
    • 它们返回一个行为类似于NSMutableArray对象的代理对象
  • mutableSetValueForKey:mutableSetValueForKeyPath:
    • 它们返回一个行为类似于NSMutableSet对象的代理对象
  • mutableOrderedSetValueForKey:mutableSetValueForKeyPath:
    • 它们返回一个行为类似于NSMutableSet对象的代理对象
  • mutableOrderedSetValueForKey:mutableOrderedSetValueForKeypath:
    • 它们返回一个行为类似于NSMutableOrderedSet对象的代理对象 修改代码如下:
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{ 
    if(self.person.dateArray.count == 0){ 
        [[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"1"]; 
    } else{ 
        [[self.person mutableArrayValueForKey:@"dateArray"] removeObjectAtIndex:0]; 
    } 
}
------------------------- 
//输出以下内容: 
{ 
    indexes = "<_NSCachedIndexSet: 0x280fc4720>[number of indexes: 1 (in 1 ranges), indexes: (0)]"; 
    kind = 2; 
    new = ( 
        1 
    ); 
} 
{ 
    indexes = "<_NSCachedIndexSet: 0x280fc4720>[number of indexes: 1 (in 1 ranges), indexes: (0)]";
    kind = 3; 
}

kind便是键值变化的类型,执行addObject时,kind打印值为2.执行removeObjectIndex时,kind打印值为3 找到kind的定义

/* Possible values in the NSKeyValueChangeKindKey entry in change dictionaries. See the comments for -observeValueForKeyPath:ofObject:change:context: for more information. */ 
typedef NS_ENUM(NSUInteger, NSKeyValueChange) { 
    NSKeyValueChangeSetting = 1, 
    NSKeyValueChangeInsertion = 2, 
    NSKeyValueChangeRemoval = 3,
    NSKeyValueChangeReplacement = 4, 
};
  • NSKeyValueChangeSetting:赋值
  • NSKeyValueChangeInsertion:插入
  • NSKeyValueChangeRemoval:移除
  • NSKeyValueChangeReplacement:替换

4.实现细节

  • 自动键值观察是使用称为isa-swizzling的技术实现
  • isa指针,顾名思义,指向对象的类,它保持一个调度表。该调度表主要包含指向类实现的方法的指针,以及其他数据
  • 当观察者为对象的属性注册时,被观察对象的isa指针被修改,指向中间类而不是真正的类。因此,isa指针的值不一定反映实例的实际类
  • 永远不要依赖isa指针来确定类成员身份。相反,应该使用class方法来确定对象实例的类

5.底层原理

5.1 isa的变化

打印person实例对象,在添加KVO观察者前后,会发生怎样的变化

- (void)viewDidLoad { 
    [super viewDidLoad]; 
    self.person = [[LGPerson alloc] init]; 
    NSLog(@"添加KVO观察者之前:%s", object_getClassName(self.person)); 
    [self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL];
    NSLog(@"添加KVO观察者之后:%s", object_getClassName(self.person)); 
}
------------------------- 
//输出以下内容: 
添加KVO观察者之前:LGPerson 
添加KVO观察者之后:NSKVONotifying_LGPerson
  • 同样person实例对象,添加KVO观察者前后,所属的类对象发生改变

5.2 NSKVONotifying_x的创建时机

验证NSKVONotifying_LGPerson类是原本就存在,还是添加KVO临时生成的

- (void)viewDidLoad { 
    [super viewDidLoad]; 
    self.person = [[LGPerson alloc] init]; 
    NSLog(@"添加KVO观察者之前:%s, %@", object_getClassName(self.person), objc_getClass("NSKVONotifying_LGPerson")); 
    [self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL];
    NSLog(@"添加KVO观察者之后:%s, %@", object_getClassName(self.person), objc_getClass("NSKVONotifying_LGPerson")); 
} 
------------------------- 
//输出以下内容: 添加KVO观察者之前:LGPerson, (null) 
添加KVO观察者之后:NSKVONotifying_LGPerson, NSKVONotifying_LGPerson
  • 系统将对象添加KVO时,临时生成NSKVONotifying_LGPerson类,并将实例对象的isa指向该类

5.3 NSKVONotifying_x和原始类的关系

验证LGPersonNSKVONotifying_LGPerson的关系

- (void)viewDidLoad { 
    [super viewDidLoad]; 
    self.person = [[LGPerson alloc] init]; 
    [self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL]; 
    NSLog(@"NSKVONotifying_LGPerson的Superclass:%@",class_getSuperclass(objc_getClass("NSKVONotifying_LGPerson"))); 
} 
------------------------- 
//输出以下内容: 
NSKVONotifying_LGPerson的Superclass:LGPerson
  • NSKVONotifying_LGPersonLGPerson类的子类,即:派生类

5.4 NSKVONotifying_x中的方法

遍历NSKVONotifying_LGPerson类中的所有方法

- (void)viewDidLoad { 
    [super viewDidLoad]; 
    self.person = [[LGPerson alloc] init]; 
    [self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL]; 
    unsigned int intCount;
    Method *methodList = class_copyMethodList(objc_getClass("NSKVONotifying_LGPerson"), &intCount); 
    for (unsigned int intIndex=0; intIndex<intCount; intIndex++) { 
        Method method = methodList[intIndex]; 
        NSLog(@"SEL:%@,IMP:%p",NSStringFromSelector(method_getName(method)), method_getImplementation(method)); 
    } 
}
------------------------- 
//输出以下内容: 
SEL:setNickName:,IMP:0x18a5d8520 
SEL:class,IMP:0x18a5d6fd4 
SEL:dealloc,IMP:0x18a5d6d58 
SEL:_isKVOA,IMP:0x18a5d6d50
  • 重写了父类的setNickName方法
  • 重写了NSObject类的classdealloc_isKVOA方法

5.5 重写class方法的目的

目的:隐藏KVO生成的中间类 调用中间类的class方法,返回的还是原始类对象的地址

- (void)viewDidLoad { 
    [super viewDidLoad]; 
    self.person = [[LGPerson alloc] init]; 
    Class cls = self.person.class; 
    NSLog(@"添加KVO观察者之前:%s, %p, %@", object_getClassName(self.person), &cls, cls);
    [self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL]; 
    cls = self.person.class; 
    NSLog(@"添加KVO观察者之后:%s, %p, %@", object_getClassName(self.person), &cls, cls);
}
------------------------- 
//输出以下内容: 
添加KVO观察者之前:LGPerson, 0x16f223538, LGPerson 
添加KVO观察者之后:NSKVONotifying_LGPerson, 0x16f223538, LGPerson

使用object_getClassName可以获取实例对象真正所属的类,但使用中间类重写后的class方法,获取的还是LGPerson类,仿佛KVO所做的一切都不存在

5.6 重写dealloc方法的目的

目的:移除观察者之后,将实例对象的isa重新指向原始类对象。

调用removeObserver移除观察者,查看实例对象在调用方法前后的变化

- (void)dealloc{ 
    Class cls = self.person.class; 
    NSLog(@"remove前:%s, %p, %@", object_getClassName(self.person), &cls, cls);
    [self.person removeObserver:self forKeyPath:@"nickName"]; 
    cls = self.person.class; 
    NSLog(@"remove后:%s, %p, %@", object_getClassName(self.person), &cls, cls); 
}
------------------------- 
//输出以下内容: 
移除观察者之前:NSKVONotifying_LGPerson, 0x16ef1e7a8, LGPerson 
移除观察者之后:LGPerson, 0x16ef1e7a8, LGPerson

中间类的class方法,配合dealloc方法,成功替换了实例对象的isa指向,并且对开发者毫无痕迹

5.6.1 NSKVONotifying_x类的销毁

移除观察者之后,实例对象的isa指向原始类对象,此时中间类的任务已经完成了,它是否会进行销毁呢? 在KVO案例的前一个页面,点击屏幕,打印NSKVONotifying_LGPerson

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{ 
    NSLog(@"中间类:%@", objc_getClass("NSKVONotifying_LGPerson")); 
}
 ------------------------- 
 //输出以下内容: 
 中间类:0x16bc4d168,(null) 
 添加KVO观察者之前:LGPerson, 0x16bc4b538, LGPerson 
 添加KVO观察者之后:NSKVONotifying_LGPerson, 0x16bc4b538, LGPerson 
 移除观察者之前:NSKVONotifying_LGPerson, 0x16bc4e7a8, LGPerson 
 移除观察者之后:LGPerson, 0x16bc4e7a8, LGPerson 
 中间类:0x16bc4d168NSKVONotifying_LGPerson

移除观察者之后,中间类并没有直接销毁。可能考虑再次添加观察者,可以对其进行重用

5.7 重写_isKVOA方法的目的

目的:标记是否为KVO生成的中间类

使用KVC,打印原始类和中间类的isKVOA

- (void)viewDidLoad { 
    [super viewDidLoad];
    NSLog(@"添加KVO观察者之前:%s,_isKVOA:%@", object_getClassName(self.person), [self.person valueForKey:@"isKVOA"]); 
    [self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL]; 
    NSLog(@"添加KVO观察者之后:%s,_isKVOA:%@", object_getClassName(self.person), [self.person valueForKey:@"isKVOA"]); 
}
------------------------- 
//输出以下内容:
添加KVO观察者之前:LGPerson,_isKVOA:0 
添加KVO观察者之后:NSKVONotifying_LGPerson,_isKVOA:1

5.8 重写set方法的目的

重写set方法,中间类负责调用KVO相关的系统函数,然后调用父类的set方法,保证原始类中的属性赋值成功。当一切都结束以后,中间类继续调用系统函数,最后调用KVO的回调通知

5.8.1 KVO只能监听属性

KVO为了完成nickName的监听,创建了中间类并重写了setNickName方法。那对于成员变量来说,它们不存在set方法,还能使用KVO进行监听吗?

LGPerson中,定义name成员变量

#import <Foundation/Foundation.h> 
@interface LGPerson : NSObject{ 
    @public NSString *name; 
} 
@property (nonatomic, copy) NSString *nickName; 

@end

使用KVO对其进行监听

- (void)viewDidLoad { 
    [super viewDidLoad]; 
    self.person = [[LGPerson alloc] init]; 
    [self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL]; 
    unsigned int intCount;
    Method *methodList = class_copyMethodList(objc_getClass("NSKVONotifying_LGPerson"), &intCount); 
    for (unsigned int intIndex=0; intIndex<intCount; intIndex++) { 
        Method method = methodList[intIndex]; 
        NSLog(@"SEL:%@,IMP:%p",NSStringFromSelector(method_getName(method)), method_getImplementation(method)); 
    }
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    self.person->name = @"KC"; 
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{ 
    NSLog(@"%@",change); 
}
- (void)dealloc{ 
    [self.person removeObserver:self forKeyPath:@"name"]; 
}
------------------------- 
SEL:class,IMP:0x18a5d6fd4 
SEL:dealloc,IMP:0x18a5d6d58 
SEL:_isKVOA,IMP:0x18a5d6d50

没有set方法,点击屏幕也没有收到KVO的监听回调。所以KVO只能监听属性,无法监听成员变量

5.8.2 set方法的调用流程

添加KVO观察者,修改nickName属性,LGPerson类中的nickName属性也会同步修改。这证明在中间类的setNickName方法中,调用了LGPersonsetNickName方法

lldb中设置断点

watchpoint set variable self->_person->_nickName

nickName赋值时,进入断点,查看函数调用栈

image.png 调用LGPersonsetNickName方法之前,调用Foundation库中三个系统方法:

  • Foundation`_NSSetObjectValueAndNotify:
  • Foundation`-[NSObject(NSKeyValueObservingPrivate) _changeValueForKey:key:key:usingBlock:]:
  • Foundation`-[NSObject(NSKeyValueObservingPrivate) _changeValueForKeys:count:maybeOldValuesDict:maybeNewValuesDict:usingBlock:]:

调用LGPersonsetNickName方法 成功修改nickName属性,再次调用Foundation库中两个系统方法:

  • Foundation`NSKeyValueDidChange:
  • Foundation`NSKeyValueNotifyObserver: 最后调用KVO的回调通知:observeValueForKeyPath:ofObject:change:context: