kvo 实践使用总结

iOS 109 2017-11-07 23:07

上半年有段时间做了一个项目,项目中聊天界面用到了音频播放,涉及到进度条,当时做android时候处理的不太好,由于item复用导致进度条会被多个信息实体引用控制,虽然最后绕啊绕,也解决了,但是费了老大劲。所以做ios时候,就使用了kvo以尽量实现解耦。

使用kvo过程中,也是经历了一些坑。

本篇文章,学完第一二节,结合自己实践就能使用了。后面的章节,可以作为自己的拔高,嘿嘿

备注:写该篇文章也借鉴参考了许多大牛的文章,结合自己的实践,总结了一下。大牛勿喷。嘿嘿

一、KVO是什么?

  • KVO 是 Objective-C 对观察者设计模式的一种实现。【另外一种是:通知机制(notification)】;
  • KVO提供一种机制,指定一个被观察对象(例如A类),当对象某个属性(例如A中的字符串name)发生更改时,监听对象会获得通知,并作出相应处理;【且不需要给被观察的对象添加任何额外代码,就能使用KVO机制】
    在MVC设计架构下的项目,KVO机制很适合实现mode模型和view视图之间的通讯。

例如:代码中,在模型类A创建属性数据,在控制器中创建观察者,一旦属性数据发生改变就收到观察者收到通知,通过KVO再在控制器使用回调方法处理实现视图B的更新;

KVC与KVO的不同

KVC(键值编码),即Key-Value Coding,一个非正式的Protocol,使用字符串(键)访问一个对象实例变量的机制。而不是通过调用Setter、Getter方法等显式的存取方式去访问。
KVO(键值监听),即Key-Value Observing,它提供一种机制,当指定的对象的属性被修改后,对象就会接受到通知,前提是执行了setter方法、或者使用了KVC赋值。

和notification(通知)的区别

notification比KVO多了发送通知的一步。
两者都是一对多,但是对象之间直接的交互,notification明显得多,需要notificationCenter来做为中间交互。而KVO如我们介绍的,设置观察者->处理属性变化,至于中间通知这一环,则隐秘多了,只留一句“交由系统通知”,具体的可参照以上实现过程的剖析。

notification的优点是监听不局限于属性的变化,还可以对多种多样的状态变化进行监听,监听范围广,例如键盘、前后台等系统通知的使用也更显灵活方便。

  • 推荐文章:通知机制

与delegate的不同

和delegate一样,KVO和NSNotification的作用都是类与类之间的通信。但是与delegate不同的是:
这两个都是负责发送接收通知,剩下的事情由系统处理,所以不用返回值;而delegate 则需要通信的对象通过变量(代理)联系;
delegate一般是一对一,而这两个可以一对多。

二、kvo简单使用

1:注册观察者,实施监听;

  • 被观察对象必须能支持kvc机制——所有NSObject的子类都支持这个机制
  • 必须用 被观察对象 的 addObserver:forKeyPath:options:context: 方法注册观察者
  • 观察者 必须实现 observeValueForKeyPath:ofObject:change:context: 方法
[self.model addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:nil];

- observer 指观察者

- keyPath 表示被观察者的属性
 
- options 决定了提供给观察者change字典中的具体信息有哪些。 【见options解析】

- context 这个参数可以是一个 C指针,也可以是一个 对象引用,它可以作为这个context的唯一标识,也可以提供一些数据给观察者。因为你传进去是啥,回调时候还是回传的还是啥

:options解析

typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
    表示监听对象的新值(变化后的值),change字典中会包含有该key的键值对,通过该key,就可以取到属性变化后的值
    NSKeyValueObservingOptionNew = 0x01,
    
    表示监听对象的旧值(变化前的值),change字典中会包含有该key的键值对,通过该key,就可以取到属性变化前的值
    NSKeyValueObservingOptionOld = 0x02,
    
    在注册观察者的方法return的时候就会发出一次通知。比如:在viewDidLoad中注册的监听,那viewDidLoad方法运行完,通知就发出去了
    NSKeyValueObservingOptionInitial NS_ENUM_AVAILABLE(10_5, 2_0) = 0x04,
    
    会在值发生改变前发出一次通知,当然改变后的通知依旧还会发出,也就是每次change都会有两个通知
    NSKeyValueObservingOptionPrior NS_ENUM_AVAILABLE(10_5, 2_0) = 0x08
};
  • 注册监听,options入参是个枚举,该入参跟监听回调中的change呼应。。并且,以上options入参时候是可以用 | 或运算进行多选的。
    • 例如:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld。。那在change字典中就会包含属性变化前后的值。。
    • 注意:通过多options监听属性的时候,例如上,并不是回到一次老值,再回调一次新值,,而是新老值都是在change字典中的。。

