×
广告

KVC && KVO

96
Liberalism
2017.07.06 13:45* 字数 3993

1.KVC

关于KVCKVO,我之前的总结文章有写过,但是实际上我在平日工作里,KVCKVO使用的相对较少,不是KVCKVO的功能不够强大,这实际上和项目的架构有比较大的关系,以前的我对于KVCKVO的使用也是趋于表面,没有探究其内部真正的实现原理和进阶用法,这次总结正好给了我很好的学习机会,在此深入的总结一下KVCKVO吧。

KVC,即是指 NSKeyValueCoding,一个非正式的 Protocol,提供一种机制来间接访问对象的属性。KVO 就是基于 KVC 实现的关键技术之一。

KVC在iOS中的定义

Objective-CKVC的定义是对NSObject的扩展来实现的。所以对于所有继承了NSObject在类型,都可以使用KVC,下面是KVC最为重要的四个方法

- (nullable id)valueForKey:(NSString *)key;                          //直接通过Key来取值
- (void)setValue:(nullable id)value forKey:(NSString *)key;          //通过Key来设值
- (nullable id)valueForKeyPath:(NSString *)keyPath;                  //通过KeyPath来取值
- (void)setValue:(nullable id)value forKeyPath:(NSString *)keyPath;  //通过KeyPath来设值

一般来讲,Obj-C 对象中都会有一些属性。如代码所示

#import <Foundation/Foundation.h>
@interface Person : NSObject

/** name */
@property (nonatomic,copy  ) NSString *name;

/** Address */
@property (nonatomic,copy  ) NSString *address;

@end

上面的Person对象所拥有的两个属性,以 KVC的角度来看,就是Person对象的name,address这两个属性分别有一个Value对应他们的Key值。

  • Key 是一个字符串类型。
  • Value 可以为任何类型。

KVC 为存取值提供了两个最基础的方法。

    Person *man = [Person new];
    
    // 存值
    [man setValue:@"LiMing" forKey:@"name"];
    
    // 取值
    NSString *name = [man valueForKey:@"name"];
    
    NSLog(@"%@",name);

KVC 为了便于使用还提供了另外两个方法。

假设我们之前创建的这个对象有一个配偶,配偶也是一个Person对象,此时我们想在man这里读出womanname属性

可以这样操作

    Person *woman = [Person new];
    
    man.spouse = woman;
    
    [man setValue:@"Lily" forKeyPath:@"spouse.name"];
    
    NSLog(@"%@",[man valueForKeyPath:@"spouse.name"]);
    
    //  Key 与 KeyPath 要区分开来
    //  Key 可以让你从一个对象中获取值
    //  KeyPath  可以让你通过连续的多个Key获取值,着多个key值用点号 “.” 分割连接起来

简单对比一下

    //  结果一样的,但是用 KeyPath 更简单
    [man valueForKeyPath:@"spouse.name"]

    [[man valueForKey:@"spouse"] valueForKey:@"name"];

KVC寻找Key值过程

KVC在某种程度上提供了访问器的替代方案,不过只要有可能,KVC也是在访问器方法的帮助下工作。KVC按照以下顺序寻找Key值。

1.赋值

当程序调用

- (void)setValue:(nullable id)value forKey:(NSString *)key;
1.优先寻找访问器方法

程序会优先调用setKey的属性值方法,代码直接通过Setter方法完成设置。这里的key值指的是成员变量名,Key值首字母大写要符合SetterGetter方法的命名规则。

2.寻找_key

如果没有找到setKey的访问器方法,KVC机制会检查

+ (BOOL)accessInstanceVariablesDirectly

的返回值是否为NO,此方法默认返回的是YES。如果开发者重写了该方法让这个返回值为NO时,接下来KVC会直接调用

- (void)setValue:(id)value forUndefinedKey:(NSString *)key

这个时候如果你不做其他操作,就要报出异常了,所以一般人都不会这么做。

接下来KVC机制会搜索该类里面有没有_key的成员变量,无论你是在声明文件中定义,还是在实现文件中定义,也无论使用了什么样的属性修饰符,只要存在着_key命名的变量,KVC都可以对该成员变量赋值。

3.寻找_isKey

如果该类即没有setKey:的访问器方法,也没有_key成员变量,KVC机制会搜索_isKey的成员变量。

