iOS KVC/KVO小结

原文链接:http://www.yupeng.fun/2020/04/16/kvo-kvc/


本文对 KVC、KVO 相关知识进行全面的整理总结,介绍了相关的基本概念、使用方法、注意事项、实现原理等。后续如有更深的理解会继续整理总结。

简介

KVC ( Key-value coding 键值编码 ) 是一种由 NSKeyValueCoding 非正式协议启用的机制,对象采用该机制提供对其属性的间接访问。当对象符合键值编码时,通过字符串名称访问对象属性。
键值编码的机制也是其他 Cocoa 框架的基础,例如 KVO。

KVO ( Key-value observing 键值观察 ) 这一机制基于 NSKeyValueObserving 非正式协议,Cocoa 通过这个协议为所有遵守协议的对象提供了一种自动化的属性观察能力。对目标对象的某属性添加观察,当该属性发生变化时,通过触发观察者对象实现的 KVO 接口方法,来通知观察者。KVO 是 Cocoa 框架使用观察者模式的一种途径。

KVC

基本使用方法

KVC 提供了简洁的方法,来访问对象属性。

- (nullable id)valueForKey:(NSString *)key;  
- (void)setValue:(nullable id)value forKey:(NSString *)key;

上面两个方法,分别是对应于 getter 访问器的 valueForKey: 和对应于 setter 访问器的 setValue:forKey: 。

  • valueForKey: 首先查找以键 -getKey、 -key 或 -isKey 命名的 getter 方法。如果不存在 getter 方法(假设没有通过@synthesize提供存取方法),它将在对象内部查找名为 _key 或 key 的实例变量。如果最后没找调用 valueForUndefinedKey: 方法。
  • setValue:forKey: 首先查找以键 -setKey、 -_setKey 命名的 setter 方法,如果不存在 setter 方法,它将在类中查找名为 _key 或 key 的实例变量。如果最后没找到则调用 setValue:forUndefinedKey: 方法。

例如某对象有属性 name、age,我们就可使用上面两个方法进行访问、设置属性值。

@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) int age;

[obj setValue:@"jone" forKey:@"name"];
[obj setValue:@(10) forKey:@"age"];
id stAge = [obj valueForKey:@"age"];

对于属性是基本的数据类型时 (int, CGFloat) 是放入 NSNumber 或 NSValue 中来设置的。
相比直接访问,KVC的效率会稍低一点,所以只有当你非常需要它提供的可扩展性时才使用它。

其他使用方法

1、属性的属性的访问
KVC 还提供了访问属性的属性的操作方法:

- (nullable id)valueForKeyPath:(NSString *)keyPath;
- (void)setValue:(nullable id)value forKeyPath:(NSString *)keyPath;

例如下面两个类:

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

@interface Teacher : NSObject
@property (nonatomic, strong) Student *student;
@end

//路径访问
[teacher setValue:@"haha" forKeyPath:@"student.name"];
id name = [teacher valueForKeyPath:@"student.name"];

2、多值访问
同时访问多个属性的方法:

[obj setValuesForKeysWithDictionary:@{@"name":@"Tom", @"age":@(2)}];
NSDictionary *values = [obj dictionaryWithValuesForKeys:@[@"name",@"age"]];

3、集合属性
KVC 同样适用于集合对象,可以通过 valueForKey: 和 setValue:forKey:(或它们的键路径方式)获取或设置集合对象。

//@property (nonatomic, copy) NSArray *students;
id array = [teacher valueForKeyPath:@"students.name"];
//返回数组,包含属性 student.name

KVC 还提供了接口 mutableArrayValueForKey:、 mutableSetValueForKey: 来操作集合类型的属性。

//@property (nonatomic, copy) NSArray *items;

obj.items = @[@"a", @"b", @"c"];
NSMutableArray *items = [obj mutableArrayValueForKey:@"items"];
[items addObject:@"d"];
//添加后,同时也改变了 obj.items

4、运算符
运算符是一个特殊的 Key Path,可以作为参数传递给 valueForKeyPath:方法,注意只能是这个方法,如果传给了valueForKey:方法会崩溃。
运算符是一个以@开头的特殊字符串:

  • 简单集合运算符有 @avg,@count,@max,@min,@sum
  • 对象运算符,比集合运算符稍微复杂,能以数组的方式返回指定的内容,有两种 @distinctUnionOfObjects、@unionOfObjects ,前者会去除重复的以后返回,后者直接返回。
  • Array和Set操作符,这种情况更复杂了,说的是集合中包含集合的情况,有三种 @distinctUnionOfArrays、@unionOfArrays、@distinctUnionOfSets,前两个针对的集合是Arrays,后一个针对的集合是Sets。因为Sets中的元素本身就是唯一的,所以没有对应的 @unionOfSets 运算符。
