iOS Objective-C KVO 详解

iOS Objective-C KVO 详解

1. KVO

KVOKey-Value Observing是苹果提供给开发者的一套键值观察的API,KVO是一种机制,它允许将其他对象的指定属性的更改通知给对象。KVO是建立在KVC的基础上的,对于KVC的原理及应用可以查看我的上一篇文章。下面我们来详细的介绍KVO

1.1 KVO 可以观察什么属性?

根据KVO官方文档的定义,我们可以知道可观察的属性分为以下三种:

  • attributes: 简单属性,比如基本数据类型,字符串布尔值等,诸如NSNumber和其他一些不可变类型,比如NSColor也被认为是简单属性。
  • to-one relationships: 一对一关系,一个属性的值取决于另一个值。比如一个人的全名姓氏名字组成,其中任一一个改变都会影响全名的改变;其实下载进度也跟这个类似,下载量和总量的任一改变都会改变下载进度。
  • to-many relationships: 一对多关系,在KVO中不支持多对多的关系键值路径。一对多关系主要是集合对象属性,通常就是NSArray或者NSSet等,但是涉及改变就是他们的可变类型。比如有一个部门,有一个员工数组,员工薪资属性,部门有个总工资属性,我想监听总工资的变化,其实就是监听员工数组的改变,再其改变后可以通过KVC的数组操作符计算总工资的变化,然后手动调用willchange去触发总工资改变的监听

对于一对一和一对多的关系可以查看苹果官方文档,进一步了解和示例代码的查看。

1.2 KVO 的三个步骤

example.jpg

举个例子,如上图所示Person对象有个Account属性,而Account对象又有balanceinterestRate两个属性。现在我们想实现一个功能:当余额和利率变化的时候需要通知到用户,其实用户可以通过轮询的方式定期去查询Account对象中的balanceinterestRate,但是这种方式不仅不及时而且效率低,消耗大,更好的方式是使用KVO,使Person对象像收到通知一样能及时的知道余额和利率的变动。

另外要实现KVO的前提是被观察对象时符合KVO机制的,一般来说,继承于NSObject根类的对象及其属性都自动符合KVO机制。当然我们也可以自己去实现,使其同样符合KVO机制,这就是Manual Change Notification(手动变更通知),所以KVO包含Automatic Change Notification(自动变更通知)和Manual Change Notification(手动变更通知)两种机制。

KVO合规性官方文档

  • 首先是注册观察者
注册观察者.jpg

将观察者实例Person与观察实例Account注册在一起。Person对每个观察到的键路径向Account发送一个addObserver:forKeyPath:options:context:消息,将自己命名为观察者。这里observer(监听者)、keyPath(被监听者)、options(监听策略)、context(上下文)。

  • 被观察者触发回调
被观察者触发回调.jpg

为了接收Account的变更通知,Person需要实现observeValueForKeyPath:ofObject:change:context:方法。Account将在任何改变的时候想Person发送该消息,Person可以根据通知做出相应的措施。

  • 移除观察
移除观察.jpg

最后,当不需要监听的时候就可以通过removeObserver:forKeyPath:方法移除监听,但是移除必须在监听者对象销毁前执行。

1.3 KVO三个方法解析

1.3.1 注册观察者

- (void)addObserver:(NSObject *)observer
                     forKeyPath:(NSString *)keyPath 
                     options:(NSKeyValueObservingOptions)options 
                     context:(nullable void *)context;
  • observer: 观察者,一般都是self
  • keyPath: 被观察者的属性
  • options: NSKeyValueObservingOptions的组合,它指定观察通知中会回调什么值
  • context: 上下文,这里是一个nullable void *类型的参数,我们通常会传nil,其实应该传NULL,官方文档也说应该传NULL。其实这里我们可以传一个void *类型的指针,用来区分相同path的不同对象的观察。传值示例:static void *PersonNameContext = &PersonNameContext;