4.寻找Key和isKey

和上面一样,如果该类即没有setKey:的访问器方法,也没有_key_isKey成员变量,KVC机制再会继续搜索keyisKey的成员变量。再给它们赋值。

如果上面列出的方法或者成员变量都不存在,系统将会执行该对象的setValue:forUNdefinedKey:方法,默认是抛出异常。

如果开发者想让这个类禁用KVC里,那么重写+ (BOOL)accessInstanceVariablesDirectly方法让其返回NO即可,这样的话如果KVC没有找到set<Key>:属性名时,会直接用setValue:forUNdefinedKey:方法。

2.取值

当程序调用

- (nullable id)valueForKey:(NSString *)key;
1.优先查找访问器的方法

首先按getKeykeyisKey的顺序查找getter方法,找到直接调用。如果是boolint等内建值类型,会做NSNumber的转换。

2.有序集合中查找

上面的getter没有找到,查找countOfKeyobjectInKeyAtIndex:KeyAtIndexes格式的方法。
如果countOfKey和另外两个方法中的一个找到,那么就会返回一个可以响应NSArray所有方法的代理集合。发送给这个代理集合的NSArray消息方法,就会以countOfKeyobjectInKeyAtIndex:KeyAtIndexes这几个方法组合的形式调用。还有一个可选的 getKey:range: 方法。

3.无序集合中查找

还没查到,那么查找countOfKeyenumeratorOfKeymemberOfKey:格式的方法。
如果这三个方法都找到,那么就返回一个可以响应NSSet所有方法的代理集合。发送给这个代理集合的NSSet消息方法,就会以countOfKeyenumeratorOfKeymemberOfKey:组合的形式调用。

4.搜索成员变量名

还是没查到,那么如果类方法accessInstanceVariablesDirectly返回YES,那么按_key_isKeykeyiskey的顺序直接搜索成员名。

5.报出异常

再找不到,调用ValueForUndefinedKey:,默认报出异常

针对集合的KVC

我们上面讲的KVC是一对一关系,比如Person类中的name属性。但也有一对多的关系,比如Person中有一个friendsName属性,保存的是一个人的所有朋友的名字,这时候就需要集合来处理了。

对于集合类的处理,我们有两种选择

1.通过KVC将集合类先取出,然后在针对集合进行处理

2.采用KVC提供的模板方法

有序集合

这里面的Key,就是被监听的属性名称

-countOfKey  
//必须实现,对应于NSArray的基本方法count:  

- objectInKeyAtIndex:
- keyAtIndexes:  
//这两个必须实现一个,对应于 NSArray 的方法 objectAtIndex: 和 objectsAtIndexes: 
 
- getKey:range:  
//不是必须实现的,但实现后可以提高性能,其对应于 NSArray 方法 
- getObjects:range:  
  
- insertObject:inKeyAtIndex:  
- insertKey:atIndexes:  
//两个必须实现一个,类似于 NSMutableArray 的方法 insertObject:atIndex: 和 insertObjects:atIndexes:  

- removeObjectFromKeyAtIndex:  
- removeKeyAtIndexes:  
//两个必须实现一个,类似于 NSMutableArray 的方法 removeObjectAtIndex: 和 removeObjectsAtIndexes:  

- replaceObjectInKeyAtIndex:withObject:  
- replaceKeyAtIndexes:withKey:  
//可选的,如果在此类操作上有性能问题,就需要考虑实现之 
无序集合
- countOfKey 
//必须实现,对应于NSArray的基本方法count: 
 
- objectInKeyAtIndex:  
- keyAtIndexes:  
//这两个必须实现一个,对应于 NSArray 的方法 objectAtIndex: 和 objectsAtIndexes:

- getKey:range:  
//不是必须实现的,但实现后可以提高性能,其对应于 NSArray 方法 
- getObjects:range:  
  
- insertObject:inKeyAtIndex:  
- insertKey:atIndexes:
//两个必须实现一个,类似于 NSMutableArray 的方法 insertObject:atIndex: 和 insertObjects:atIndexes:  

- removeObjectFromKeyAtIndex:  
- removeKeyAtIndexes:  
//两个必须实现一个,类似于 NSMutableArray 的方法 removeObjectAtIndex: 和 removeObjectsAtIndexes:

- replaceObjectInKeyAtIndex:withObject:  
- replaceKeyAtIndexes:withKey:  
//这两个都是可选的,如果在此类操作上有性能问题,就需要考虑实现之

KVC对基本数据类型和结构体的支持

1.对基本数据类型会以 NSNumber 进行包装
+ (NSNumber *)numberWithChar:(char)value;  
+ (NSNumber *)numberWithUnsignedChar:(unsigned char)value;  
+ (NSNumber *)numberWithShort:(short)value;  
+ (NSNumber *)numberWithUnsignedShort:(unsigned short)value;  
+ (NSNumber *)numberWithInt:(int)value;  
+ (NSNumber *)numberWithUnsignedInt:(unsigned int)value;  
+ (NSNumber *)numberWithLong:(long)value;  
+ (NSNumber *)numberWithUnsignedLong:(unsigned long)value;  
+ (NSNumber *)numberWithLongLong:(long long)value;  
+ (NSNumber *)numberWithUnsignedLongLong:(unsigned long long)value;  
+ (NSNumber *)numberWithFloat:(float)value;  
+ (NSNumber *)numberWithDouble:(double)value;  
+ (NSNumber *)numberWithBool:(BOOL)value;  
+ (NSNumber *)numberWithInteger:(NSInteger)value NS_AVAILABLE(10_5, 2_0);  
+ (NSNumber *)numberWithUnsignedInteger:(NSUInteger)value NS_AVAILABLE(10_5, 2_0);
2.对结构体会以 NSValue 进行包装
+ (NSValue *)valueWithCGPoint:(CGPoint)point;  
+ (NSValue *)valueWithCGSize:(CGSize)size;  
+ (NSValue *)valueWithCGRect:(CGRect)rect;  
+ (NSValue *)valueWithCGAffineTransform:(CGAffineTransform)transform;  
+ (NSValue *)valueWithUIEdgeInsets:(UIEdgeInsets)insets;  
+ (NSValue *)valueWithUIOffset:(UIOffset)insets NS_AVAILABLE_IOS(5_0);  

所有的结构体都支持以NSValue进行封装

KVC中的集合运算符

集合运算符是一个特殊的KeyPath,可以作为参数传递给valueForKeyPath:方法

1.简单的集合运算符

简单的集合运算符有以下几个 @avg@count@max@min@sum5

2.对象运算符

对象运算符有@distinctUnionOfObjects,
@unionOfObjects,这两个运算符返回的对象都是NSArray

1.@distinctUnionOfObjects会将集合在剔除重复对象之后返回

2.@unionOfObjects会直接返回所有对象

NSKeyValueCoding其他方法

+ (BOOL)accessInstanceVariablesDirectly;
//默认返回YES,表示如果没有找到SetKey方法的话,会按照_key,_iskey,key,iskey的顺序搜索成员,设置成NO就会直接抛出异常。

- (BOOL)validateValue:(inout id __nullable * __nonnull)ioValue forKey:(NSString *)inKey error:(out NSError **)outError;
//KVC提供属性值确认的API,它可以用来检查set的值是否正确、为不正确的值做一个替换值或者拒绝设置新值并返回错误原因。

- (NSMutableArray *)mutableArrayValueForKey:(NSString *)key;
//这是集合操作的API,里面还有一系列这样的API,如果属性是一个NSMutableArray,那么可以用这个方法来返回

- (nullable id)valueForUndefinedKey:(NSString *)key;
//如果Key不存在,且没有KVC无法搜索到任何和Key有关的字段或者属性,则会调用这个方法,默认是抛出异常

- (void)setValue:(nullable id)value forUndefinedKey:(NSString *)key;
//和上一个方法一样,只不过是设值。

- (void)setNilValueForKey:(NSString *)key;
//如果你在SetValue方法时面给Value传nil,则会调用这个方法

- (NSDictionary<NSString *, id> *)dictionaryWithValuesForKeys:(NSArray<NSString *> *)keys;
//输入一组key,返回该组key对应的Value,再转成字典返回,用于将Model转到字典。

没想到光一个KVC就写了这么多的内容,而越深入写就越觉得自己写的不过是皮毛,接下来再说说KVO吧。

2.KVO

1.认识KVO

KVO类似于观察者模式,我们利用简单的代码来了解什么是KVO

//  注册一个Person类
#import <Foundation/Foundation.h>

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