2:监听回调

  • 观察者实现方法都一样:observeValueForKeyPath:ofObject:change:context: 就这一个方法
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
    - keyPath:你所观察对象的属性
    - object:你所观察的对象
    - change:你所观察对象属性值的变化
}

:change解析

NSKeyValueChangeKey枚举

监听回调中,通过key获取监听属性的变化值。如下枚举:

FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeKindKey;
FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeNewKey;
FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeOldKey;
FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeIndexesKey;
FOUNDATION_EXPORT NSKeyValueChangeKey const NSKeyValueChangeNotificationIsPriorKey
  • NSKeyValueChangeKindKey

    • 这个key包含的value是一个 NSNumber 里面是一个 int
      (有点绕:value是[NSNumber numberWithInt:xxx]),
      与之对应的是 NSKeyValueChange 的枚举
  • NSKeyValueChangeNewKey

    • 跟options中的对应
  • NSKeyValueChangeOldKey

    • 跟options中的对应
  • NSKeyValueChangeIndexesKey

    • 当 NSKeyValueChangeKindKey 的结果是 NSKeyValueChangeInsertion,
      NSKeyValueChangeRemoval 或 NSKeyValueChangeReplacement 的时候,
      这个key的value是一个NSIndexSet,包含了发生insert,remove,replace的对象的索引集合
  • NSKeyValueChangeNotificationIsPriorKey

    • 这个key包含了一个 NSNumber,里面是一个布尔值,如果在注册时 options 中有
      NSKeyValueObservingOptionPrior,那么在前一个通知中的 change 中就会
      有这个key的value, 我们可以这样来判断是不是在改变前的通知[change[NSKeyValueChangeNotificationIsPriorKey] boolValue] ==
      YES;】

    说明: change是个字典,ios中dic获取值通常用valueForKey或objectFroKey,,以上方式也可,dic[@"key"]..方便快捷。大家可以了解一下,不失为一种方式。。

NSKeyValueChange枚举

typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
    NSKeyValueChangeSetting = 1,
    NSKeyValueChangeInsertion = 2,
    NSKeyValueChangeRemoval = 3,
    NSKeyValueChangeReplacement = 4,
};
  • 当 change[NSKeyValueChangeKindKey] 是 NSKeyValueChangeSetting 的时候,说明被观察属性的setter方法被调用了
    • Insert, Remove, Replace:被观察属性是集合类型,且对它进行了 insert,remove,replace 操作的时候会返回这三种Key

:context解析

context作用一般都被忽略了。主要还是平常使用kvo都是简单的订阅-响应-移除。很少涉及到 多订阅-响应-多移除 或 多订阅-多响应-多移除。。。在以下的 三、kvo注意事项中有详解

3:移除观察者

你可以通过 removeObserver:forKeyPath: 或 removeObserver:forKeyPath:context: 方法来移除一个观察。

注意:如果你的 context 是一个 对象,你必须在移除观察之前持有它的强引用。当移除了观察后,观察者对象再也不会受到这个 keyPath 的通知。

三、kvo使用注意事项

:注册监听

  • 多次添加相同的监听
    • 也就是添加过的监听,都要挨个移除。。所以这一点,在cell中使用时候要特别注意。因为cell多次运行,监听可能就是多次添加
    • 效果如下图
kvo 实践使用总结-JEESNS
observation.png

