iOS 底层 KVO - AlvinSunny/OC-TheUnderlying GitHub Wiki

KVO基础

KVO的全称是 Key Value Observing, 俗称“键值监听/观察”,是苹果提供的一套事件通知机制,允许一个对象观察/监听另一个对象指定属性值的改变,当被观察对象属性值改变时,会触发KVO的监听方法来通知观察者

KVO和NSNotification都是iOS中观察者模式的一种运用

KVO可以监听单个属性的变化,也可以监听集合对象的变化,监听集合对象变化时需要通过KVC的mutableArrayValueForkey:mutableSetValueForKey:等可变代理方法获取集合代理对象,并使用代理对象进行操作,当代理对象的内部对象发生改变时,就会触发KVO的监听方法,集合对象包含NSArray、NSSet、NSDictionary等

KVO的基本使用

KVO使用三部曲: 添加/注册KVO监听、实现监听方法(用来接收属性改变通知)、移除KVO监听

1.调用方法addObserver:forKeyPath:options:context:给被观察对象添加观察者

2.在观察者类中实现 observerValueForKeyPath:ofObject:change:context:方法以接收属性改变的通知

3.当观察者不在需要监听时,调用removeObserver:forKeyPath:方法将观察者移除;需要注意的是:至少需要在观察者销毁之前调用此方法,否则可能会导致Crash

注册方法的理解


- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;

使用
[self.persion addObserver:self forKeyPath:NSStringFromSelector(@selector(myList)) options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:NULL];

  • target: 被观察者
  • observer: 观察者
  • keyPath: 被观察对象的属性的关键路径,不能为nil
  • options: 观察的配置选项,包括观察的内容(枚举类型)

NSKeyValueObservingOptionNew: 观察新值

NSKeyValueObservingOptionOld: 观察旧值

NSKeyValueObservingOptionInitial: 观察初始值,如果想在注册观察者后,立即接收一次回调 既可以加入该枚举

NSKeyValueObservingOptionPrior: 分别在值改变前后触发方法(即一次改变有两次触发:即将修改属性、修改完属性)

  • context: 可以传入任意数据(任意类型的对象或者C指针),在监听方法中可以接收到这个数据,是KVO中一种传值方式;如果传的是一个对象你必须在移除观察之前持有它的强引用,否则在监听方法中访问context就可能导致Crash

监听方法的理解:

观察者对象必须能响应以下监听方法,因为当被观察者属性发生改变时会调用它

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
  • keyPath: 被观察对象的属性路径
  • object: 被观察对象
  • change: 属性值改变的详细信息,根据注册方法中options参数传入的枚举来返回

NSKeyValueChangeKindKey:存储本次改变的信息(change字典中默认包含这个key),对应枚举类型NSKeyvalueChange

typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
    NSKeyValueChangeSetting = 1, 属性(包括集合)进行赋值操作
    NSKeyValueChangeInsertion = 2, 集合对象插入元素
    NSKeyValueChangeRemoval = 3,   集合对象删除元素
    NSKeyValueChangeReplacement = 4, 集合对象替换元素
};

NSKeyValueChangeNewKey: 存储新值

NSKeyValueChangeOldKey: 存储旧值

NSKeyValueChangeIndexesKey: 观察者是集合对象,且进行的是(插入、删除、替换)操作

NSKeyValueChangeNotificationIsPriorKey: 对应options传入的NSKeyValueObservingOptionPrior

context: 注册时传入的context

KVO移除

在调用注册方法后,KVO并不会对观察者进行强引用(unsafe_unretained),所以需要注意观察者的生命周期;至少需要在观察者销毁之前调用以下方法移除观察者,否则如果在观察者释放后再次触发KVO监听方法就会导致Crash

iOS9之前使用通知需要在dealloc调移除监听者的方法removeobserver:,因为ios9之前观察者注册的时候通知中心对观察者对象有一个unsafe_unretained引用(cocoa 和 cocoa touch中一些类不支持weak引用),在ios9之后已经支持了weak 所以大多数情况不需要自己再手动调用removeObserver了,但是通过addObserverForName:object:queue:usingblock注册的观察者还是需要手动释放 因为通知中心对它们做了强引用

KVO触发分为自动触发和手动触发两种方式

自动触发

  • 监听对象属性值的改变时: 使用点语法赋值、使用setter方法赋值、使用KVC的setValue:forKey赋值、使用KVC的setValue:forKeyPath:赋值

  • 监听集合对象的改变,需要通过KVC的mutableArrayValueForKey:等方法获得代理对象,并使用代理对象进行操作,当代理对象内部发生改变时就会触发KVO,集合对象包含NSMutableArray和NSMutableSet(注意:直接(没有获取代理)对集合对象进行操作改变,不会触发KVO)

  • 直接给集合对象赋值为nil会触发KVO监听回调(因为这就是在修改集合这个属性呀), 但如果重写automaticallyNotifiesObserversForKey方法并返回NO就无法触发了

