KVC/KVO

过去的靠现在忘记,将来的靠现在努力,现在才最重要。

1. KVC

KVC 也就是 key-value-coding,即键值编码,是一种间接访问对象实例变量(对象的属性)的方法机制。KVC 的主要用法如下。

1.1 通过键值路径为对象的属性赋值,主要是可以为私有的属性赋值

KVC 键值编码,通常是用来给某一个对象的属性进行赋值,例如有动物这么一个类,其对外有三个属性,类别、名字和年龄,我们在创建了一个动物 animal 后可以通过点语法直接给 animal 赋值。

// Animal.h文件
@interface Animal : NSObject

@property (nonatomic,strong)NSString *type;
@property (nonatomic,strong)NSString *name;
@property (nonatomic,assign)NSInteger age;

@end
  
// 创建对象并通过点语法赋值
Animal *animal = [[Animal alloc] init];
animal.type = @"dog";
animal.name = @"小白";
animal.age = 2;

我们也可以通过 KVC 给这个动物 animal 赋值,代码如下,因为 setValue 这里的值是 id 类型的,所以将整数包装成一个对象:

// KVC赋值
[animal setValue:@"cat" forKey:@"type"];
[animal setValue:@"小花猫" forKey:@"name"];
[animal setValue:@3 forKey:@"age"];

但是我们这样去赋值显得多此一举,可是如果人这个类的属性是没有暴露在外面呢?比如现在给动物这个类一个私有的体重的属性,并且对外提供一个输出体重的接口,如下:

// Animal.m文件
@implementation Animal {
    NSInteger _weight;
}

- (void)logWeight {
  NSLog(@"006 - KVC/KVO Weight = %ld",_weight);
}

@end
  
// 通过kvc直接对私有属性/变量进行赋值
[animal setValue:@15 forKey:@"weight"];
[animal logWeight];

// 输出结果
2021-08-22 21:36:03.040994+0800 001 - Class[2058:31772] 006 - KVC/KVO Weight = 15

针对代码 [animal setValue:@15 forKey:@"weight"]; 我们传入的字符串key 是 height,但是定义的属性是 _height,但是通过 KVC 还是可以给_height 属性赋到值。说明对某一个属性进行赋值,可以不用加下划线,而且它的查找规则应该是:先查找和直接写入的字符串相同的成员变量,如果找不到就找以下划线开头的成员变量。

通过 KVC 对对象属性赋值的两种方法比较:

除了 [animal setValue:<#(nullable id)#> forKey:<#(nonnull NSString *)#>] 这个方法外,还有一个方法也是可以对私有属性进行赋值的 [animal setValue:<#(nullable id)#> forKeyPath:<#(nonnull NSString *)#>],这两个方法对于一个普通的属性是没有区别的,都可以用,但是对于一些特殊的属性就有区别了。比如说动物这个类有个属性是所属主人(动物的拥有者,也是用一个类来描述),而人又有属性体重。

animal.people = [[People alloc] init];
[animal setValue:@70 forKey:@"people.weight"];

如果我们直接这样是会报错说找不到 people.weight 这个 key 的,而在storyboard 中,我们拖控件连线错误的时候也会报错说找不到什么 key,说明 storyboard 在赋值的时候也是通过 KVC 来操作的。

这里如果我们换另外的一个方法,这时候是不会报错的,而且可以打印出人的体重。

[animal setValue:@70 forKeyPath:@"people.weight"];

// 输出结果
2021-08-22 22:01:03.944144+0800 001 - Class[6806:55872] 006 - KVC/KVO type=cat name=小花猫 age=3 people.weith=70

说明 forKeyPath 是包含了 forKey 这个方法的功能的,甚至 forKeyPath 方法还有它自己的高级的功能,它会先去找有没有 people 这个 key,然后去找有没有 weight 这个属性。所以我们在使用 KVC 的时候,最好用 forKeyPath 这个方法。

1.2 通过键值路径获取属性的值,主要是可以通过 key 获得私有属性的值

// 通过KVC进行取值
NSLog(@"006 - KVC/KVO name=%@", [animal valueForKey:@"name"]);
NSLog(@"006 - KVC/KVO people.weight=%@", [animal valueForKeyPath:@"people.weight"]);

1.3 KVC 的另外一个用处:字典转模型

KVC 除了访问私有变量这个用处外,还可以用于字典转模型。在 Animal 类对外提供一个接口,将转模型的工作放在模型中进行。

- (instancetype)initWithDict:(NSDictionary *)dict {
    if (self = [super init]) {
        [self setValuesForKeysWithDictionary:dict];
    }
    return self;
}

外面可以直接将字典传入,和平常转模型相比,KVC 更加方便,减少了代码量。

// 字典转模型
NSDictionary *animalDict = @{@"type":@"dog", @"name":@"小黑",@"age":@"3"};
Animal *animal2 = [[Animal alloc] initWithDict:animalDict];

通过 KVC 字典转模型要注意以下几点:

字典转模型的时候,字典中的某一个 key 一定要在模型中有对应的属性,否则就会报错 NSUnknownKeyException。当然我们可以实现下面这个函数来解决这个问题:

- (void)setValue:(id)value forUndefinedKey:(NSString *)key{
		// 函数体可以为空
  
  	// 如果只是属性命名不一致,我们可以这样转换
  	if ([key isEqualToString:@"test"]) {
        self.testID = value;
    }
}

如果一个模型中包含了另外的模型对象,是不能直接转化成功的;

1.4 基于以上我们总结一下,KVC 的基本用处

1)通过键值路径为对象的属性赋值,主要是可以为私有的属性赋值;