:响应

  • kvo触发是严格依赖kvc机制的。简单来说就是触发kvo必须是kvc方式给属性赋值。。

    • 反例:_name = @"qkn"..这种是不会触发响应的。。。
    • 因为没有调用属性的setter方法,所以也就不会触发notify,,kvo原理深入分析中有讲kvo的实现原理
  • 由于监听回调是一个函数,可能有多个监听,所以,比较好的逻辑是,通过object和keypath过滤出来你要监听的对象-属性

  • KVO严重依赖string,换句话说,KVO中的keyPath必须是NSString这个事实使得编译器没办法在编译阶段将错误的keyPath给找出来;譬如很容易将「contentSize」写成「contentsize」;

    • 方案一:使用NSStringFromSelector(SEL aSelector)方法,即改@"contentSize"为NSStringFromSelector(@selector(contentSize))
    • 方案二:#define varName(var) [NSString stringWithFormat:@"%s",#var]。。使用:varName(属性名)。。
  • 对于Objective-C,很多时候runtime系统都会自动帮助处理superclass的方法。但对于KVO不会这样,所以为了保证父类(父类可能也会自己observe处理嘛)的observe事务也能被处理。所以要注意:在过滤到自己监听的属性后,要有个else分支,去处理:[superobserveValueForKeyPath:keyPath ofObject:object change:change context:context];

  • 针对上面那个问题,如果业务比较复杂,多类监听了同一对象或爷父子孙类监听了同一对象。怎么整?

    • 原则:谁的事儿谁负责
    • 所以,用到context,类注册监听时候,传一个独一无二的值。建议把自己的类名传进去。在回调监听时候,就可以通过context进行检验过滤了
    • 但是如果一个类监听一个对象的多个属性呢?传的context也不够用了。还有object和keyPath呢。。这三个参数已经可以确定独一无二的监听

:移除监听

  • 有两个方法:
    • 建议用上面的。注册,响应,取消。方法保持一样
-(void)removeObserver:(NSObject*)observer forKeyPath:(NSString*)keyPath context:(void *)context;

-(void)removeObserver:(NSObject*)observer forKeyPath:(NSString*)keyPath;
  • 我们一般会在dealloc中进行removeObserver操作(这也是Apple所推荐的)

  • 取消订阅可能会crash:移除一个不存在的监听

    • 三种解决方案:iOS拦截系统KVO监听,防止多次删除和添加
    • 常用 @try @catch
  • 多次remove相同的监听会导致crash

    • 解决方案同上
    • 另外:同一个对象同一属性的多次监听(添加顺序:123),默认移除时候,也是要多次移除(移除顺序321)。
    • 当然也可以指定context。例如:注册监听时候context入参为@“1”,那么移除时候,就可以指定context为@“1”。并别这不是比对指针,而是值。
kvo 实践使用总结-JEESNS
context.png
所以,如果使用中用到了context传的是字符串,索性也就把context值提出来作为公共的独一无二的,避免像keyPath入参一样,误写了。但是如果是c指针,或对象引用就另说了
  • 如果某对象被释放时候,还有对象在监听它,也会报错
reason: 'An instance 0x7fed3ef6e170 of class SecVC was deallocated while key value observers were still registered with it. Current observation info: <NSKeyValueObservationInfo 0x600000038300> (
<NSKeyValueObservance 0x60000004ff60: Observer: 0x7fed3ef6e170, Key path: changeColor, Options: <New: NO, Old: NO, Prior: NO> Context: 0x0, Property: 0x60000005b720>

解决方案:

Facebook开源一个库,KVOController。移除监听不用再自己管理

    • 解析:http://www.JEESNS.com/p/4c0c36b88db6
    • 使用:http://www.cnblogs.com/cocoajin/p/3600634.html
    • github: https://github.com/facebook/KVOController

四、kvo实现原理

原理

虽然ios不开源,官方api说的也很有限,但是kvo原理已经被大家通过“黑科技”摸透了

  • KVO在Apple中的API文档如下:
Automatic key-value observing is implemented using a technique called isa-swizzling… 
When an observer is registered for an attribute of an object the isa pointer of the observed object is modified, 
pointing to an intermediate class rather than at the true class …

简单翻译:在我们对某个对象完成监听的注册后,编译器会修改监听对象(下文中原理验证中的test对象)的isa指针,让这个指针指向一个新生成的中间类。从某个意义上来说,这是一场骗局。

  • 引自sindri的小巢的iOS开发-KVO的奥秘

