写在前面
说到KVC(键值编码)
和KVO(键值观察)
,可能大家都用的溜溜的,但是你真的了解它吗?本文就将全方位分析KVO的原理
一、KVO初探
KVO(Key-Value Observing)
是苹果提供的一套事件通知机制,这种机制允许将其他对象的特定属性的更改通知给对象
.iOS开发者
可以使用KVO
来检测对象属性的变化、快速做出响应,这能够为我们在开发强交互、响应式应用以及实现视图和模型的双向绑定时提供大量的帮助。
在Documentation Archieve中提到一句想要理解KVO,必须先理解KVC
,因为键值观察是建立在键值编码的基础上
In order to understand key-value observing, you must first understand key-value coding.——Key-Value Observing Programming Guide
在iOS日常开发中,经常使用KVO
来监听对象属性的变化,并及时做出响应,即当指定的被观察的对象的属性被修改后,KVO
会自动通知相应的观察者,那么KVO
与NSNotificatioCenter
有什么区别呢?
- 相同点
- 1、两者的实现原理都是
观察者模式
,都是用于监听 - 2、都能
实现一对多
的操作
- 1、两者的实现原理都是
- 不同点
- 1、
KVO
只能用于监听对象属性的变化,并且属性名都是通过NSString
来查找,编译器不会帮你检测对错和补全,纯手敲会比较容易出错 - 2、
NSNotification
的发送监听(post
)的操作我们可以控制,KVO
由系统控制 - 3、
KVO
可以记录新旧值
变化
- 1、
二、KVO使用及注意点
①.基本使用
KVO使用三部曲:
-
注册观察者
[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 { if ([keyPath isEqualToString:@"name"]) NSLog(@"%@", change); }
-
移除观察者
[self.person removeObserver:self forKeyPath:@"name"];
②.context的使用
Key-Value Observing Programming Guide
是这么描述context
的
消息中的上下文指针包含任意数据,这些数据将在相应的更改通知中传递回观察者;您可以指定NULL
并完全依赖键路径字符串来确定更改通知的来源,但是这种方法可能会导致对象的父类由于不同的原因而观察到相同的键路径,因此可能会出现问题;一种更安全,更可扩展的方法是使用上下文确保您收到的通知是发给观察者的,而不是超类的.
这里提出一个假想,如果父类中有个name
属性,子类中也有个name
属性,两者都注册对name
的观察,那么仅通过keyPath
已经区分不了是哪个name
发生变化了,现有两个解决办法
- 多加一层判断——判断
object
,显然为了满足业务需求而去增加逻辑判断是不可取的 - 使用
context
传递信息,更安全、更可扩展
通俗的讲,context
上下文主要是用于区分不同对象的同名属性
,从而在KVO
回调方法中可以直接使用context
进行区分,可以大大提升性能,以及代码的可读性
context
使用总结:
-
不使用
context
作为观察值// context是 void * 类型,应该填 NULL 而不是 nil [self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
-
使用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); } }
③.移除通知的必要性
也许在日常开发中你觉得是否移除通知都无关痛痒,但是不移除会带来潜在的隐患
以下是一段没有移除观察者的代码,页面push前后、键值改变前后都很正常
- (void)viewDidLoad {
[super viewDidLoad];
self.student = [TCJStudent new];
[self.student addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld) context:ChildNameContext];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if ([keyPath isEqualToString:@"name"]) NSLog(@"%@", change);
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
self.student.name = @"葵花宝典";
}
但当把TCJStudent
以单例的形式创建后,先点击屏幕在push到TCJDetailViewController
页面点击屏幕后pop返回上一页面再次点击屏幕程序就崩溃了
这是因为没有移除观察,单例对象
依旧存在,再次进来点击屏幕时就会报出野指针
错误了
移除了观察者之后便不会发生这种情况了——移除观察者是必要的
苹果官方推荐的方式是 —— 在
init
的时候进行addObserver
,在dealloc
时removeObserver
,这样可以保证add
和remove
是成对出现的,这是一种比较理想的使用方式
④.手动触发键值观察
有时候业务需求需要观察某个属性值,一会儿要观察了,一会又不要观察了...如果把KVO
三部曲整体去掉、再整体添上,必然又是一顿繁琐而又不必要的工作,好在KVO
中有两种办法可以手动触发键值观察:
-
将被观察者的
automaticallyNotifiesObserversForKey
返回NO(可以只对某个属性设置)-- 自动开关,返回NO,就监听不到,返回YES,表示监听+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key { if ([key isEqualToString:@"name"]) { return NO; } return [super automaticallyNotifiesObserversForKey:key]; }
-
使用
willChangeValueForKey
、didChangeValueForKey
重写被观察者的属性的setter
方法这两个方法用于通知系统该
key
的属性值即将和已经变更了- (void)setName:(NSString *)name { [self willChangeValueForKey:@"name"]; _name = name; [self didChangeValueForKey:@"name"]; }
两种方式使用的排列组合如下,可以自由组合如何使用
最近发现[self willChangeValueForKey:name]和[self willChangeValueForKey:"name"]两种写法是不同的结果:重写setter方法取属性值操作不会额外发送通知;而使用“name”会额外发送一次通知
⑤.键值观察 一对多
KVO
观察中的一对多
,意思是通过注册一个KVO
观察者,可以监听多个属性的变化
比如有一个下载任务的需求,根据总下载量totalData
和当前已下载量writtenData
来得到当前下载进度downloadProgress
,这个需求就有两种实现方式:
- 分别观察总下载量
totalData
和当前已下载量writtenData
两个属性,其中一个属性发生变化时计算求值当前下载进度downloadProgress
- 实现
keyPathsForValuesAffectingValueForKey
方法,并观察downloadProgress
属性
只要总下载量totalData
或当前已下载量writtenData
任意发生变化,keyPaths=downloadProgress
就能收到监听回调
+ (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{
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];
}
⑥.KVO观察 可变数组
如题:TCJPerson
下有一个可变数组dataArray
,现观察之,问点击屏幕是否打印?
- (void)viewDidLoad {
[super viewDidLoad];
self.person = [TCJPerson new];
[self.person addObserver:self forKeyPath:@"dataArray" options:(NSKeyValueObservingOptionNew) context:NULL];
}
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
if ([keyPath isEqualToString:@"dataArray"]) NSLog(@"%@", change);
}
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
[self.person.dataArray addObject:@"1"];
}
答:不会
分析:
-
KVO
是建立在KVC
的基础上的,而可变数组直接添加是不会调用Setter
方法 - 可变数组
dataArray
没有初始化,直接添加会报错
// 初始化可变数组
self.person.dataArray = @[].mutableCopy;
// 调用setter方法
[[self.person mutableArrayValueForKey:@"dataArray"] addObject:@"2"];
三、KVO原理——isa-swizzling
①.官方解释
Key-Value Observing Programming Guide
中有一段底层实现原理的叙述
-
KVO
是使用isa-swizzling
技术实现的 - 顾名思义,
isa
指针指向维护分配表的对象的类,该分派表实质上包含指向该类实现的方法的指针以及其他数据 - 在为对象的属性注册观察者时,将修改观察对象的
isa
指针,指向中间类而不是真实类。isa
指针的值不一定反映实例的实际类 - 您永远不应依靠
isa
指针来确定类成员身份。相反,您应该使用class
方法来确定对象实例的类
②.代码探索
这段话说的云里雾里的,还是敲代码见真章吧
- 注册观察者之前:类对象为
TCJPerson
,实例对象isa
指向TCJPerson
- 注册观察者之后:类对象为
TCJPerson
,实例对象isa
指向NSKVONotifying_TCJPerson
从这两图中可以得出一个结论:观察者注册前后TCJPerson
类没发生变化,但实例对象的isa
指向发生变化
那么这个动态生成的中间类NSKVONotifying_TCJPerson
和TCJPerson
是什么关系呢?
在注册观察者前后分别调用打印子类的方法——发现NSKVONotifying_TCJPerson
是TCJPerson
的子类
③.动态子类探索
➊首先得明白动态子类观察的是什么?下面观察属性变量nickName
和成员变量name
来找区别
两个变量同时发生变化,但只有属性变量
监听到回调——说明动态子类观察的是setter
方法
➋通过runtime-API
打印一下动态子类和观察类的方法
#pragma mark - 遍历方法-ivar-property
- (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);
}
通过打印可以看出:
-
TCJPerson
类中的方法没有改变(imp实现地址没有变化) -
NSKVONotifying_TCJPerson
类中重写了父类TCJPerson
的dealloc
方法 -
NSKVONotifying_TCJPerson
类中重写了基类NSObject
的class
方法和_isKVOA
方法- 重写的
class
方法可以指回TCJPerson
类
- 重写的
-
NSKVONotifying_TCJPerson
类中重写了父类TCJPerson
的setNickName
方法- 因为子类只继承、不重写是不会有方法imp的,调用方法时会问父类要方法实现
- 且两个
setNickName
的地址指针不一样 - 每观察一个
属性变量
就重写一个setter
方法(可自行论证)
➌dealloc
之后isa
指向谁?——指回原类
➍dealloc
之后动态子类会销毁吗?——不会
页面pop后再次push进来打印TCJPerson
类,子类NSKVONotifying_TCJPerson
类依旧存在
➎automaticallyNotifiesObserversForKey
是否会影响动态子类生成——会
动态子类会根据观察属性的automaticallyNotifiesObserversForKey
的布尔值来决定是否生成
④.总结
1.automaticallyNotifiesObserversForKey
为YES
时注册观察属性会生成动态子类NSKVONotifying_XXX
2.动态子类观察的是setter
方法
3.动态子类重写了观察属性的setter
方法、dealloc
、class
、_isKVOA
方法
- setter
方法用于观察键值
- dealloc
方法用于释放时对isa
指向进行操作
- class
方法用于指回动态子类的父类
- _isKVOA
用来标识是否是在观察者状态的一个标志位
4.dealloc
之后isa
指向元类
5.dealloc
之后动态子类不会销毁
四、自定义KVO
根据KVO的官方文档和上述结论,我们将自定义KVO——下面的自定义会有runtime-API的使用和接口设计思路的讲解,最终的自定义KVO能满足基本使用的需求但仍不完善。系统的KVO回调和自动移除观察者都与注册逻辑分层,自定义的KVO将使用block回调和自动释放来优化这一点不足
新建一个NSObject+TCJKVO
的分类,开放注册观察者方法
-(void)cj_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath block:(TCJKVOBlock)block;
①.注册观察者
1.判断当前观察值keypath
是否存在/setter
方法是否存在
一开始想的是判断属性是否存在,虽然父类的属性不会对子类造成影响,但是分类中的属性虽然没有setter
方法,但是会添加到propertiList
中去——最终改为去判断setter
方法
if (keyPath == nil || keyPath.length == 0) return;
// if (![self isContainProperty:keyPath]) return;
if (![self isContainSetterMethodFromKeyPath:keyPath]) return;
// 判断属性是否存在
- (BOOL)isContainProperty:(NSString *)keyPath {
unsigned int number;
objc_property_t *propertiList = class_copyPropertyList([self class], &number);
for (unsigned int i = 0; i < number; i++) {
const char *propertyName = property_getName(propertiList[i]);
NSString *propertyString = [NSString stringWithUTF8String:propertyName];
if ([keyPath isEqualToString:propertyString]) return YES;
}
free(propertiList);
return NO;
}
/// 判断setter方法
- (BOOL)isContainSetterMethodFromKeyPath:(NSString *)keyPath {
Class superClass = object_getClass(self);
SEL setterSeletor = NSSelectorFromString(setterForGetter(keyPath));
Method setterMethod = class_getInstanceMethod(superClass, setterSeletor);
if (!setterMethod) {
NSLog(@"没找到该属性的setter方法%@", keyPath);
return NO;
}
return YES;
}
2.判断观察属性的automaticallyNotifiesObserversForKey
方法返回的布尔值
BOOL isAutomatically = [self fx_performSelectorWithMethodName:@"automaticallyNotifiesObserversForKey:" keyPath:keyPath];
if (!isAutomatically) return;
// 动态调用类方法
- (BOOL)fx_performSelectorWithMethodName:(NSString *)methodName keyPath:(id)keyPath {
if ([[self class] respondsToSelector:NSSelectorFromString(methodName)]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
BOOL i = [[self class] performSelector:NSSelectorFromString(methodName) withObject:keyPath];
return i;
#pragma clang diagnostic pop
}
return NO;
}
3.动态生成子类,添加class
方法指向原先的类
// 动态生成子类
Class newClass = [self createChildClassWithKeyPath:keyPath];
- (Class)createChildClassWithKeyPath:(NSString *)keyPath {
NSString *oldClassName = NSStringFromClass([self class]);
NSString *newClassName = [NSString stringWithFormat:@"%@%@", kTCJKVOPrefix, oldClassName];
Class newClass = NSClassFromString(newClassName);
// 防止重复创建生成新类
if (newClass) return newClass;
// 申请类
newClass = objc_allocateClassPair([self class], newClassName.UTF8String, 0);
// 注册类
objc_registerClassPair(newClass);
// class的指向是TCJPerson
SEL classSEL = NSSelectorFromString(@"class");
Method classMethod = class_getInstanceMethod([self class], classSEL);
const char *classTypes = method_getTypeEncoding(classMethod);
class_addMethod(newClass, classSEL, (IMP)cj_class, classTypes);
return newClass;
}
4.isa
重指向——使对象的isa
的值指向动态子类
object_setClass(self, newClass);
5.保存信息
由于可能会观察多个属性值,所以以属性值-模型的形式一一保存在数组中
typedef void(^TCJKVOBlock)(id observer,NSString *keyPath,id oldValue,id newValue);
@interface TCJKVOInfo : NSObject
@property (nonatomic, weak) NSObject *observer;
@property (nonatomic, copy) NSString *keyPath;
@property (nonatomic, copy) TCJKVOBlock handleBlock;
@end
@implementation TCJKVOInfo
- (instancetype)initWitObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath handleBlock:(TCJKVOBlock)block {
if (self=[super init]) {
_observer = observer;
_keyPath = keyPath;
_handleBlock = block;
}
return self;
}
@end
// 保存信息
TCJKVOInfo *info = [[TCJKVOInfo alloc] initWitObserver:observer forKeyPath:keyPath handleBlock:block];
NSMutableArray *mArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kTCJKVOAssiociateKey));
if (!mArray) {
mArray = [NSMutableArray arrayWithCapacity:1];
objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(kTCJKVOAssiociateKey), mArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
[mArray addObject:info];
②.添加setter方法并回调
往动态子类添加setter
方法
- (Class)createChildClassWithKeyPath:(NSString *)keyPath {
...
// 添加setter
SEL setterSEL = NSSelectorFromString(setterForGetter(keyPath));
Method setterMethod = class_getInstanceMethod([self class], setterSEL);
const char *setterTypes = method_getTypeEncoding(setterMethod);
class_addMethod(newClass, setterSEL, (IMP)cj_setter, setterTypes);
return newClass;
}
setter方法的具体实现
static void cj_setter(id self,SEL _cmd,id newValue) {
NSString *keyPath = getterForSetter(NSStringFromSelector(_cmd));
id oldValue = [self valueForKey:keyPath];
// 改变父类的值 --- 可以强制类型转换
void (*cj_msgSendSuper)(void *,SEL , id) = (void *)objc_msgSendSuper;
struct objc_super superStruct = {
.receiver = self,
.super_class = class_getSuperclass(object_getClass(self)),
};
cj_msgSendSuper(&superStruct,_cmd,newValue);
// 信息数据回调
NSMutableArray *mArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kTCJKVOAssiociateKey));
for (FXKVOInfo *info in mArray) {
if ([info.keyPath isEqualToString:keyPath] && info.handleBlock) {
info.handleBlock(info.observer, keyPath, oldValue, newValue);
}
}
}
③.销毁观察者
往动态子类添加dealloc
方法
- (Class)createChildClassWithKeyPath:(NSString *)keyPath {
...
// 添加dealloc
SEL deallocSEL = NSSelectorFromString(@"dealloc");
Method deallocMethod = class_getInstanceMethod([self class], deallocSEL);
const char *deallocTypes = method_getTypeEncoding(deallocMethod);
class_addMethod(newClass, deallocSEL, (IMP)cj_dealloc, deallocTypes);
return newClass;
}
由于页面释放时会释放持有的对象,对象释放时会调用dealloc,现在往动态子类的dealloc方法名中添加实现将isa指回去,从而在释放时就不会去找父类要方法实现
static void cj_dealloc(id self, SEL _cmd) {
Class superClass = [self class];
object_setClass(self, superClass);
}
但仅仅是这样还是不够的,只把isa指回去,但对象不会调用真正的dealloc方法,对象不会释放
出于这种情况,根据iOS之武功秘籍⑩: OC底层题目分析讲过的方法交换进行一波操作
- 取出基类NSObject的dealloc实现与cj_dealloc进行方法交换
- isa指回去之后继续调用真正的dealloc进行释放
- 之所以不在+load方法中进行交换,一是因为效率低,二是因为会影响到所有类
- (Class)createChildClassWithKeyPath:(NSString *)keyPath {
...
// 添加dealloc
// SEL deallocSEL = NSSelectorFromString(@"dealloc");
// Method deallocMethod = class_getInstanceMethod([self class], deallocSEL);
// const char *deallocTypes = method_getTypeEncoding(deallocMethod);
// class_addMethod(newClass, deallocSEL, (IMP)cj_dealloc, deallocTypes);
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[self cj_methodSwizzlingWithClass:[self class] oriSEL:NSSelectorFromString(@"dealloc") swizzledSEL:@selector(cj_dealloc)];
});
return newClass;
}
- (void)cj_dealloc {
Class superClass = [self class];
object_setClass(self, superClass);
[self cj_dealloc];
}
就这样自定义KVO将KVO三部曲用block形式合成一步
写在后面
和谐学习,不急不躁.我还是我,颜色不一样的烟火.
网友评论