KVO原理分析及使用进阶

1、概念

KVO(Key-Value-Oberver)观察者模式,是苹果提供的一套事件通知机制,允许对象监听另一个对象特定属性的改变,并在改变时接收事件,一般继承自NSObject的对象的都默认支持KVO

KVO和NSNotificationCenter都是iOS中观察者模式的一种实现。区别在于:

  • 1、 notification 比 KVO 多了发送通知的一步。
    两者都是一对多,但是对象之间直接的交互,notification 明显得多,需要notificationCenter 来做为中间交互。而 KVO 如我们介绍的,设置观察者->处理属性变化,至于中间通知这一环,则隐秘多了,只留一句“交由系统通知”,具体的可参照以上实现过程的剖析。
  • 2、 notification 的优点是监听不局限于属性的变化,还可以对多种多样的状态变化进行监听,监听范围广,例如键盘、前后台等系统通知的使用也更显灵活方便。

2、基本使用

1、注册观察者

    /*
        - (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
     
     observer:观察者  也就是被观察对象发生改变时通知的接收者
     
     keyPath:被观察的属性名   比如我们这里是age属性
     
     options:参数  这里一般选择NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld  也就是在回调方法里会受到被观察属性的旧值和新值,默认为只接收新值。如果想在注册观察者后,立即接收一次回调,则可以加入NSKeyValueObservingOptionInitial枚举。
     
     context:这个参数可以传入任意类型的对象,这个值会传递到接收消息回调的代码中,是KVO中的一种传值方式。

     */
    self.penson = [[Person alloc]init];
    [self.penson addObserver:self forKeyPath:@"age" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:nil];

注:forKeyPath: 参数可以改成NSStringFromSelector(@selector(age)),直接打字符,有可能会出现打错问题,NSStringFromSelector这个会检查提示没有这个属性
优化:

[self.penson addObserver:self forKeyPath:NSStringFromSelector(@selector(name)) options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:nil];

2、实现监听

//2.实现通知回调方法 当被观察对象的属性值发生变化时  就会回调这个方法  change字典中存放KVO属性相关的值,根据options时传入的枚举来返回。

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

3、 一定要记得移除监听,要不然就反问野指针啦

  //3.移除监听
  [self.penson removeObserver:self forKeyPath:NSStringFromSelector(@selector(age)) ];
注意点
1: KVO的addObserver和removeObserver需要是成对的

KVO的addObserver和removeObserver需要是成对的,如果重复remove则会导致NSRangeException类型的Crash,如果忘记remove则会在观察者释放后再次接收到KVO回调时Crash。

苹果官方推荐的方式是,在init的时候进行addObserver,在dealloc时removeObserver,这样可以保证add和remove是成对出现的,是一种比较理想的使用方式。

2: 提个小问题: 添加观察者会导致循环引用吗?

当然不会,首先可验证,控制器关闭后,会执行delloc说明控制器被释放啦,没有导致循环引用,因为系统在实现KVO生成对象的子类是,使用的方法
objc_setAssociatedObject(self, (__bridge const void *)@"objc", observer, OBJC_ASSOCIATION_ASSIGN); 使用的是弱引用
使用OBJC_ASSOCIATION_RETAIN或者OBJC_ASSOCIATION_COPY就会导致循环引用啦,下面的原理探究会讲到这块, 听不懂啦吧,哈哈😺

3: 调用KVO属性对象时,不仅可以通过点语法和set语法进行调用,KVO兼容很多种调用方式:(关于KVC的实现原理接下来会讲到)
// 1.通过属性的点语法间接调用
   self.penson.name = @"小王";
//2. 直接调用set方法
  [self.penson setName:@"小王"];
// 3.使用KVC的setValue:forKeyPath:方法
   [self.penson setValue:@"小王" forKeyPath:NSStringFromSelector(@selector(name))];    
//4. 使用KVC的setValue:forKey:方法
   [self.penson setValue:@"小王" forKey:NSStringFromSelector(@selector(name))];    
// 5.通过mutableArrayValueForKey:方法获取到代理对象,并使用代理对象进行操作
4: 如果直接修改对象的成员变量是不会触发KVO的:
//PersonClass.h文件

#import <Foundation/Foundation.h>
@interface Person : NSObject{
   @public;
    NSString _name;//成员变量
}
//属性
@property (nonatomic, assign) NSString *name;
@end