2)通过键值路径获取属性的值,主要是可以通过 key 获得私有属性的值;

3)字典转模型;

4)修改一些系统控件的内部属性;例如设置:UITextField 中的placeHolderText

[textField setValue:[UIFont systemFontOfSize:25.0] forKeyPath:@"_placeholderLabel.font"];

如何获取控件内部的属性:

unsigned int count = 0;
objc_property_t *properties = class_copyPropertyList([UITextField class], &count);
for (int i = 0; i < count; i++) {
    objc_property_t property = properties[i];
    const char *name = property_getName(property);
    NSLog(@"name:%s",name);
}

5)高阶消息传递

当对容器类使用 KVC 时,valueForKey: 将会被传递给容器中的每一个对象,而不是容器本身进行操作。结果会被添加进返回的容器中,这样,开发者可以很方便的操作集合来返回另一个集合。

NSArray *arr = @[@"hubert", @"andy", @"cydia"];
NSArray *arrCap = [arr valueForKey:@"capitalizedString"];
for (NSString *str  in arrCap) {
    NSLog(@"%@",str);
}

KVC 对数组的作用机制

  • 当对 NSArray 调用 valueForKey: 时,KVC 会遍历数组中的每个元素,并对每个元素调用指定的 key 方法(此处为 capitalizedString)。
  • capitalizedStringNSString 的系统方法,作用是将字符串的首字母大写,其余字母小写(如 @"hubert"@"Hubert")。

打印结果‌,上述代码会输出以下内容:

Hubert  
Andy  
Cydia  
  • 原数组 @[@"hubert", @"andy", @"cydia"] 中的每个字符串均被转换为首字母大写形式。

关键原理说明

  • KVC 的集合操作特性‌:
    valueForKey: 在数组上的调用会自动映射到每个元素,相当于对每个元素执行 [obj capitalizedString]
  • 系统方法兼容性‌:
    capitalizedStringNSString 的公开方法,KVC 可直接通过键名调用,无需额外实现。

此结果展示了 KVC 如何简化集合元素的批量操作。

6)KVC 中的函数操作集合

集合运算符: @avg @count @max @min @sum

@interface Book : NSObject
@property (nonatomic,assign)  CGFloat price;
@end

NSArray* arrBooks = @[book1,book2,book3,book4];
NSNumber* sum = [arrBooks valueForKeyPath:@"@sum.price"];

对象运算符

  • @distinctUnionOfObjects
  • @unionOfObjects
// 获取所有Book的price组成的数组,并且去重
NSArray* arrDistinct = [arrBooks valueForKeyPath:@"@distinctUnionOfObjects.price"];

2. 观察者模式(KVO)

观察者模式是一种对象行为模式。它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。在观察者模式中,主体是通知的发布者,它发出通知时并不需要知道谁是它的观察者,可以有任意数目的观察者订阅并接收通知。观察者模式不仅被广泛应用于软件界面元素之间的交互,在业务对象之间的交互、权限管理等方面也有广泛的应用。

具体理解为 KVO 是键值观察者(key-value-observing),是苹果提供的一套事件通知机制(也叫做观察者模式)。允许对象监听另一个对象特定属性的改变,并在改变时接收到事件。由于 KVO 的实现机制,只针对属性才会发生作用,一般继承自NSObject 的对象都默认支持 KVO

KVONSNotificationCenter 都是 iOS 中观察者模式的一种实现。KVO 对被监听对象无侵入性,不需要修改其内部代码即可实现监听。

KVO 可以监听单个属性的变化,也可以监听集合对象的变化。通过 KVCmutableArrayValueForKey: 等方法获得代理对象,当代理对象的内部对象发生改变时,会回调 KVO 监听的方法。集合对象包含 NSArrayNSSet

2.1 KVO 基本使用

1)给对象的属性注册观察者:

// 注册观察者
[animal2 addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:nil];

2)在观察者中实现监听方法,observeValueForKeyPath: ofObject: change: context:(通过查阅文档可以知道,绝大多数对象都有这个方法,因为这个方法属于 NSObject):