手动触发

普通对象属性调用

- (void)willChangeValueForKey:(NSString *)key
- (void)didChangeValueForKey:(NSString *)key

NSMutableArray、NSMutableSet对象调用

- (void)willChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key
- (void)didChange:(NSKeyValueChange)changeKind valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key

注意📢: 如果注册时options传入NSKeyValueObservingOptionPrior,那么可以通过值调用willChangeValueForKey或willChange:valuesAtIndexes:forKey来触发改变前的那次KVO,可以用于在属性值即将更改前做一些操作

使用场景:

  • 修改成员变量时想让外界知道属性被修改了 [示例1]
  • 新值旧值相等时不触发KVO,不相等时触发KVO [示例2]
示例1
 [self willChangeValueForKey:NSStringFromSelector(@selector(name))];
    _name = @"成员变量_name修改了";
 [self didChangeValueForKey:NSStringFromSelector(@selector(name))];
示例2

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

- (void)setName:(NSString *)name {
    if (![_name isEqualToString:name]) {
        [self willChangeValueForKey:NSStringFromSelector(@selector(name))];
        _name = @"成员变量_name修改了";
        [self didChangeValueForKey:NSStringFromSelector(@selector(name))];
    }
}

KVO进阶使用

observationInfo属性

  • observationInfo属性是NSKeyValueObserving.h文件中系统通过分类给NSObject添加的属性,所以所有继承自NSObject的对象都含有该属性
  • 可以通过observationInfo属性查看被观察对象的全部观察信息,包括observation、keyPath、options、context等
@property (nullable) void *observationInfo NS_RETURNS_INNER_POINTER;

context

context在注册时传入,在监听方法里带回其作用如下:

  • 标签/区分,可以更精确的确定被观察对象属性,用于继承+多监听 也可以用来传值;KVO只有一个监听回调方法,通常情况下可以在注册方法中指定context为NULL,并在监听方法中通过object和keyPath来判断触发KVO的来源,但如果存在继承的情况大家都对一个对象的同一个属性监听了,那么监听方法被触发时谁来处理就是个问题,此时就需要在注册时为context设置不同的值,然后在监听方法中校验即可做到

  • 苹果的推荐用法:用context来精确的确定被观察对象属性,使用唯一命名的静态变量地址作为值,可以为整个类设置一个context,然后在监听方法中通过object和keyPath来确定被观察属性,这样存在继承的情况就可以通过context来判断; 也可以为每个被观察对象属性设置不同的context,这样就可以精确的确定被观察对象属性

context优点:嵌套少、性能高、更安全、扩展性强

context注意点:

如果传入的是一个对象,必须在移除观察者之前持有它的强引用(观察者需要强引用传入的这个对象,这样才能保证在回调方法中这个对象是可用的),否则在监听方法中访问context就可能导致Crash;

空传NULL而不应该传nil, 因为context接收的是 void *指针不是实例对象; 附iOS中nil 、NULL、Nil 、null、NSNull

KVO监听集合对象

自动触发

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.persion = [[Persion alloc] init];
    self.persion.myList = [NSMutableArray array];
    
    [self.persion addObserver:self forKeyPath:NSStringFromSelector(@selector(myList)) options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:NULL];
    
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    
    //[self.persion.myList addObject:@"2"];//直接(没有获取代理)对集合对象进行操作改变,不会触发KVO
    //self.persion.myList = nil;
    
    NSMutableArray *list = [self.persion mutableArrayValueForKey:NSStringFromSelector(@selector(myList))];
    [list addObject:@"1"];//会触发
    
}

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

手动触发


ViewController.m
- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.persion = [[Persion alloc] init];
    self.persion.myList = [NSMutableArray array];
    
    [self.persion addObserver:self forKeyPath:NSStringFromSelector(@selector(myList)) options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:NULL];
    
}

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    
    NSMutableArray *list = [self.persion mutableArrayValueForKey:NSStringFromSelector(@selector(myList))];
    [list addObject:@"1"];
    
}

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

Persion.m

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    BOOL automatic = YES;
    if([key isEqualToString:NSStringFromSelector(@selector(myList))]) {
        automatic = NO;
    }else {
        automatic = [super automaticallyNotifiesObserversForKey:key];
    }
    return automatic;
}