这里要说明的是isa这个指针,isa是一个Class类型的指针,对象的首地址一般是isa变量,同时isa又保存了对象的类对象的首地址。我们通过object_getClass方法来获取这个对象的元类,即是对象的类对象的类型(正常来说,class方法内部的实现就是获取这个isa保存的对象的类型,在kvo的实现中苹果对被监听对象的class方法进行了重写隐藏了实现)。class方法是获得对象的类型,虽然这两个返回的结果是一样的,但是两个方法在本质上得到的结果不是同一个东西
在oc中,规定了只要拥有isa指针的变量,通通都属于对象。上面的objc_object表示的是NSObject这个类的结构体表示,因此oc不允许出现非NSObject子类的对象(block是一个特殊的例外)*
当然了,苹果并不想讲述更多的实现细节,但是我们可以通过运行时机制来完成一些有趣的调试。

  • 原理简析:引自滴滴构架师 sunnyxx 的一篇文章 objc kvo简单探索
    • 当一个object有观察者时,动态创建这个object的类的子类
    • 对于每个被观察的property,重写其set方法
    • 在重写的set方法中调用- willChangeValueForKey:和- didChangeValueForKey:通知观察者
    • 当一个property没有观察者时,删除重写的方法
    • 当没有observer观察任何一个property时,删除动态创建的子类

原理验证

(lldb)po xxx.class -> 对象的类型

(lldb)po object-getClass(xxx) -> 对象的类对象的类型

1:声明对象

kvo 实践使用总结-JEESNS
debug1.png

结果:

kvo 实践使用总结-JEESNS
result1.png

2:注册监听

kvo 实践使用总结-JEESNS
debug2.png

结果:

kvo 实践使用总结-JEESNS
result2.png

3:移除监听

kvo 实践使用总结-JEESNS
debug3.png

结果:

kvo 实践使用总结-JEESNS
result3.png

上面的结果说明,在test对象被观察时,framework使用runtime动态创建了一个Sark类的子类NSKVONotifying_Test
而且为了隐藏这个行为,NSKVONotifying_Sark重写了- class方法返回之前的类,就好像什么也没发生过一样
但是使用object_getClass()时就暴露了,因为这个方法返回的是这个对象的isa指针,这个指针指向的一定是个这个对象的类对象

拓展:类 动态创建后,观察一下这个动态中间类实现的方法

ios 代码库。。大家可以积累下

NSObject+DLIntrospection :: 它封装了打印一个类的方法、属性、协议等常用调试方法,一目了然。

po [object_getClass(test) instanceMethods] -> 打印类内部实现方法

kvo 实践使用总结-JEESNS
debug2.png
kvo 实践使用总结-JEESNS
kvomethods.png

说明:

    • setxxx 最主要的重写方法,set值时调用通知函数, 接下来会深入分析重写setter方法
    • class 隐藏自己必备啊,返回原来类的class。。有了这个方法,你就不会知道,系统已经创建了中间类NSKVONotifying_xxx
    • dealloc 做清理犯罪现场工作。。既然实现了一个类,里面做的操作,产生的垃圾啥的,都要释放回收
    • _isKVOA 这就是内部使用的标示了,判断这个类有没被KVO动态生成子类

原理深入分析

Apple 使用了 isa 混写(isa-swizzling)来实现 KVO 。当观察对象A时,KVO机制动态创建一个新的名为: NSKVONotifying_A的新类,该类继承自对象A的本类,且KVO为NSKVONotifying_A重写观察属性的setter 方法,setter 方法会负责在调用原 setter 方法之前和之后,通知所有观察对象属性值的更改情况。

(备注: isa 混写(isa-swizzling)isa:is a kind of ; swizzling:混合,搅合;)

①NSKVONotifying_A类剖析:在这个过程,被观察对象的 isa 指针从指向原来的A类,被KVO机制修改为指向系统新创建的子类 NSKVONotifying_A类,来实现当前类属性值改变的监听;

所以当我们从应用层面上看来,完全没有意识到有新的类出现,这是系统“隐瞒”了对KVO的底层实现过程,让我们误以为还是原来的类。但是此时如果我们创建一个新的名为“NSKVONotifying_A”的类(),就会发现系统运行到注册KVO的那段代码时程序就崩溃,因为系统在注册监听的时候动态创建了名为NSKVONotifying_A的中间类,并指向这个中间类了。