// 再注册一个Dog类
#import <Foundation/Foundation.h>

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

我们在ViewController中引入头文件,并创建两个全局的属性。我们希望Person作为Dog的观察者,当Dogname属性发生变化的时候,Person可以第一时间知道。这时我们就可以运用KVO的技术。

    Person *p = [Person new];
    self.p = p;
    Dog *dog = [Dog new];
    self.dog = dog;
   
   // 成为其他对象的观察者要进行注册
   // KeyPath代表监听对象的具体属性
   // Observe就是观察者喽
   // Options可以指定管擦得值得新旧等
   // Context可以是任何对象,可以向观察者传递信息,也可以用指定的标识对不同的观察者进行区分
    [dog addObserver:p
          forKeyPath:@"name"
             options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld)
             context:nil];

    dog.name = @"旺财";

监听选项Options是由枚举NSKeyValueObservingOptions定义的,他决定了哪些值可以被传入到观察者内部实现的方法中。

定义如下:

enum {
       // 提供新值
    NSKeyValueObservingOptionNew = 0x01,
    
    // 提供旧值
    NSKeyValueObservingOptionOld = 0x02,
    
    // 添加观察者时立即发送一个通知给观察者,
    // 并且是在注册观察者方法返回之前
    NSKeyValueObservingOptionInitial = 0x04,
    
    // 如果指定,则在每次修改属性时,会在修改通知被发送之前预先发送一条通知给观察者,
    // 这与-willChangeValueForKey:被触发的时间是相对应的。
    // 这样,在每次修改属性时,实际上是会发送两条通知。
    NSKeyValueObservingOptionPrior = 0x08 
};
typedef NSUInteger NSKeyValueObservingOptions;
//  选项值可以支持多个选项

注册之后,我们要在观察者内部实现如下方法

// 此时,当被观察者的属性发生变更,观察者就会自动调用如下方法
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    
    // keyPath被观察的属性值
    NSLog(@"keyPath = %@",keyPath);
    
    // object被观察的对象
    NSLog(@"object = %@",object);
    
    // 被观察属性值得变化,后面还会讲
    NSLog(@"change = %@",change);
    
    // 上下文,也可以是任意的额外数据
    // 这个Context的作用十分重要,我在后面会强调
    NSLog(@"context = %@",context);
}
// 我们通过这个方法,可以得到一些关键信息

Change选项,它记录了被监听属性的变化情况。可以通过key来获取值:


// 属性变化的类型,是一个NSNumber对象,包含NSKeyValueChange枚举相关的值
NSString *const NSKeyValueChangeKindKey;

// 属性的新值。当NSKeyValueChangeKindKey是 NSKeyValueChangeSetting,
// 且添加观察的方法设置了NSKeyValueObservingOptionNew时,我们能获取到属性的新值。
// 如果NSKeyValueChangeKindKey是NSKeyValueChangeInsertion或者NSKeyValueChangeReplacement,
// 且指定了NSKeyValueObservingOptionNew时,则我们能获取到一个NSArray对象,包含被插入的对象或
// 用于替换其它对象的对象。
NSString *const NSKeyValueChangeNewKey;

// 属性的旧值。当NSKeyValueChangeKindKey是 NSKeyValueChangeSetting,
// 且添加观察的方法设置了NSKeyValueObservingOptionOld时,我们能获取到属性的旧值。
// 如果NSKeyValueChangeKindKey是NSKeyValueChangeRemoval或者NSKeyValueChangeReplacement,
// 且指定了NSKeyValueObservingOptionOld时,则我们能获取到一个NSArray对象,包含被移除的对象或
// 被替换的对象。
NSString *const NSKeyValueChangeOldKey;

// 如果NSKeyValueChangeKindKey的值是NSKeyValueChangeInsertion、NSKeyValueChangeRemoval
// 或者NSKeyValueChangeReplacement,则这个key对应的值是一个NSIndexSet对象,
// 包含了被插入、移除或替换的对象的索引
NSString *const NSKeyValueChangeIndexesKey;

// 当指定了NSKeyValueObservingOptionPrior选项时,在属性被修改的通知发送前,
// 会先发送一条通知给观察者。我们可以使用NSKeyValueChangeNotificationIsPriorKey
// 来获取到通知是否是预先发送的,如果是,获取到的值总是@(YES)
NSString *const NSKeyValueChangeNotificationIsPriorKey;