- (void)insertMyList:(NSArray *)array atIndexes:(NSIndexSet *)indexes {
    
    [self willChange:NSKeyValueChangeInsertion valuesAtIndexes:indexes forKey:NSStringFromSelector(@selector(myList))];
    
    [self.myList insertObjects:array atIndexes:indexes];
    
    [self didChange:NSKeyValueChangeInsertion valuesAtIndexes:indexes forKey:NSStringFromSelector(@selector(myList))];

}

- (void)removeMyListAtIndexes:(NSIndexSet *)indexes {
    
    [self willChange:NSKeyValueChangeRemoval valuesAtIndexes:indexes forKey:NSStringFromSelector(@selector(myList))];

    [self.myList removeObjectsAtIndexes:indexes];
    
    [self didChange:NSKeyValueChangeRemoval valuesAtIndexes:indexes forKey:NSStringFromSelector(@selector(myList))];

}

- (void)replaceMyListAtIndexes:(NSIndexSet *)indexes withMyList:(NSArray *)array {
    
    [self willChange:NSKeyValueChangeReplacement valuesAtIndexes:indexes forKey:NSStringFromSelector(@selector(myList))];

    [self.myList replaceObjectsAtIndexes:indexes withObjects:array];
    
    [self willChange:NSKeyValueChangeReplacement valuesAtIndexes:indexes forKey:NSStringFromSelector(@selector(myList))];

}

KVO的触发控制

可以在被观察对象的类中重写 + (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key方法来控制KVO的自动触发

如果我们只允许外界观察person的name属性,就可以在persion类如下操作,这样外界就只能观察name属性;即使外界注册了对persion其他属性的监听,那么在属性发生改变时也不会触发KVO; 假如想所有属性都不允许监听直接返回NO即可


+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    if([key isEqualToString:@"name"]) {
        return YES;
    }
    return NO;
}

也可以实现遵循命名规则为+ (BOOL)automaticallyNotifiesObserversOf<Key>的方法来单一控制属性的KVO自动触发,为属性名(首字母大写)

+ (BOOL)automaticallyNotifiesObserversOfName {
    return NO;
}

注意📢:

第一个方法的优先级高于第二个方法,如果实现了automaticallyNotifiesObserversForKey:并对Key做了处理 则系统就不会再调用该Key的automaticallyNotifiesObserversOf<Key>方法

options指定的NSKeyValueObservingOptioninitial触发的KVO通知是无法被automaticallyNotifiesObserversForKey阻止的

KVO的依赖观察

一对一关系

有些情况下,一个属性的改变需要依赖于另一个或多个(有限的数量比如3个)属性的改变;比如我们想要对Download类中的downloadProgress属性进行KVO监听,该属性的改变依赖于writtenData和totalData属性的改变,观察者监听了downloadProgress当writtenData和totalData属性改变时观察者也应该被通知

两种解决办法:

  • 重写keyPathsForValuesAffectingValueForKey方法来指明downloadProgress属性依赖于writtenData和totalData

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

  • 实现一个遵循命名规则为keyPathsForValuesAffecting<Key>的类方法,是依赖于其他值的属性名(首字母大写)
+ (NSSet<NSString *> *)keyPathsForValuesAffectingDownloadProgress {
        
    NSArray *affectingKeys = @[NSStringFromSelector(@selector(writtenData)),NSStringFromSelector(@selector(totalData))];
    
    return affectingKeys;
    
}

注意📢:

1.以上两个方法可以同时存在且都会调用,但最终结果会以keyPathsForValuesAffectingValueForKey:为准

2.不适用于集合属性

一对多关系

场景: 假如Persion类有一个数组list,数组中元素是Student类型, Student类有一个age,希望Persion有一个totalAge属性来计算所有age的和;此时totalAge就依赖list中Student的age属性

方法一: 遍历数组中所有的Student对其添加关于age属性的监听,并设置context的值为一个特殊标记(比如static void *totalAgeContext = &totalAgeContext;),然后在监听方法里处理相关逻辑

方法二: 使用iOS中观察者模式的另一个实现方式: 通知(NSNotification)