NSKeyValueObservingOptions:的四个枚举值

  1. NSKeyValueObservingOptionNew: 表明通知中的更改字典应该提供新的属性值,如果有的话。
  2. NSKeyValueObservingOptionOld: 表明通知中的更改字典应该包含旧的属性值,如果有的话。
  3. NSKeyValueObservingOptionInitial: 在属性发生变化后立即通知观察者,这个过程甚至早于观察者注册是时候。如果在注册的时候配置了 NSKeyValueObservingOptionNew,那么在通知的更改字典中也会包含 NSKeyValueChangeNewKey,但是不会包括 NSKeyValueChangeOldKey。(在初始通知中,观察到的属性值可能是旧的,但是对于观察者来说是新的)其实简单来说就是这个枚举值会在属性变化前先触发一次 observeValueForKeyPath 回调。
  4. NSKeyValueObservingOptionPrior: 这个会先后连续出发两次 observeValueForKeyPath 回调。同时在回调中的可变字典中会有一个布尔值的 key - notificationIsPrior 来标识属性值是变化前还是变化后的。如果是变化后的回调,那么可变字典中就只有 new 的值了,如果同时制定了 NSKeyValueObservingOptionNew 的话。如果你需要启动手动 KVO 的话,你可以指定这个枚举值然后通过 willChange 实例方法来观察属性值。在出发 observeValueForKeyPath 回调后再去调用 willChange 可能就太晚了。

下面我们来验证一下NSKeyValueObservingOptions几个key会有什么样的结果。

初始实现代码:

static void *PersonNameContext = &PersonNameContext;

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person  = [LGPerson new];
    self.person.name = @"nameA";
    
    [self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:PersonNameContext];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context{
  
    if (context == PersonNameContext) {
        NSLog(@"person name change %@ - %@",self, change);
    } else {
        [super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event{
    self.person.name  = @"nameB";
}

NSKeyValueObservingOptionNew:

new.jpg

NSKeyValueObservingOptionOld:

old.jpg

NSKeyValueObservingOptionInitial:

Initial.jpg

NSKeyValueObservingOptionInitial会触发两次回调,第一次是在属性改变前,第二次是在属性改变后。但是并没有返回任何的旧值和新值。其实第一次是在我们调用addObserver:forKeyPath:后就打印了的。这与name是否赋初始值没有关系,有没有初值都会打印。

Initial | New | Old:

Initial | New | Old.jpg

此时还是触发了两次回调,只不过第一次返回的新值其实就是旧值,就是我们初始化时的值,第二次返回即包含了新值,也包含了旧值。其实我们包含new在第二次就会返回新值,包含old就会返回旧值,如果不包含就不会返回。如果不包含new第一次就不会返回新值。

NSKeyValueObservingOptionPrior:

Prior.jpg

这是也是触发了两次回调,不过这两次回调是在值改变后触发的,并且第一次多返回了一个notificationIsPrior值。

Prior | New | Old:

Prior | New | Old.jpg

此时还是触发了两次回调,同样在第一次回调中包含notificationIsPrior值。并且第一次回调中多了旧值,第二次回调中即包含旧值也包含新值。同样我们包含new在第二次就会返回新值,包含old就会返回旧值,如果不包含就不会返回。如果不包含old第一次就不会返回旧值。

1.3.2 观察者接收通知

- (void)observeValueForKeyPath:(nullable NSString *)keyPath 
                    ofObject:(nullable id)object 
                    change:(nullable NSDictionary *)change 
                    context:(nullable void *)context;

除了change其他参数跟上面注册观察时的相同。

change包含五个key,如下:

key value 描述
NSKeyValueChangeKindKey NSNumber类型 1:Setting,2:Insertion,3:Removal,4:Replacement
NSKeyValueChangeNewKey id 变化后的新值
NSKeyValueChangeOldKey id 变化后的旧值
NSKeyValueChangeIndexesKey NSIndexSet 插入、删除或替换的对象的索引
NSKeyValueChangeNotificationIsPriorKey NSNumber boolValue Option为Prior时标识属性值是变化前和还是变化后的

NSKeyValueChangeKindKey对应的枚举:

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

1.3.3 移除观察

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

移除和注册时一一对应的,有两个方法一个是带context参数的,另一个是不带的。在观察者生命周期结束前,一定要移除观察,如果没有移除,KVO机制会给一个不存在的对象发送变化回调消息导致野指针错误。另外也不能重复移除注册,重复移除会导致crash,当然为了避免crash我们可以把移除放在@try里面去执行。

1.4 自动观察与手动观察

默认情况下,我们只需要按照上面的步骤就可以实现属性的观察,其实这是由系统完全控制的,属于自动观察。其实KVO还给我们提供了手动观察的选项。

如果我们想要开启手动观察就要通过重写类方法+ (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key,如果返回YES就是自动观察,返回NO就是手动观察,根据方法的我们还可以判断key值对不同的key分别实现自动观察手动观察

// 自动开关
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey {
 
    BOOL automatic = NO;
    if ([theKey isEqualToString:@"balance"]) {
        automatic = NO;
    }
    else {
        automatic = [super automaticallyNotifiesObserversForKey:theKey];
    }
    return automatic;
}

对于需要手动观察key在改变前需要调用willChangeValueForKey方法,在改变后需要调用didChangeValueForKey方法,如果不调用,就不会触发KVO的监听。

示例代码:

- (void)setBalance:(double)theBalance {
    [self willChangeValueForKey:@"balance"];
    _balance = theBalance;
    [self didChangeValueForKey:@"balance"];
}
  • 我们可以通过提前检查是否已更改来最大程度的减少发送不必要的通知:

官方示例:

- (void)setBalance:(double)theBalance {
    if (theBalance != _balance) {
        [self willChangeValueForKey:@"balance"];
        _balance = theBalance;
        [self didChangeValueForKey:@"balance"];
    }
}
  • 如果单个操作导致更改多个键,则必须嵌套更改通知

官方示例:

- (void)setBalance:(double)theBalance {
    [self willChangeValueForKey:@"balance"];
    [self willChangeValueForKey:@"itemChanged"];
    _balance = theBalance;
    _itemChanged = _itemChanged+1;
    [self didChangeValueForKey:@"itemChanged"];
    [self didChangeValueForKey:@"balance"];
}
  • 对于有序的一对多关系,不仅必须指定已更改的键,还必须指定更改的类型和所涉及对象的索引。
    • 改变的类型的键值是一个 NSKeyValueChange 类型的枚举值,有三个分别是:NSKeyValueChangeInsertionNSKeyValueChangeRemovalNSKeyValueChangeReplacement
    • 受影响对象的索引作为NSIndexSet对象传递。

示例代码:

- (void)removeTransactionsAtIndexes:(NSIndexSet *)indexes {
    [self willChange:NSKeyValueChangeRemoval
        valuesAtIndexes:indexes forKey:@"transactions"];
 
    // Remove the transaction objects at the specified indexes.
 
    [self didChange:NSKeyValueChangeRemoval
        valuesAtIndexes:indexes forKey:@"transactions"];
}

1.5 Registering Dependent Keys(注册从属关系的键值)

在许多情况下,一个属性的值取决于另一对象中一个或多个其他属性的值。如果一个属性的值发生更改,则派生属性的值也应标记为更改。如何确保为这些从属属性发布键值观察通知取决于关系的基数。 这个在上面已经有所提到,这里在通过举例进行详细的说明。

1.5.1 一对一关系

要自动触发一对一关系的通知,您应该重写 keyPathsForValuesAffectingValueForKey:或实现遵循其定义的用于注册从属键的模式的合适方法。

例如,一个人的全名取决于名字和姓氏。返回全名的方法可以编写如下:

- (NSString *)fullName {
    return [NSString stringWithFormat:@"%@ %@",firstName, lastName];
}

fullName当firstName或lastName属性更改时,必须通知观察该属性的应用程序,因为它们会影响属性的值。

第一种方法是我们通过重写keyPathsForValuesAffectingValueForKey:指定fullName的属性取决于lastNamefirstName属性。通常我们应该调用super并返回一个集合,该集合包括这样做所导致的集合中的其他任何成员免受干扰。

+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
 
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
 
    if ([key isEqualToString:@"fullName"]) {
        NSArray *affectingKeys = @[@"lastName", @"firstName"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    }
    return keyPaths;
}

第二种方法是通过实现遵循命名约定的类方法keyPathsForValuesAffecting来实现相同的结果,其中是依赖值的属性名称(首字母大写)。实现代码如下:

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

对于分类我们只能以第二种方法进行实现因为我们不能再分类中覆盖keyPathsForValuesAffectingValueForKey:的实现。

1.5.2 一对多关系

keyPathsForValuesAffectingValueForKey:方法不支持包含多对多关系的键路径。那么对于这种关系的键值路径我们该如何处理呢?

例如我们有个Department(部门),他又一个employees(员工数组)对象,部门跟员工有很多关系,但是Employee(员工)具有salary(薪资)属性,这时我们希望部门有个totalSalary(总工资)属性,那么这个属性取决于员工数组中所有员工的薪资,我们也不能使用keyPathsForValuesAffectingTotalSalaryemployees.salary作为键返回。

此时我们可以使用键值观察将父项(在此示例中为Department)注册为所有子项(在此示例中为employees)的相关属性的观察者。您必须作为观察者添加和删除父对象,因为要在关系中添加或删除子对象。在该observeValueForKeyPath:ofObject:change:context:方法中,您将响应更改来更新从属值,如以下代码片段所示:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
 
    if (context == totalSalaryContext) {
        [self updateTotalSalary];
    }
    else
    // deal with other observations and/or invoke super...
}
 
- (void)updateTotalSalary {
    [self setTotalSalary:[self valueForKeyPath:@"[email protected]"]];
}
 
- (void)setTotalSalary:(NSNumber *)newTotalSalary {
 
    if (totalSalary != newTotalSalary) {
        [self willChangeValueForKey:@"totalSalary"];
        _totalSalary = newTotalSalary;
        [self didChangeValueForKey:@"totalSalary"];
    }
}
 
- (NSNumber *)totalSalary {
    return _totalSalary;
}

另外如果您使用的是Core Data,则可以将父项注册到应用程序的通知中心,作为其托管对象上下文的观察者。父母应以类似于观察键值的方式响应孩子发布的相关变更通知。

2. KVO 底层原理探索

由于KVO的实现并没有开源,我们首先看看官方文档是怎么说的:

Automatic key-value observing is implemented using a technique called isa-swizzling. 【译:】自动键值观察使用的是一种叫做isa-swizzling的技术。

The isa pointer, as the name suggests, points to the object's class which maintains a dispatch table. This dispatch table essentially contains pointers to the methods the class implements, among other data.【译:】isa指针,顾名思义,指向的是对象所属的类,这个类维护了一个哈希表,这个哈希表实质上包含指向该类实现的方法的指针以及其他数据。

When an observer is registered for an attribute of an object the isa pointer of the observed object is modified, pointing to an intermediate class rather than at the true class. As a result the value of the isa pointer does not necessarily reflect the actual class of the instance.【译:】在位对象的属性注册观察者时,将修改观察对象的isa指针,指向中间类而不是真实的类,因为isa的值不一定反映的是实例的实际的类。

You should never rely on the isa pointer to determine class membership. Instead, you should use the class method to determine the class of an object instance.【译:】所以我们永远不要依靠isa指针来确定类成员,所以我们应该使用class方法确定对象实例的类。

2.1 中间类(派生类)

根据官方文档的内容我们可以知道,在KVO的底层实现中会生成一个中间类,此时我们实例对象的isa就指向了这个中间类,那么我们就来验证一下:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person = [[LGPerson alloc] init];
    NSLog(@"注册KVO前%@---%s", NSStringFromClass([self.person class]), object_getClassName(self.person));
    [self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
    NSLog(@"注册KVO后%@---%s", NSStringFromClass([self.person class]), object_getClassName(self.person));
}

打印结果如下:

打印结果.jpg
打印对象的isa.jpg

通过打印结果可以看出在注册KVO观察后通过Objective-C方法打印的的类名仍然是LGPerson,但是在注册后通过Runtime API打印的确有不同了,所以说Objective-C方法对class方法进行了封装,让我们在开发过程中对中间类无感知,但是底层确实是实现了一个中间类就是NSKVONotifying_xxx。其实我们也可以通过打印对象的isa来验证,至此我们就验证了官方文档所说的内容。

那么这个中间类跟我们的类有什么关系呢?我们不妨打印一下类和它的子类来看看。

打印类实现代码:

NSLog(@"注册KVO前");
[self printClasses:[LGPerson class]];
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
NSLog(@"注册KVO后");
[self printClasses:[LGPerson class]];
- (void)printClasses:(Class)cls{
    
    /// 注册类的总数
    int count = objc_getClassList(NULL, 0);
    /// 创建一个数组, 其中包含给定对象
    NSMutableArray *mArray = [NSMutableArray arrayWithObject:cls];
    /// 获取所有已注册的类
    Class* classes = (Class*)malloc(sizeof(Class)*count);
    objc_getClassList(classes, count);
    for (int i = 0; i
打印结果.jpg

可以看到在LGPerson的子类中有这个中间类,所以说这个中间类是类的子类。

2.2 KVO 观察

我们知道KVO是观察属性的变化,那么属性的本质是成员变量+getter+settergetter是取值的,并不会修改值,值的变化发生在setter和给成员变量赋值两种情况。那么我们分别测试一下这两种情况哪一种会触发KVO的观察。

声明代码:

@interface LGPerson : NSObject{
    @public
    NSString *name;
}
@property (nonatomic, copy) NSString *nickName;

@end

验证代码和结果:

验证代码和结果.jpg

通过上图我们可以看到直接给实例变量赋值并不会触发KVO的监听,但是直接给属性赋值就触发了KVO的监听,其实给属性赋值就是调用setter方法,所以说KVO底层是观察的setter方法。

2.3 中间类都有哪些方法

我们分别打印原始类和中间类中的方法进行查看:

实现代码:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person = [[LGPerson alloc] init];
    
    NSLog(@"原始类中的方法");
    [self printClassAllMethod:[LGPerson class]];
    [self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL];
    NSLog(@"派生类中的方法");
    [self printClassAllMethod:NSClassFromString(@"NSKVONotifying_LGPerson")];
}

printClassAllMethod 代码:

- (void)printClassAllMethod:(Class)cls{
    unsigned int count = 0;
    Method *methodList = class_copyMethodList(cls, &count);
    for (int i = 0; i

打印结果:

查看方法.jpg

我们可以看到中间类中有属性的setter方法,class方法,dealloc方法以及_isKVOA方法。这里的setter方法是重写了原始类的方法,其余的都是重写的NSObject方法。

  • 对于重写setter应该是在setter方法中触发监听回调,已经给原始类中属性赋值
  • 对于重写class,这里也就验证了我们在上面打印class时为什么都是原始类的名称。这样是为了隐藏中间类的存在,让开发者在使用过程中保持一致性。
  • 对于重写dealloc应该是移除监听时需要处理一些逻辑
  • 对于重写_isKVOA方法应该是返回是否是KVO的值

2.3 isa 何时指回原始类

其实这很容易想到,当我们移除所有观察后就意味着我们不需要观察了,此时在指向中间类也就没什么意义了。下面我们进行验证。

验证代码:

- (void)dealloc{
    NSLog(@"移除观察前%@",object_getClass(self.person));
    [self.person removeObserver:self forKeyPath:@"nickName"];
    NSLog(@"移除观察后%@",object_getClass(self.person));
    [self printClasses:[LGPerson class]];
}

打印结果:

代码打印.jpg
lldb验证.jpg

我们通过代码和lldb进行了验证在移除观察后isa即指回了原始的类。另外我们也验证了指回后是否销毁中间类,显然中间类并没有被销毁。其实这也很正常,因为创建一个类还是非常耗费性能的,虽然移除了观察,但是也不能保证不再重新开始观察,既然创建了就让它留着吧,如果下次继续开始监听就不用重新创建了,也就提高了性能。

3. 自定义KVO

至此我们就基本分析完毕了KVO,那么我们可以自己来实现以下。

搁置了!!!
  • FaceBook 的 KVOController
  • 根据原生的 KVCKVO 反汇编而编写的 DIS_KVC_KVO
  • 开源的 GNUSteplibs-base (最接近APPLE源码的)gnustep/libs-base

4.总结

  1. KVO是苹果提供给开发者的一套键值观察的API
  2. KVO由注册观察者,监听通知,移除观察三个步骤组成
  3. 有自动观察和手动观察两种模式
  4. 对于可变集合需要通过mutableXXXValueForKey的相关方法触发更改
  5. 我们还可以注册从属关系的键值观察,KVO支持一对一和一对多两种
  6. KVO本质是isa-swizzling技术,通过生成中间类(派生类)来实现属性的观察
  7. 中间类会重写属性的setter方法以及重写class方法,dealloc方法和_isKVOA方法

你可能感兴趣的:(iOS Objective-C KVO 详解)