NSKeyValueChangeKindKey的值取自于NSKeyValueChange,这是一个枚举值,定义如下

enum {
    // 设置一个新值。被监听的属性可以是一个对象,也可以是一对一关系的属性或一对多关系的属性。
    NSKeyValueChangeSetting = 1,
    
    // 表示一个对象被插入到一对多关系的属性。
    NSKeyValueChangeInsertion = 2,
    
    // 表示一个对象被从一对多关系的属性中移除。
    NSKeyValueChangeRemoval = 3,
    
    // 表示一个对象在一对多的关系的属性中被替换
    NSKeyValueChangeReplacement = 4
};
typedef NSUInteger NSKeyValueChange;

注意,观察者在不需要使用的时候一定要移除,否则会产生崩溃

- (void)dealloc {

    [self.dog removeObserver:self.p forKeyPath:@"name"];
}

通过上面简要的代码示例,我们可以得知,时运观察者只需要实现简单的几步。

  1. 注册观察者
  2. 观察者实现相应的方法
  3. 移除观察者

2.KVC和KVO的实现原理

KVCKVO是基于强大的Runtime来实现的。其中使用到的技术就是isa-swilling,isa-swilling这项技术也是一个重点,我们会在后续的Runtime部分会讲到。如果有看到此处不明白的同学也请保持耐心。

网上有一篇文章针对实现原理写的很好,链接在此

整体来说就是,当某个类的对象第一次被观察时,系统会在运行期间动态的为这个类创建一个派生类,假如被监听类名为ClassA,那么派生类的名称就为NSKVONotifying_ClassA

1.原有对象的isa指针会指向全新的派生类,派生类为了混淆,避免别人知道他不是原来的类,所以派生类重写了Class的类方法。

2.同时重写了Dealloc方法,用于资源的销毁处理。

3.还重写了_isKVOA,这个是一个标记,用于标示这个类是遵守KVO机制的。

4.最关键的是重写了被监听属性的Setter方法,这是实现KVO的关键。至于为什么,后面会讲解到。

简单的画了张图,可能会有助于理解。


我们上面讲重写了被观察对象属性的Setter方法是十分关键的,这就要说起另外两个十分重要的方法

// 在属性值即将被修改的时候,会调用这个方法
- (void)willChangeValueForKey:(NSString *)key;

// 在属性值已经被修改的时候,会调用这个方法
- (void)didChangeValueForKey:(NSString *)key;

// didChangeValueForKey:方法会显式的调用
- (void)observeValueForKeyPath:(NSString *)keyPath 
                      ofObject:(id)object 
                        change:(NSDictionary<NSKeyValueChangeKey,id> *)change 
                       context:(void *)context {
                       }

其实我个人猜测,重写Setter方法内部应该这样实现的

[self willChangeValueForKey:@"name"];
[super setName:name];
[self didChangeValueForKey:@"name"];

说到这里,相信你应该完整的明白KVO的实现机制了。

// 这才是KVO机制触发的关键
- (void)didChangeValueForKey:(NSString *)key;

3.调用KVO的三种方法

综合上面KVO的实现原理,我们可以得出如下结论:

1.使用了KVC

使用了KVC,如果有访问器方法,则运行时会在访问器方法中调用will/didChangeValueForKey:方法;
没用访问器方法,运行时会在setValue:forKey方法中调用will/didChangeValueForKey:方法。

2.有访问器方法

运行时会重写访问器方法调用will/didChangeValueForKey:方法。
因此,直接调用访问器方法改变属性值时,KVO也能监听到。

3.直接调用

显式调用will/didChangeValueForKey:方法。

4.KVO自动通知、手动通知

通常意义下我们使用的都是自动通知,注册观察者之后,当触发will/didChangeValueForKey:方法后,观察者对象的- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context { }方法会被调用。

如果像实现手动通知,我们需要借助一个额外的方法

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key

这个方法默认返回YES,用来标记Key指定的属性是否支持KVO,如果返回值为NO,则需要我们手动更新。

我们还是用我们最上面的例子,监听Personname属性,不过这次我们采取手动通知的方式。

#import "Person.h"