KVO使用注意📢

  • 移除观察者的注意点,在调用KVO注册方法后,KVO并不会对观察者进行强引用,所以需要注意观察者的生命周期,至少需要在观察者销毁之前移除观察者,否则如果在观察者被释放后再次触发KVO监听方法就会导致Crash

  • KVO的注册方法和移除方法应该是成对的,如果重复调用移除方法就会抛出异常NSRangeExxeption导致程序Crash;官方推荐的方式是在观察者初始化期间(init或viewDidLoad)注册,在dealloc时调用移除

    防止多次注册和移除KVO的方式有三种:

  1. 利用@try @catch(只能针对删除多次KVO的情况),给NSObject增加一个分类,然后利用Runtime的method_exchangeImplementations交换系统的removerObserver:方法在里面添加@try @catch

  2. 利用模型数组进行存储记录

  3. 利用observationInfo里私有属性

  • 如果对象被注册成为观察者,则该对象必须实现监听方法;当被观察者属性发生改变时就会调用监听方法,没有实现就会导致Crash

  • keyPath传入的是一个字符串,为避免写错可以使用NSStringFromSelector(@selector(propertyName)),将属性的getter方法SEL转换成字符串,这样编译阶段就会对keyPath进行检查

  • 如果注册方法中context传的是一个对象,必须在移除观察之前持有它的强引用,否则在监听方法中访问ceontext就会Crash

  • 如果监听集合对象改变,需要通过KVC的mutableArrayValueForKey:等方法获取代理对象,并使用代理对象进行操作,当代理对象内部发生变化时会触发KVO,如果直接对集合对象进行操作改变不会触发KVO

KVO的底层实现

KVO的底层实现: 利用Runtime API 动态生成一个继承自原有类的子类 NSKVONotifying_XXX, 实现原有类的setter方法 保留成员变量赋值是在原来类中进行,并且让instance对象的isa指向这个全新的子类,当修改instance对象的属性时,会调用Foundation的_NSSetXXXValueAndNotify函数

1> 执行 willChangeValueForKey:

2> 父类原来的setter方法

3> 执行 didChangeValueForKey: ,内部会触发监听器(Observe)的监听方法--> observeValueForKeyPath:ofObject:change:context

最常见的使用方法

image

KVO 衍生类图解:

image

注:

NSKVONotifying_XXX :KVO监听时系统自动生成的类

NSKVONotifying_XXX类对象中存储了 :

isa指针 : 指向元类对象,而这个元类对象的ISA指向 NSObject 的根元类

superclass 指针:指向原来的实例对象的类对象,也就是说指向在添加KVO监听之前的类对象;而这个类对象在该实例对象添加KVO监听后成为了衍生类的父类。

setAge: 衍生类重写了属性setter方法,用来赋值;但实际赋值操作仍在原有类中实现

class : 衍生类重写class方法是为了不希望开发者了解到系统API的内部实现。直接返回原有类的类对象

dealloc: 衍生类重写dealloc方法便于管理监听的移除

_isKVOA: 此方法是为了给开发者一个可以判定对象是否添加KVO监听服务

疑问:为什么不重写getter方法?

因为getter中的实现是直接返回成员变量,而且通常是系统自动实现;子类不需要再重写,父类中有了就可以。

image

KVO属性监听的内部调用逻辑--伪代码实现如下

image

FBKVOController

系统KVO缺点

  • 使用比较麻烦,需要三个步骤:添加/注册KVO监听、实现监听方法、移除监听;需要手动移除观察者且移除时机必须合适,同时不能重复移除(会Crash)
  • 在复杂业务逻辑中,准确判断被观察者相对比较麻烦,有多个被观察的属性或继承+多观察时需要写很多if判断

FBKVOController优点和使用

优点

  • 会自动移除观察者对象
  • 函数式编程,可以一行代码实现系统KVO的三个步骤
  • 实现KVO与事件发生处的代码上下文相同,不需要跨方法传参数
  • 增加了block和SEL自定义操作对NSKeyValueObserving回调的处理支持
  • 每一个keyPath会对应一个block或者SEL,不需要使用if判断keyPath
  • 可以同时对一个对象的多个属性进行监听,写法简洁(NSHashTable<_FBKVOInfo *> *_infos)
  • 线程安全(内部使用了pthread_mutex_lock)

使用

FBKVOController实现了观察者和被观察者的角色反转,系统的KVO是被观察者添加观察者,而FBKVO实现了观察者主动添加被观察者,实现角色上的反转,使用比较方便

系统KVO

- target: 被观察者
- observer: 观察者

[self.persion addObserver:self forKeyPath:NSStringFromSelector(@selector(myList)) options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:NULL];

---------------------------------------------------------------

FBKVO

- target: 观察者
- observer: 被观察者

__weak typeof(self) waekSelf = self;
[self.KVOController observe:viewModel keyPath:@"name"    options:NSKeyValueObservingOptionNew block:^(id  _Nullable observer, id  _Nonnull object, NSDictionary<NSKeyValueChangeKey,id> * _Nonnull change) {
  //监听block回调

}];

FBKVOController原理

FBKVOController 实现原理

⚠️ **GitHub.com Fallback** ⚠️