直接修改成员变量,我们发现没有触发KVO

self.person -> _name = 234;

3 当对象里面又有更复杂的属性对象呢,怎么监控属性对象的的属性改变,如下面

#import <Foundation/Foundation.h>
@class AnimalClass;
@interface PersonClass : NSObject
@property (nonatomic, assign) NSInteger age;
@property (nonatomic, strong) AnimalClass *animal;
@end

AnimalClass类中有一个name属性

@interface AnimalClass : NSObject
@property (nonatomic, copy) NSString *name;
@end

当我们对animal这个属性进行监听时,发现当对animal的属性值(name)修改时 kvo并不会监听到, 而当给person对象重新赋值一个新的animalClass对象时会被监听到

//会监听到改变  因为person1的animal属性是个指针 存储的是animal类型的一个地址值  当重新赋值一个alloc出来的新animalClass对象时  animal的地址值发生了改变  会调用person1的setAnimal方法
    AnimalClass *ani2 = [[AnimalClass alloc]init];
    ani2.name = @"cat";
    self.person1.animal = ani2;
    
    //不会被kvo监听到  因为修改animal的name属性 根本没有调用person1的setAnimal方法  只是调用了animal的setName方法
    self.person1.animal.name = @"cat";
方式1:

而当我们对person.boy对象的age属性进行监听时 是可以监听到 self.person.boy.age = 12; 这种值改动的

    self.person.boy = [[Boy alloc]init];
    [self.person.boy addObserver:self forKeyPath:@"age" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:nil];

所以kvo能否监听到变化 要看这个被监听对象存储的是什么?实际上是否发生了改变?

方式2:

还是监控person对象,只是key改成@"boy.age", person对象属性boy,在加上boy对象属性age。

    person.boy = [[Boy alloc]init];
    [person addObserver:self forKeyPath: @"boy.age" options:NSKeyValueObservingOptionNew context:nil];

3、进阶用法

1 :(KVO触发模式)

当对象person有很多属性情况下,我并不需要一设置属性就自动触发监控,有需要触发有时候不需要触发,有什么方法不?
KVO触发模式,重写automaticallyNotifiesObserversForKey
返回YES,就是默认的自动模式,设置属性值就会触发KVO
返回NO,赋值不会触发KVO啦

/* Return YES if the key-value observing machinery should 
automatically invoke -willChangeValueForKey:/-
didChangeValueForKey:, -willChange:valuesAtIndexes:forKey:/-
didChange:valuesAtIndexes:forKey:, or -
willChangeValueForKey:withSetMutation:usingObjects:/-
didChangeValueForKey:withSetMutation:usingObjects: whenever 
instances of the class receive key-value coding messages for the key, 
or mutating key-value coding-compliant methods for the key are 
invoked. Return NO otherwise. Starting in Mac OS 10.5, the default 
implementation of this method searches the receiving class for a 
method whose name matches the pattern 
+automaticallyNotifiesObserversOf<Key>, and returns the result of 
invoking that method if it is found. So, any such method must return 
BOOL too. If no such method is found YES is returned.
*/
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key;
#import "Person.h"
@implementation Person
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    return NO;
}
@end
监控类中
 [self.penson willChangeValueForKey:NSStringFromSelector(@selector(name))];
 [self.penson.name = @"小碗";
 [self.penson didChangeValueForKey:NSStringFromSelector(@selector(name))];

这个时候就需要在设置属性值之前调用willChangeValueForKey:
设置属性值之后调用didChangeValueForKey:
说明自动模式下直接设置属性值后,其实会帮我们调用这两个方法触发KVO,
也就是不改变属性值,直接调用这两句方法,也是会触发KVO的

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    /// 当改变属性值name时,需要手动去调用上述说的两个方法触发,其他属性就会不用调用也会自动触发
    if ([key isEqualToString:@"name"]) {
        return NO
    }
    return YES;
}
2 :(KVO关联属性)

// 设置boy的关联属性,这样想观察person对象的boy对象属性值变化,就不需要去使用
// 如 person addObserver: self forKeyPath: @"boy.age"
// 或者 person.boy addObserver: self forKeyPath: @"age" 这几种方式啦,麻烦而且boy属性值比较多还要一个个去监听
// person类实现如下方法,把boy类的属性关联过来就可以直接
// person addObserver: self forKeyPath: @"boy" 添加KVO
// 直接赋值 person.boy.age = 10, 就能出发KVO啦