/**
 观察者监听的回调方法
 @param keyPath 监听的keyPath
 @param object 监听的对象
 @param change 更改的字段内容
 @param context 注册时传入的地址值
 */
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    // 获取变化前后的值
    NSLog(@"006 - KVO change=%@",change);
}

3)移除观察者:

- (void)dealloc {
    [animal2 removeObserver:self forKeyPath:@"name"];
}

4)调用:

// 以下调用方式都可以触发KVO
animal2.name = @"新名字";
[animal2 setName:@"新名字1"];
[animal2 setValue:@"新名字2" forKey:@"name"];
[animal2 setValue:@"新名字3" forKeyPath:@"name"];

5)手动调用:

KVO 在属性发生改变时的调用是自动的,如果想要手动控制这个调用时机,或想自己实现 KVO 属性的调用,则可以通过 KVO 提供的方法进行调用。下面以animal2name 属性为例:

5.1)禁用自动调用:

// name 不需要自动调用,name 属性之外的自动调用
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    BOOL automatic = NO;
    if ([key isEqualToString:@"name"]) {
        automatic = NO;
    } else {
        automatic = [super automaticallyNotifiesObserversForKey:key];
    }
    return automatic;
}

// 单独设置某个属性
+ (BOOL)automaticallyNotifiesObserversOfName {
    return NO;
}

针对每个属性,KVO都会生成一个 + (BOOL)automaticallyNotifiesObserversOfXXX 方法,返回是否可以自动调用KVO。按上面实现上述方法,我们会发现,此时改变 name 属性的值,无法触发KVO,还需要实现手动调用才能触发 KVO。

5.2)手动调用实现:

// KVO 手动调用实现
- (void)setName:(NSString *)name {
    if (_name != name) {
        [self willChangeValueForKey:@"name"];
        _name = name;
        [self didChangeValueForKey:@"name"];
    }
}

实现了(1)禁用自动调用(2)手动调用实现两步,name 属性手动调用就实现了,此时能和自动调用一样,触发 KVO。

6)Crash

KVO 若使用不当,极容易引发 Crash,下面总结使用过程中常见问题。

  • 观察者未实现监听方法;

  • 未及时移除观察者;

  • 多次移除观察者;

7)属性依赖

// 属性依赖:如果属性type改变,观察者也能收到name改变的通知
+ (NSSet<NSString *> *)keyPathsForValuesAffectingName {
    NSSet *set = [NSSet setWithObjects:@"type", nil];
    return set;
}

8)监听集合对象的变化

首先,数组不能直接使用 KVO 使用监听。当我们想要使用 KVO 监听数组的状态时改变时,我们需要进行以下几步:

8.1)KVO 不能直接监听 UIViewController 中的数组变化。我们需要先创建一个模型,将需要监听的数组封装到模型中。然后控制器 UIViewController 持有模型对象,通过该对象才能监听。

@interface ObserveArray : NSObject

@property (nonatomic,strong)NSMutableArray *datas;

- (id)initWithDict:(NSDictionary *)dict;

@end

8.2)建立观察者与观察的对象,在观察者中实现监听方法

// ViewController.m 文件中
@interface ViewController () 
  
// 数组模型对象,来代替纯数组保存数据  
@property (nonatomic,strong)ObserveArray *observeArray;
// 临时数组,用来接收被观察的模型中的数据,方便数据操作
@property (nonatomic,strong)NSMutableArray *tempArray;

@end
  
- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    
  	// 创建观察的对象模型
  	NSDictionary *dict = [NSDictionary dictionaryWithObject:[NSMutableArray arrayWithCapacity:0] forKey:@"datas"];
    _observeArray = [[ObserveArray alloc] initWithDict:dict];  
  
  	[self KVOObserverArray];
}

// 给数组模型添加观察者
- (void)KVOObserverArray {
    // 建立观察者以及观察者对象
    [_observeArray addObserver:self forKeyPath:@"datas" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:nil];
    
  	// 模拟改变数据,这里不能直接[_observeArray.datas addobject"@""];
    //[[_observeArray mutableArrayValueForKey:@"datas"] addObject:@"测试"];
    _tempArray = [_observeArray mutableArrayValueForKey:@"datas"];
    [_tempArray addObject:@"测试1"];
}

// 在观察者中实现监听方法
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:@"datas"]) {
        NSLog(@"006 - KVO Array change=%@",change);
    }
}

8.3)移除观察者

- (void)dealloc {
    if (_observeArray != nil) {
        [_observeArray removeObserver:self forKeyPath:@"datas"];
    }
}