NSNumber *value = [teacher valueForKeyPath:@"[email protected]"];
NSNumber *count = [teacher valueForKeyPath:@"students.@count"];

 NSArray * array = [teacher valueForKeyPath:@"[email protected]"];
 
 NSMutableArray *someStudents = [NSMutableArray array];
[someStudents addObject:@[st0, st1, st2]];
[someStudents addObject:@[st3, st4]];
id value = [someStudents valueForKeyPath:@"@distinctUnionOfArrays.age"];

异常情况

1、找不到对应的 key
当调用 setValue:forKey: 或者 valueForKey: 找不到对应 key 命名的属性时,就会 NSUnknownKeyException 异常崩溃,可以在对象里重写下面两个方法,防止崩溃。

- (id)valueForUndefinedKey:(NSString *)key {
    return nil;
}
- (void)setValue:(id)value forUndefinedKey:(NSString *)key {
}

2、将不是对象类型的属性设置为 nil
将对象赋值为 nil 这是可以的相当于把对象置空。但是当使用 setValue:forKey: 将非对象类型的属性值( int、CGFloat、结构体等),设置为 nil 时会 NSInvalidArgumentException 异常崩溃。我们可以重写方法 setNilValueForKey: 处理设置为 nil 的情况:

//@property (nonatomic, assign) int age;
//[obj setValue:nil forKey:@"age"];

- (void)setNilValueForKey:(NSString *)key {
    if ([key isEqualToString:@"age"]) {
        [self setValue:@(0) forKey:@"age"];
    } else {
        [super setNilValueForKey:key];
    }
}


KVO

KVO 是 Cocoa 框架使用观察者模式的一种途径。 KVC 是 KVO 技术实现的基础 ,参与 KVO 的对象需要符合 KVC 的要求和存取方法,也可以手动实现观察者通知。

使用方法

1、添加观察:

[obj addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionOld|NSKeyValueObservingOptionNew context:(__bridge void *)self];
  • options 回调选项,NSKeyValueObservingOptionOld 表示获取旧值,NSKeyValueObservingOptionNew 表示获取新值,NSKeyValueObservingOptionInitial 表示在添加观察的时候就立马响应一个回调,NSKeyValueObservingOptionPrior 表示在被观察属性变化前后都回调一次。

  • context 可以是 C 指针或者一个对象引用,既可以当作一个唯一的标识来分辨被观察的变更,也可以向观察者提供数据。

2、观察回调:

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

/*
observe  key = name, obj = , change = {
    kind = 1;
    new = Tony;
    old = Tom;
}
*/

change 是一个字典,对应的键有:NSKeyValueChangeKindKey、NSKeyValueChangeNewKey、NSKeyValueChangeOldKey、NSKeyValueChangeIndexesKey、NSKeyValueChangeNotificationIsPriorKey。

NSKeyValueChangeKindKey 指明了变更类型,设置、插入、移除、替换:

enum {
   NSKeyValueChangeSetting = 1,
   NSKeyValueChangeInsertion = 2,
   NSKeyValueChangeRemoval = 3,
   NSKeyValueChangeReplacement = 4
};
typedef NSUInteger NSKeyValueChange;

NSKeyValueChangeNotificationIsPriorKey 指明是变更前或变更后,触发的回调。

3、移除观察:

[obj removeObserver:self forKeyPath:@"name"];

移除观察,和移除通知比较类似,需要在不用继续观察的时候移除它,比如在控制器的 dealloc 方法里面释放,注意重复移除会 crash。

4、调试 KVO
可以打断点,在 lldb 中查看被观察对象的所有观察信息。

lldb po [obj observationInfo]

这会打印出有关谁观察谁之类的很多信息。

KVO 兼容

有两种方法可以保证变更通知被发出。自动发送通知是 NSObject 提供的,并且一个类中的所有属性都默认支持,只要是符合 KVC 的。
手动变更通知需要些额外的代码,但也对通知发送提供了额外的控制。可以通过重写子类 automaticallyNotifiesObserversForKey: 方法的方式控制子类一些属性的自动通知。

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

- (void)setName:(NSString *)name {
    if (![_name isEqualToString:name]) {
        [self willChangeValueForKey:@"name"];
        _name = [name copy];
        [self didChangeValueForKey:@"name"];
    }
}