(isa 指针的作用:每个对象都有isa 指针,指向该对象的类,它告诉 Runtime 系统这个对象的类是什么。所以对象注册为观察者时,isa指针指向新子类,那么这个被观察的对象就神奇地变成新子类的对象(或实例)了。) 因而在该对象上对 setter 的调用就会调用已重写的 setter,从而激活键值通知机制。

—>我猜,这也是KVO回调机制,为什么都俗称KVO技术为黑魔法的原因之一吧:内部神秘、外观简洁。

②子类setter方法剖析:KVO的键值观察通知依赖于 NSObject 的两个方法:willChangeValueForKey:和 didChangevlueForKey:,在存取数值的前后分别调用2个方法:

被观察属性发生改变之前,willChangeValueForKey:被调用,通知系统该 keyPath 的属性值即将变更;当改变发生后, didChangeValueForKey: 被调用,通知系统该 keyPath 的属性值已经变更;之后, observeValueForKey:ofObject:change:context: 也会被调用。且重写观察属性的setter 方法这种继承方式的注入是在运行时而不是编译时实现的。

KVO为子类的观察者属性重写调用存取方法的工作原理在代码中相当于:

-(void)setName:(NSString *)newName
{
    [self willChangeValueForKey:@"name"];    //KVO在调用存取方法之前总调用
    [super setValue:newName forKey:@"name"]; //调用父类的存取方法
    [self didChangeValueForKey:@"name"];     //KVO在调用存取方法之后总调用
}

系统重写了setter方法:如何获知呢?

在你监听的属性所在的类中,重写willChangeValueForKey 和 didChangeValueForKey。。在你注册完监听,改变监听属性值的时候,看能不能走下面的方法就ok了

- (void)willChangeValueForKey:(NSString *)key {
    NSLog(@"%@", NSStringFromSelector(_cmd));
    [super willChangeValueForKey:key];
}

- (void)didChangeValueForKey:(NSString *)key {
    NSLog(@"%@", NSStringFromSelector(_cmd));
    [super didChangeValueForKey:key];
}

五、kvo进阶使用

监听readOnly属性

如果在开发中,不幸遇到要对readonly的属性进行kvo监听,那就只能求心理阴影面积了。。因为readonly的属性没有setter方法呀。那怎么去触发kvo通知。
注意: 以下方案只针对自己创建的类的属性有效,对于系统的属性无效..亲测,使用分类,通过_xxx拿到系统的属性,还是不行。哈哈。。说到底,那还是系统的。岂能让你乱动

方案一:
系统怎么实现的,咱也怎么实现。。在给属性赋值前后,模仿系统的做法,如下:

[self willChangeValueForKey:@"xxx"];

_xxx = xxx;

[self didChangeValueForKey:@"xxx"];

方案二:

[被观察者 setValue:xxx forKey:@"xxx"];

监听数组变化

  • 参考:JasonEVA的iOS KVO方式监听数组变化方法

iOS默认不支持对数组的KVO,因为普通方式监听的对象的地址的变化,而数组地址不变,而是里面的值发生了改变。
亲测:

kvo 实践使用总结-JEESNS
objectAddress.png
  • 该实践并没有什么实际作用,项目中不适宜这么用。除非有特殊需求,这么整也算是一种方案

使用方法:

1:创建继承object类:一般就是创建一个模型,里面的属性是你要监听的数组

2:注册监听:[_object addObserver:forKeyPath:options:context]

3:改变数组,触发监听:[_object mutableArrayValueForKey:xxx] addObject:xxx]

4:响应监听:observeValueForKeyPath~

注意: 第一步:初始化要监听的数组,必须通过kvc方式。否则初始化不了。

1: 新建数组初始化
NSDictionary *dic = [NSDictionary dictionaryWithObject:[NSMutableArray arrayWithCapacity:0] forKey:@"数组属性名"];  

self.model = [[model alloc] initWithDic:dic];
        
2: kvc方式,给模型中数组初始化
-(id)initWithDic:(NSDictionary *)dic  
{  
    self = [super init];  
    if (self) {  
        [self setValuesForKeysWithDictionary:dic];  
    }  

    return self;  
}  

-(void)setValue:(id)value forUndefinedKey:(NSString *)key  
{  
    NSLog(@"undefine key ---%@",key);  
}
文章评论