@implementation Person

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {

    BOOL automaic = NO;
    if ([key isEqualToString:@"name"])
    {
        automaic = NO;
    }
    else
    {
        // 此处需要注意,没有被处理的其他属性要调用父类的原有方法
        automaic = [super automaticallyNotifiesObserversForKey:key];
    }
    return automaic;
}
@end


这样我们就已经标记好当Personname属性发生改变时,手动发送通知,代码如下:

@implementation Person

- (void)setName:(NSString *)name {
    
    if(name != _name)// 加一处判断,如果值相同,就无需发送通知了
    {   
        // 我们需要在值修改前调用`will...`方法
        [self willChangeValueForKey:@"name"];
        _name = name;
        // 我们还需要在修改后调用`did...`方法,显式调用观察者的方法
        [self didChangeValueForKey:@"name"];
    }
}
@end

手动发送通知一对一的操作方法如上,如果是一对多的案例,则可以使用如下方法

- (void)willChange:(NSKeyValueChange)change valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key
- (void)didChange:(NSKeyValueChange)change valuesAtIndexes:(NSIndexSet *)indexes forKey:(NSString *)key
  
- (void)willChangeValueForKey:(NSString *)key withSetMutation:(NSKeyValueSetMutationKind)mutationKind usingObjects:(NSSet *)objects
- (void)didChangeValueForKey:(NSString *)key withSetMutation:(NSKeyValueSetMutationKind)mutationKind usingObjects:(NSSet *)objects

5.注册依赖键

实际开发过程中可能会遇到这种场景,某个变量的值取决于其他的值。

我们还是看一个例子吧:

// 声明一个Person类,有三个属性
#import <Foundation/Foundation.h>

@interface Person : NSObject

@property (nonatomic,copy) NSString *fullName;
@property (nonatomic,copy) NSString *firstName;
@property (nonatomic,copy) NSString *lastName;

@end

// 其中 fullName 取决于 firstName 和 lastName的值.
// 同时如果 firstName 和 lastName发生改变的话,fullName也会受到影响。

#import "Person.h"

@implementation Person

// 注册 fullName依赖于 firstName 和 lastName
+ (NSSet<NSString *> *)keyPathsForValuesAffectingFullName {

    return [NSSet setWithObjects:@"firstName",@"lastName",nil];
}

- (NSString *)fullName {

    NSString *tempName = _fullName;
    
    if (_firstName || _lastName)
    {
        tempName = [NSString stringWithFormat:@"%@-%@",_firstName,_lastName];
    }

    return tempName;
}

回到Controller:

- (void)viewDidLoad {
    
    [super viewDidLoad];
    
    Person *p = [Person new];
    self.p = p;
    
    [self.p  addObserver:self
        forKeyPath:NSStringFromSelector(@selector(name))
           options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
           context:ContextMark];
    
    self.p.fullName = @"lilei";
    NSLog(@"fullName = %@",self.p.fullName);
    
    self.p.firstName = @"lala";
    NSLog(@"fullName = %@",self.p.fullName);

    self.p.lastName = @"papa";
    NSLog(@"fullName = %@",self.p.fullName);
}

// 打印结果如下
fullName = lilei
fullName = lala-(null)
fullName = lala-papa

6.KVO使用中的"坑"

最近我在看这方面资料的时候,发现大家都以 tableViewContentOffset作为例子。咱们就用这个最常见的控件来说明一下吧。

1.keyPath为字符串

众所周知,KVO里面的KeyPathNSString类型,结合Obj-C动态语言的特性,在编译时是不做检查的,只有运行到执行的时候,才会动态的去方法列表实例变量列表中去查找,所以一旦我们写错了KeyPath,不运行的时候很难发现。

基于这个问题,我们用以下的方法规避

// 这样就不会写错了
NSStringFromSelector(@selector(contentSize))
2.多层继承、共用同一个回调方法

假如父类的控制器监听了tableViewContentOffset属性,同时该控制器还监听了其他控件的一些属性,但是同一个对象或者控制器作为多个对象属性的观察者,实际上最后调用的都是同一个回调方法- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context { },这样写极其容易混淆,所以我们为了解决这个问题,把代码写成如下的样子

- (void)observeValueForKeyPath:(NSString *)keyPath 
                      ofObject:(id)object
                        change:(NSDictionary *)change 
                       context:(void *)context
{
    if (object == _tableView && [keyPath isEqualToString:@"contentOffset"]) 
    {
        [self doSomethingWhenContentOffsetChanges];
   } 
}