//如果一个操作导致多个键变化,需要嵌套变更通知
- (void)setLastName:(NSString *)lastName {
    [self willChangeValueForKey:@"lastName"];
    [self willChangeValueForKey:@"fullName"];
    _lastName = [lastName copy];
    _fullName = [NSString stringWithFormat:@"Title %@", lastName];
    [self didChangeValueForKey:@"fullName"];
    [self didChangeValueForKey:@"lastName"];
}

当观察某个对象的集合属性时,当直接使用 obj.mutableArray 添加、删除、替换元素时,不会触发观察回调,需要手动添加代码 willChange:valuesAtIndexes:forKey: 和 didChange:valuesAtIndexes:forKey: 来通知集合属性发生了变化。或者使用 KVC 来操作集合属性。如下例子:

//某类集合属性
//@property (nonatomic, strong) NSMutableArray *myArray;

//添加观察
[obj addObserver:self forKeyPath:@"myArray" options:NSKeyValueObservingOptionOld|NSKeyValueObservingOptionNew context:nil];

//变更集合时,手动通知观察者
[self.obj willChange:NSKeyValueChangeRemoval valuesAtIndexes:[NSIndexSet indexSetWithIndex:0] forKey:@"myArray"];
[self.obj.myArray removeObjectAtIndex:0];
[self.obj didChange:NSKeyValueChangeRemoval valuesAtIndexes:[NSIndexSet indexSetWithIndex:0] forKey:@"myArray"];

//或者使用 KVC 操作集合,会自动通知观察者
NSMutableArray *array = [self.obj mutableArrayValueForKey:@"myArray"];
[array removeObjectAtIndex:0];

注册从属键

某些情况下,一个属性的值取决于另一个对象中的一个或多个其他属性的值。如果一个属性的值发生更改,则还应标记派生属性的值以进行更改。
例如,fullName 取决于 firstName 和 lastName。当 firstName 或 lastName 发生改变时,必须通知观察 fullName 属性的程序,因为它们影响这个属性的值。
重写 keyPathsForValuesAffectingValueForKey 来指定 fullName 属性依赖于lastName和firstName。

- (void)setLastName:(NSString *)lastName {
    _lastName = [lastName copy];
    _fullName = [NSString stringWithFormat:@"%@ %@", _firstName, lastName];
}

//重写指定 fullName 属性依赖于lastName
+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"fullName"]) {
        NSArray *affectingKeys = @[@"lastName", @"firstName"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    }
    return keyPaths;
}

//或者重写
+ (NSSet *)keyPathsForValuesAffectingFullName {
    return [NSSet setWithObjects:@"lastName", @"firstName", nil];
}

基本原理

KVO 是基于 runtime 运行时来实现的,当你观察了某个对象的属性,内部会生成一个该对象所属类的子类,中间类,然后重写被观察属性的 setter 方法,当然在重写的方法中会调用父类的 setter 方法从而不会影响框架使用者的逻辑,之后会将该对象的 isa 指针指向新创建的这个类,最后会重写 -(Class)class; 方法,让使用者通过 [obj class] 查看当前对象所属类的时候会返回其父类,使其看似没有改变什么,让你觉得不需要添加额外的代码,就能使用 KVO。
Apple 并不希望过多暴露 KVO 的实现细节。想要深究实现细节,可查看文章
下面例子,通过 object_getClass() 方法查看观察前后的变化。

//object_getClass 获取 isa 指针指向的对象
//object_setClass 更改对象的 isa 指针指向。将对象设置为别的类类型,返回原来的Class
NSLog(@"1 --- %p %@ %p", obj, object_getClass(obj), [obj methodForSelector:@selector(setName:)]);

[obj addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionOld|NSKeyValueObservingOptionNew context:(__bridge void *)self];

NSLog(@"2 --- %p %@ %p", obj, object_getClass(obj), [obj methodForSelector:@selector(setName:)]);


[self.obj removeObserver:self forKeyPath:@"name"];
NSLog(@"3 --- %p %@ %p", self.obj, object_getClass(self.obj), [self.obj methodForSelector:@selector(setName:)]);

//1 --- 0x60000225cea0  AStudent 0x1067f72a0
//2 --- 0x60000225cea0  NSKVONotifying_AStudent 0x7fff258e454b
//3 --- 0x60000225cea0  AStudent 0x1067f72a0

通过上面的打印结果可知,添加观察后,原本的类变成了 NSKVONotifying_AStudent,移除观察后又变回去了, setName: 方法也发生了变化。


References

KVC/KVO原理详解及编程指南
Objective-C中的KVC和KVO
透彻理解 KVO 观察者模式

你可能感兴趣的:(iOS KVC/KVO小结)