iOS 设计模式(五)-KVO 详解
Posted
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了iOS 设计模式(五)-KVO 详解相关的知识,希望对你有一定的参考价值。
参考技术A KVO 的全称是 Key-Value Observing,俗称“键值观察/监听”,是苹果提供的一套事件通知机制,允许一个对象观察/监听另一个对象指定属性值的改变。当被观察对象属性值发生改变时,会触发 KVO 的监听方法来通知观察者。KVO 是在 MVC 应用程序中的各层之间进行通信的一种特别有用的技术。KVO 和 NSNotification 都是 ios 中观察者模式的一种实现。
KVO 可以监听单个属性的变化,也可以监听集合对象的变化。监听集合对象变化时,需要通过 KVC 的 mutableArrayValueForKey: 等可变代理方法获得集合代理对象,并使用代理对象进行操作,当代理对象的内部对象发生改变时,会触发 KVO 的监听方法。集合对象包含 NSArray 和 NSSet 。
先创建一个类,作为要监听的对象。
监听实现
KVO 主要用来做键值观察操作,想要一个值发生改变后通知另一个对象,则用 KVO 实现最为合适。斯坦福大学的 iOS 教程中有一个很经典的案例,通过 KVO 在 Model 和 Controller 之间进行通信。如图所示:
KVO 触发分为自动触发和手动触发两种方式。
如果是监听对象特定属性值的改变,通过以下方式改变属性值会触发 KVO:
如果是监听集合对象的改变,需要通过 KVC 的 mutableArrayValueForKey: 等方法获得代理对象,并使用代理对象进行操作,当代理对象的内部对象发生改变时,会触发 KVO。集合对象包含 NSArray 和 NSSet 。
普通对象属性或是成员变量使用:
NSArray 对象使用:
NSSet 对象使用:
observationInfo 属性是 NSKeyValueObserving.h 文件中系统通过分类给 NSObject 添加的属性,所以所有继承于 NSObject 的对象都含有该属性;
可以通过 observationInfo 属性查看被观察对象的全部观察信息,包括 observer 、 keyPath 、 options 、 context 等。
注册方法 addObserver:forKeyPath:options:context: 中的 context 可以传入任意数据,并且可以在监听方法中接收到这个数据。
context 作用:标签-区分,可以更精确的确定被观察对象属性,用于继承、 多监听;也可以用来传值。
KVO 只有一个监听回调方法 observeValueForKeyPath:ofObject:change:context: ,我们通常情况下可以在注册方法中指定 context 为 NULL ,并在监听方法中通过 object 和 keyPath 来判断触发 KVO 的来源。
但是如果存在继承的情况,比如现在有 Person 类和它的两个子类 Teacher 类和 Student 类,person、teacher 和 student 实例对象都对象的 name 属性进行观察。问题:
当 name 发生改变时,应该由谁来处理呢?
如果都由 person 来处理,那么在 Person 类的监听方法中又该怎么判断是自己的事务还是子类对象的事务呢?
这时候通过使用 context 就可以很好地解决这个问题,在注册方法中为 context 设置一个独一无二的值,然后在监听方法中对 context 值进行检验即可。
苹果的推荐用法:用 context 来精确的确定被观察对象属性,使用唯一命名的静态变量的地址作为 context 的值。可以为整个类设置一个 context ,然后在监听方法中通过 object 和 keyPath 来确定被观察属性,这样存在继承的情况就可以通过 context 来判断;也可以为每个被观察对象属性设置不同的 context ,这样使用 context 就可以精确的确定被观察对象属性。
context 优点:嵌套少、性能高、更安全、扩展性强。
context 注意点:
KVO 可以监听单个属性的变化,也可以监听集合对象的变化。监听集合对象变化时,需要通过 KVC 的 mutableArrayValueForKey: 等方法获得代理对象,并使用代理对象进行操作,当代理对象的内部对象发生改变时,会触发 KVO 的监听方法。集合对象包含 NSArray 和 NSSet 。(注意:如果直接对集合对象进行操作改变,不会触发 KVO。)
可以在被观察对象的类中重写 + (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key 方法来控制 KVO 的自动触发。
如果我们只允许外界观察 person 的 name 属性,可以在 Person 类如下操作。这样外界就只能观察 name 属性,即使外界注册了对 person 对象其它属性的监听,那么在属性发生改变时也不会触发 KVO。
也可以实现遵循命名规则为 + (BOOL)automaticallyNotifiesObserversOf<Key> 的方法来单一控制属性的 KVO 自动触发,<Key> 为属性名(首字母大写)。
使用场景:
使用 KVO 监听成员变量值的改变;
在某些需要控制监听过程的场景下。比如:为了尽量减少不必要的触发通知操作,或者当多个更改同时具备的时候才调用属性改变的监听方法。
由于 KVO 的本质,重写 setter 方法来达到可以通知所有观察者对象的目的,所以只有通过 setter 方法或 KVC 方法去修改属性变量值的时候,才会触发 KVO,直接修改成员变量不会触发 KVO。
当我们要使用 KVO 监听成员变量值改变的时候,可以通过在为成员变量赋值的前后手动调用 willChangeValueForKey: 和 didChangeValueForKey: 两个方法来手动触发 KVO,如:
NSKeyValueObservingOptionPrior (分别在值改变前后触发方法,即一次修改有两次触发)的两次触发分别在 willChangeValueForKey: 和 didChangeValueForKey: 的时候进行的。
如果注册方法中 options 传入 NSKeyValueObservingOptionPrior ,那么可以通过只调用 willChangeValueForKey: 来触发改变前的那次 KVO,可以用于在属性值即将更改前做一些操作。
有时候我们可能会有这样的需求,KVO 监听的属性值修改前后相等的时候,不触发 KVO 的监听方法,可以结合 KVO 的自动触发控制和手动触发来实现。
例如:对 person 对象的 name 属性注册了 KVO 监听,我们希望在对 name 属性赋值时做一个判断,如果新值和旧值相等,则不触发 KVO,可以在 Person 类中如下这样实现,将 name 属性值改变的 KVO 触发方式由自动触发改为手动触发。
有些情况下我们想手动观察集合属性,下面以观察数组为例。
关键方法:
需要注意的是,根据 KVC 的 NSMutableArray 搜索模式:
有些情况下,一个属性的改变依赖于别的一个或多个属性的改变,也就是说当别的属性改了,这个属性也会跟着改变。
比如我们想要对 Download 类中的 downloadProgress 属性进行 KVO 监听,该属性的改变依赖于 writtenData 和 totalData 属性的改变。观察者监听了 downloadProgress ,当 writtenData 和 totalData 属性值改变时,观察者也应该被通知。以下有两种方法可以解决这个问题。
以上两个方法可以同时存在,且都会调用,但是最终结果会以 keyPathsForValuesAffectingValueForKey: 为准。
以上方法在观察集合属性时就不管用了。例如,假如你有一个 Department 类,它有一个装有 Employee 类的实例对象的数组,Employee 类有 salary 属性。你希望 Department 类有一个 totalSalary 属性来计算所有员工的薪水,也就是在这个关系中 Department 的 totalSalary 依赖于所有 Employee 实例对象的 salary 属性。以下有两种方法可以解决这个问题。
有时候我们难以避免多次注册和移除相同的 KVO,或者移除了一个未注册的观察者,从而产生可能会导致 Crash 的风险。
三种解决方案: 黑科技防止多次添加删除KVO出现的问题
我们在对象添加监听之前分别打印对象类型
我们看到,添加监听后,使用 object_getClass 方法获取model类型时获取到的是 NSKVONotifying_DJModel 。
这里就产生了几个问题:
从打印结果可以看出, NSKVONotifying_DJModel 是 DJModel 的子类,说明我们添加了监听之后动态创建了一个 DJModel 的子类 NSKVONotifying_DJModel ,并将对象 DJModel 的类型更改为了 NSKVONotifying_DJModel 。
我们从源码看出,实例对象调用 class 方法会返回 isa 指针,类对象调用 class 方法会返回自己,通过 object_getClass 方法获取对象的类型也会返回 isa 指针。从源码上看model对象添加监听之后使用 class 和使用 object_getClass 方法获取到的类型应该是一样的,但是这里却不同,我们猜测在添加了监听之后在 NSKVONotifying_DJModel 中重写了 class 方法。
我们打印一下添加监听前后 class 方法的 IMP 地址来确认是否重写了 class 方法。
从打印结果可以看出,添加监听之后 class 方法的地址改变了,这验证了我们之前的猜想, NSKVONotifying_DJModel 类中重写了 class 方法。
我们监听对象时调用了 set 方法,我们对监听前后的 set 方法单独分析。
我们再添加监听前后分别打印 setName 方法的 IMP 地址。
通过打印结果可以看出 setName 方法也在 NSKVONotifying_DJModel 中被重写了,我们再使用lldb来看下 setName 具体是什么
第一个地址打印的是添加监听前 setName 方法的 IMP 地址,第二个打印的是添加监听后 setName 方法的 IMP 地址。
这里看出添加监听前 setName 对应的具体方法就是 setName ,但是添加监听后, setName 对应的鸡头方法却变成了 _NSSetObjectValueAndNotify 函数。
下面我们就来研究一下 _NSSetObjectValueAndNotify 函数。
从上面与KVO相关的方法中我们可以看出,每一种数据类型都对应了一个 setXXXValueAndNotify 函数。
不过这些函数的具体实现没有公布,所以内部构造这里还是不清楚。
但是我们知道,在调用 `setXXXValueAndNotify 函数的过程中会调用另外两个方法。
测试后得出了以下几个结论:
我们还可以利用这两个方法手动触发 observeValueForKeyPath 方法:
所以我们判断在 _NSSetObjectValueAndNotify 函数内部,在调用原来的 set 方法之前插入了 willChangeValueForKey 方法,在调用原来的 set 方法之后插入了 didChangeValueForKey 方法,并根据初始化时的枚举值决定调用 observeValueForKeyPath 的时机。
(1)添加监听时,会动态创建一个监听对象类型的子类,并将监听对象的 isa 指针指向新的子类。
(2)子类中重写了 class 和监听属性的 set 方法。
(3)重写 class 方法是为了不将动态创建的类型暴露出来。
(4)重写 set 方法是将 set 方法的具体实现替换成了与属性类型相关的 __NSSetXXXValueAndNotify 函数。
(5)在 __NSSetXXXValueAndNotify 函数内部在 set 方法前后分别插入了 willChangeValueForKey 和 didChangeValueForKey 这两个方法。
(6)根据添加监听时的枚举值决定调用 observeValueForKeyPath 的具体时机。
ios开发runtime学习五:KVC以及KVO,利用runtime实现字典转模型
一:KVC和KVO的学习
#import "StatusItem.h" /* 1:总结:KVC赋值:1:setValuesForKeysWithDictionary实现原理:遍历字典,得到所有的key,value值,再利用kvc, setVaue forkey来为value赋值 2: [item setValue:@"来自即刻笔记" forKey:@"source"],内部的底层实现, 1.首先去模型中查找有没有setSource,找到,直接调用赋值 [self setSource:@"来自即刻笔记"] 2.去模型中查找有没有source属性,有,直接访问属性赋值 source = value 3.去模型中查找有没有_source属性,有,直接访问属性赋值 _source = value 4.找不到,就会直接报错 setValue:forUndefinedKey:报找不到的错误 当系统找不到就会调用这个方法,报错- (void)setValue:(id)value forUndefinedKey:(NSString *)key可以重写此方法更改key值 3:KVC四个方法:利用kvc可以访问成员变量和属性,setValue,value为属性值,forKey,key为属性名,forKeyPath为键值路径,例如在model中有如下属性定义: @property (nonatomic, strong) BankAccount *account; keyPath: [zhangSan setValue:@150 forKeyPath:@"account.balance"]; - (id)valueForKey:(NSString *)key; - (id)valueForKeyPath:(NSString *)keyPath; - (void)setValue:(id)value forKey:(NSString *)key; - (void)setValue:(id)value forKeyPath:(NSString *)keyPath; 4:KVO:键值观察机制,用于对属性的value值的改变做监听:用法: @interface BankAccount : NSObject @property (nonatomic, assign) NSInteger balance; @end @interface Person : NSObject @property (nonatomic, strong) BankAccount *account; @end @implementation Person - (instancetype)init { ... // 注册Observer: [self.account addObserver:self forKeyPath:@"balance" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld context:nil]; ... } - (void)dealloc { // 不要忘了removeObserver [self.account removeObserver:self forKeyPath:@"balance"]; } // 属性变化的回调方法: - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if ([keyPath isEqualToString:@"balance"]) { NSLog(@"Balance was %@.", change[NSKeyValueChangeOldKey]); NSLog(@"Balance is %@ now.", change[NSKeyValueChangeNewKey]); } } @end - (void)testKVO { Person *zhangSan = [[Person alloc] initWithName:@"ZhangSan" andBalance:20]; // 无论是用点语法还是KVC的方法都会触发回调: zhangSan.account.balance = 150; [zhangSan setValue:@250 forKeyPath:@"account.balance"]; } */ @interface StatusItem () @property (nonatomic,copy)NSString *hello; @end @implementation StatusItem // 模型只保存最重要的数据,导致模型的属性和字典不能一一对应 + (instancetype)itemWithDict:(NSDictionary *)dict { StatusItem *item = [[self alloc] init]; // KVC:把字典中所有值给模型的属性赋值 [item setValuesForKeysWithDictionary:dict]; // 拿到每一个模型属性,去字典中取出对应的值,给模型赋值 // 从字典中取值,不一定要全部取出来 // MJExtension:字典转模型 runtime:可以把一个模型中所有属性遍历出来 // MJExtension:封装了很多层 // item.pic_urls = dict[@"pic_urls"]; // item.created_at = dict[@"created_at"]; // KVC原理: // 1.遍历字典中所有key,去模型中查找有没有对应的属性 [dict enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull value, BOOL * _Nonnull stop) { // 2.去模型中查找有没有对应属性 KVC // key:source value:来自即刻笔记 // [item setValue:@"来自即刻笔记" forKey:@"source"] [item setValue:value forKey:key]; }]; return item; } // 重写系统方法? 1.想给系统方法添加额外功能 2.不想要系统方法实现 // 系统找不到就会调用这个方法,报错 - (void)setValue:(id)value forUndefinedKey:(NSString *)key { } @end
二:利用runtime实现字典转模型
#import "ViewController.h" #import "NSDictionary+Property.h" #import "StatusItem.h" #import "NSObject+Model.h" @interface ViewController () @end /* 总结:1:项目中的文件都保存在mainBundle里,读取项目中的本地信息:[[NSBundle mainBundle] pathForResource:@"status.plist" ofType:nil]; 得到本地路径path,再看项目中的文件根节点是字典还是数组,再从中读取本地路径filer:dictionaryWithContentsOfFile读取 。若是获取网络端的路径:dictionaryWithContentsOfUrl */ @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; // 获取文件全路径 NSString *filePath = [[NSBundle mainBundle] pathForResource:@"status.plist" ofType:nil]; // 文件全路径 NSDictionary *dict = [NSDictionary dictionaryWithContentsOfFile:filePath]; // 设计模型,创建属性代码 => dict // [dict[@"user"] createPropertyCode]; // 字典转模型:KVC,MJExtension StatusItem *item = [StatusItem modelWithDict:dict]; } @end
#import <Foundation/Foundation.h> // 字典转模型 @interface NSObject (Model) + (instancetype)modelWithDict:(NSDictionary *)dict; @end
#import "NSObject+Model.h" #import <objc/message.h> /* 总结:MJExtension字典转模型的底层核心实现:runtime实现字典转模型。 1:因为模型model都继承NSObject,所以可以给系统类写分类进行拓展,子类继承NSObject,也就是继承了扩展的方法。所以模型转字典的方法考虑给NSObject写一个分类进行方法的拓展 2:在分类中若是对象方法,self指的是调用该方法的对象,类方法中self指的是调用该方法的类。方法的设计:类方法简单粗暴,直接用类去调用,字典转模型方法获得所转换的模型,不用创建对象,并且会将调用方法的类作为参数传进方法中(对象方法也如此) 3:原理:runtime:根据模型中属性,去字典中取出对应的value给模型属性赋值 1:先获取模型中所有成员变量 key 参数意义: // 获取哪个类的成员变量 // count:成员变量个数 int *类型 unsigned int count = 0; // 获取成员变量数组 Ivar *ivarList = class_copyIvarList(self, &count); 注意:1:int *count ,此count的类型为int *类型,当作为参数的时候,需要传入一个int *类型的指针,指针里存放的都是内存地址,也就是将地址作为参数传递,当方法执行完毕后,系统会拿到*count 进行赋值 int a = 2; int b = 3; int c = 4; int arr[] = {a,b,c}; int *p = arr; p[0]; NSLog(@"%d %d",p[0],p[1]); 2: 获取类里面属性 class_copyPropertyList(<#__unsafe_unretained Class cls#>, <#unsigned int *outCount#>) 获取类里面所有方法 class_copyMethodList(<#__unsafe_unretained Class cls#>, <#unsigned int *outCount#>)// 本质:创建谁的对象 3:获取模型中所有成员变量 key,Ivar:成员变量 以下划线开头,相当于一个数组 // 获取哪个类的成员变量 // count:成员变量个数 unsigned int count = 0; // 获取成员变量数组 Ivar *ivarList = class_copyIvarList(self, &count); 4:具体实现:获取成员变量名字:ivar_getName(ivar),属于c语言字符串,所以要进行UTF8编码 获取成员变量类型:ivar_getTypeEncoding(ivar) // 获取成员变量名字 NSString *ivarName = [NSString stringWithUTF8String:ivar_getName(ivar)]; // 获取成员变量类型 NSString *ivarType = [NSString stringWithUTF8String:ivar_getTypeEncoding(ivar)]; 注意:1:字符串替换:stringByReplacingOccurrencesOfString 2:字符串截取:substringFromIndex:包括该index substringToIndex:不包括该index 3:前后缀:hasPrefix前缀,hasSuffix:后缀 4:是否包含某个字符串:containString 5:字符串转换为Class:NSClassFromString: // 获取类 Class modelClass = NSClassFromString(ivarType); value = [modelClass modelWithDict:value]; 6:一般某个方法接受传递进来的参数的时候,要判断参数是否为空,为nil或是为空值,给某个值赋值的时候,也要判断该值是否存在: // 给模型中属性赋值 if (value) { [objc setValue:value forKey:key]; } */ @implementation NSObject (Model) // Ivar:成员变量 以下划线开头 // Property:属性 + (instancetype)modelWithDict:(NSDictionary *)dict { id objc = [[self alloc] init]; // runtime:根据模型中属性,去字典中取出对应的value给模型属性赋值 // 1.获取模型中所有成员变量 key // 获取哪个类的成员变量 // count:成员变量个数 unsigned int count = 0; // 获取成员变量数组 Ivar *ivarList = class_copyIvarList(self, &count); // 遍历所有成员变量 for (int i = 0; i < count; i++) { // 获取成员变量 Ivar ivar = ivarList[i]; // 获取成员变量名字 NSString *ivarName = [NSString stringWithUTF8String:ivar_getName(ivar)]; // 获取成员变量类型 NSString *ivarType = [NSString stringWithUTF8String:ivar_getTypeEncoding(ivar)]; // @\"User\" -> User ivarType = [ivarType stringByReplacingOccurrencesOfString:@"\"" withString:@""]; ivarType = [ivarType stringByReplacingOccurrencesOfString:@"@" withString:@""]; // 获取key NSString *key = [ivarName substringFromIndex:1]; // 去字典中查找对应value // key:user value:NSDictionary id value = dict[key]; // 二级转换:判断下value是否是字典,如果是,字典转换层对应的模型 // 并且是自定义对象才需要转换 if ([value isKindOfClass:[NSDictionary class]] && ![ivarType hasPrefix:@"NS"]) { // 字典转换成模型 userDict => User模型 // 转换成哪个模型 // 获取类 Class modelClass = NSClassFromString(ivarType); value = [modelClass modelWithDict:value]; } // 给模型中属性赋值 if (value) { [objc setValue:value forKey:key]; } } return objc; } void test(int *count){ *count = 3; } @end