+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"boy"]) {
        keyPaths = [[NSSet alloc]initWithObjects:@"_boy.age", @"_boy.height", nil];
    }
    return keyPaths;
}

    self.person.boy = [[Boy alloc]init];
    [self.person addObserver:self forKeyPath: NSStringFromSelector(@selector(boy)) options:NSKeyValueObservingOptionNew context:nil];

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    static int a = 0;
    self.person.boy.age = a++;
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    // 即能打印出boy改变的age
    NSLog(@"%@", change);
}

4、高级玩法

自定义一个KVO

系统的KVO实现的原理是,当监控KVO时,会运用runtime生成对象的子类对象,然后重写属性的set方法,运用runtime直接调用- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
方法,自定义就是根据这个原理来的
如下

#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface NSObject (SHKVO)

/* Register or deregister as an observer of the value at a key path relative to the receiver. The options determine what is included in observer notifications and when they're sent, as described above, and the context is passed in observer notifications as described above. You should use -removeObserver:forKeyPath:context: instead of -removeObserver:forKeyPath: whenever possible because it allows you to more precisely specify your intent. When the same observer is registered for the same key path multiple times, but with different context pointers each time, -removeObserver:forKeyPath: has to guess at the context pointer when deciding what exactly to remove, and it can guess wrong.
*/
- (void)sh_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;
- (void)sh_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0));
- (void)sh_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

@end

NS_ASSUME_NONNULL_END
#import "NSObject+SHKVO.h"
#import <objc/message.h>
#import <objc/runtime.h>

@implementation NSObject (SHKVO)


/* Register or deregister as an observer of the value at a key path relative to the receiver. The options determine what is included in observer notifications and when they're sent, as described above, and the context is passed in observer notifications as described above. You should use -removeObserver:forKeyPath:context: instead of -removeObserver:forKeyPath: whenever possible because it allows you to more precisely specify your intent. When the same observer is registered for the same key path multiple times, but with different context pointers each time, -removeObserver:forKeyPath: has to guess at the context pointer when deciding what exactly to remove, and it can guess wrong.
*/
- (void)sh_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context {
    //1. 创建一个类
    NSString *oldClassName = NSStringFromClass(self.class);
    NSString *newClassName = [@"SHKVO_" stringByAppendingString:oldClassName];
    
    // 创建MyClass继承 self.class 的子类
    Class MyClass = objc_allocateClassPair(self.class, newClassName.UTF8String, 0);
    
    // 注册类
    objc_registerClassPair(MyClass);
    
    //2. 重写setNamea方法
    /**
     * class 给那个类添加方法
       sel 方法编号
     */
    class_addMethod(MyClass, @selector(setName:), (IMP)setName, @"v@:@");
    
    //3. 修改isa指针!!
    objc_setClass(self, MyClass);
    
    //4. 将观察者保存到当前对象
    objc_setAssocisatedObject(self, @"observer", observer, OBJC_ASSOCIATION_ASSIGN);
}

void setName(id self, SEL _cmd, NSString *newName) {
    NSLog(@"来了!! %@", newName);
    
    // 调用父类的setName方法,目的就是不印象父类的正常逻辑赋值
    // 拿到当前类型
    Class class = [self class];
    
    objc_setClass(self, class_getSuperclass(class));
    objc_msgSend(self, @selector(setName:), newName);
    
    // 这个地方调用willchange,didChange,会不会触发KVO呢?
    // 答,当然不会啦,这个时候已经走的是我们自定义的KVO,z系统的KVO你都没有添加监控呢
    
    // 观察者
    id observer = onjc_getAssociateObject(self, @"observer");
    if (observer) {
        objc_msgSend(observer, @selector(observeValueForKeyPath:ofObject:change:contex:), @"name",
                     self, {@"new": newName, @"kind":@1}, nil);
    }
    
    // 改回子类
    objc_setClass(self, class);
}

- (void)sh_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0)) {
    
    
}

- (void)sh_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath {
    
}

使用

person.boy = [[Boy alloc]init]
[person sh_addObserver:self forKeyPath: NSStringFromSelector(@selector(boy)) options:NSKeyValueObservingOptionNew context:nil];

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    NSLog(@" %@", change);
}

5、KVO 对容器类的监听

未完待续

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

推荐阅读更多精彩内容