2.2 应用场景

  1. 下拉刷新、下拉加载监听 UIScrollView 的 contentoffsize;
  2. webview 混排监听 contentsize;
  3. 可以用来应用在导航栏颜色渐变;
  4. 监听模型属性实时更新 UI;
  5. 监听控制器 frame 改变,实现抽屉效果;
  6. 监听集合对象对象的变化;
  7. 监听带有状态的基础控件,如开关、按钮等;
  8. 当数据模型的数据发生改变时,视图组件能动态的更新,及时显示数据模型更新后的数据,比如 tableview 中数据发生变化进行刷新列表操作。

3. KVO/KVC、代理、通知的区别

3.1 与 KVC 的不同?

KVC,即是指 NSKeyValueCoding,一个非正式的 Protocol,提供一种机制来间接访问对象的属性,而不是通过调用 Setter、Getter 方法等显式的存取方式去访问。KVO 就是基于 KVC 实现的关键技术之一。

KVO,即 Key-Value Observing,它提供一种机制,当指定的对象的属性被修改后,对象就会接受到通知。

3.2 与 delegate 的不同?

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

3.3 和 notification 的区别?

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

参考:https://www.sohu.com/a/343498712_671228

4. KVO 原理

当一个类的对象属性被观察时,系统会通过 runtime 动态的创建一个该类的派生类,并且会在这个类中重写基类被观察的属性的 setter 方法,并且在 setter 方法中实现了通知的机制。派生类重写了class方法,以“欺骗”外部调用者他就是原先那个类。而且系统将这个类的 isa 指针指向新的派生类,因此改对象也就是改新的派生类的对象了。从而实现了给监听的属性赋值时调用的是派生类的 setter 方法。从而激活键值通知机制,重写的 setter 方法会在调用原 setter方法前后,通知观察对象值得改变。此外派生类还重写了 delloc 方法来释放资源。

2. KVO原理图

1. KVO 机制图

我们再看下详细的解析,全称是Key-value observing,翻译成键值观察。提供了一种当其它对象属性被修改的时候能通知当前对象的机制。在 MVC 大行其道的 Cocoa 中,KVO 机制很适合实现 model 和 controller 类之间的通讯。

KVO 的实现依赖于 Objective-C 强大的 Runtime,当观察某对象 A 时,KVO 机制动态创建一个对象 A 当前类的子类,并为这个新的子类重写了被观察属性 keyPathsetter 方法。setter 方法随后负责通知观察对象属性的改变状况。

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

A 类剖析:

NSLog(@"self->isa:%@",self->isa);  
NSLog(@"self class:%@",[self class]);  

在建立 KVO 监听前,打印结果为:

self->isa:A
self class:A

在建立 KVO 监听之后,打印结果为:

self->isa:NSKVONotifying_A
self class:A

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

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

子类 setter 方法剖析:

KVO 的键值观察通知依赖于 NSObject 的两个方法:willChangeValueForKey:didChangeValueForKey: ,在存取数值的前后分别调用 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 在调用存取方法之后总调用
}

https://sg.jianshu.io/p/47ead92286cd

https://blog.csdn.net/qq_18505715/article/details/80205796

https://www.jianshu.com/p/d412ac1113a0

5. Swift 中 KVO 的实现与限制

‌5.1 与 OC 的 KVO 对比

  • OC 的 KVO‌:基于 NSKeyValueCoding 协议和动态派发(Runtime),所有继承自 NSObject 的类默认支持。

  • ‌Swift 的 KVO:

    默认不支持‌:Swift 属性默认关闭动态派发(静态派发优先),需显式添加 @objc dynamic 修饰符开启运行时支持。

    依赖 NSObject‌:被观察类和观察者均需继承自 NSObject,否则无法使用 KVO。

‌5.2 Swift 实现 KVO 的条件

  • ‌属性标记:被观察属性需添加修饰符

    @objc dynamic
    
    class Person: NSObject {
        @objc dynamic var age: Int = 0  // 支持 KVO
        var name: String = ""           // 不支持 KVO
    }
    
  • 观察者注册‌:通过 addObserver(_:forKeyPath:options:context:) 方法监听,并重写 observeValue(forKeyPath:of:change:context:) 处理变更。

‌5.3 替代方案:属性观察器(Property Observer)

  • ‌Swift 原生特性:通过 willSetdidSet 监听属性变化,无需继承 NSObject

    var score: Int = 0 {
        willSet { print("新值: \(newValue)") }
        didSet { print("旧值: \(oldValue)") }
    }
    
  • 局限性‌:仅能观察同一类内的属性,无法跨对象监听。

‌5.4 注意事项‌

  • 性能影响‌:dynamic 会强制启用动态派发,可能降低性能。
  • 类型限制‌:KVO 仅适用于类(Class),不适用于结构体(Struct)或枚举(Enum)。

Swift 中可通过 @objc dynamic 实现类似 OC 的 KVO,但更推荐优先使用属性观察器以减少运行时开销。

posted @ 2021-08-25 17:36  背包の技术  阅读(769)  评论(0)    收藏  举报