但是光这样写是不全面的,因为当前的这个类很可能有父类,并且它的父类可能绑定了一些其他的KVO,上面的代码只有一个条件判断,一旦不成立,此次KVO的触发操作也就断了。而当前类无法捕捉的这个KVO事件很可能就在它的父类里,或者是父类的父类,上述操作,将这一链条截断,所以正确的方法应该如下:

- (void)observeValueForKeyPath:(NSString *)keyPath 
                      ofObject:(id)object
                        change:(NSDictionary *)change 
                       context:(void *)context
{
    if (object == _tableView && [keyPath isEqualToString:@"contentOffset"]) 
    {
        [self doSomethingWhenContentOffsetChanges];
   } 
   else
   {
       [super observeValueForKeyPath:keyPath 
                            ofObject:object 
                              change:change 
                             context:context];
   }
}

这样做这一链条就完整的保留了。

3.观察者的注销

上面的方法做完之后还是有隐患的。我们知道KVO不用的时候是需要注销的。我们知道当你对同一个KVO注销两次的时候,系统默认是抛出异常的。

你可能会好奇,什么时候我会对同一个Observer注销多次呢?

这个时候我们可以想一下我们注销Observer的时机,是不是多在Dealloc方法中?

Obj-C中,有很多系统的方法被重写时需要调用super xxxxxxx等方法,这是Obj-C的继承关系决定的。

例如:

// 在重写init方法时,我们要调用一下父类的init方法
- (instancetype)init {

    [super init];
}

// 布局子控件时,要调用一下父类的layoutSubviews方法
- (void)layoutSubviews {

    [super layoutSubviews];
}

还有些方法,不需要调用父类的方法,自动就会帮你调用,就如我们所说的Dealloc。其实只有在ARC模式下才不需要调用父类,MRC下的Dealloc还是要手动调用super dealloc的。

所以我们在注销观察者的时候就这么写

- (void)dealloc {

    [_tableView removeObserver:self forKeyPath:@"contentOffset"];
}

假设我们有三个类 ClassA(父类)ClassB(子类)ClassC(孙子类)。这三个类都作为观察者,观察tableViewcontentOffset属性。

如果我们在ClassC(孙子类)Dealloc方法中释放观察者

- (void)dealloc {

    [_tableView removeObserver:self forKeyPath:@"contentOffset"];
}

ClassC(孙子类)Dealloc执行完毕后,就会自动去ClassB(子类)Dealloc方法中,释放观察者

- (void)dealloc {

    [_tableView removeObserver:self forKeyPath:@"contentOffset"];
}

这个时候就出现崩溃了,因为我们在前面提到过这样会导致相同的removeObserver被执行两次,于是导致crash。

4.正确写法

针对这种类型的Crash,我们就要谈一下在注册Observer似的一个关键的参数Context,之前我是不知道这个Context是做啥用的,对于KVO的使用只是流于表面,所以对于这个神秘的Context的作用一直没有深究,现在我们将使用Context来为每一个Observer做区分,避免多次调用相同的removeObserver

KVO的三个关键方法

//  注册观察者
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;

// 观察者响应方法
- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context;

// 移除观察者(有两个方法)
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context NS_AVAILABLE(10_7, 5_0);

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

相比细心的同学已经看出来了,我们在注册响应移除的三个步骤里,都可以找到Context这个关键字。所以为了保持注册响应移除的一致性,正确的写法应该如下:

// 首先我们应在使用KVO的类中,创建一个独一无二的Context,用来和其他类进行区分
static Void *ContextMark = &ContextMark;

// 接下来注册的时候用
 [_tableView addObserver:self
              forKeyPath:NSStringFromSelector(@selector(contentSize))
                 options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld)
                 context:ContextMark];
                 
// 响应的时候用
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    
    if (context == ContextMark)
    {
        // do someThing
    }
    else
    {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

// 注销的时候用
- (void)dealloc {
    [_tableView removeObserver:self 
                    forKeyPath:NSStringFromSelector(@selector(contentSize)) 
                       context:ContextMark];
}

如果还不放心,也可以使用@try @catch去捕获异常

7.总结、

KVO这套 API 真麻烦~~~~~

知识总结
Web note ad 1