iOS底层原理探索 之 KVO原理|8月更文挑战

1,008 阅读17分钟
写在前面: iOS底层原理探究是本人在平时的开发和学习中不断积累的一段进阶之
路的。 记录我的不断探索之旅,希望能有帮助到各位读者朋友。

目录如下:

  1. iOS 底层原理探索 之 alloc
  2. iOS 底层原理探索 之 结构体内存对齐
  3. iOS 底层原理探索 之 对象的本质 & isa的底层实现
  4. iOS 底层原理探索 之 isa - 类的底层原理结构(上)
  5. iOS 底层原理探索 之 isa - 类的底层原理结构(中)
  6. iOS 底层原理探索 之 isa - 类的底层原理结构(下)
  7. iOS 底层原理探索 之 Runtime运行时&方法的本质
  8. iOS 底层原理探索 之 objc_msgSend
  9. iOS 底层原理探索 之 Runtime运行时慢速查找流程
  10. iOS 底层原理探索 之 动态方法决议
  11. iOS 底层原理探索 之 消息转发流程
  12. iOS 底层原理探索 之 应用程序加载原理dyld (上)
  13. iOS 底层原理探索 之 应用程序加载原理dyld (下)
  14. iOS 底层原理探索 之 类的加载
  15. iOS 底层原理探索 之 分类的加载
  16. iOS 底层原理探索 之 关联对象
  17. iOS底层原理探索 之 魔法师KVC

以上内容的总结专栏


细枝末节整理


KVO 是什么

Key-Value Observing Programming Guide

  • 键值观察是一种机制,它允许对象在其他对象的指定属性发生更改时收到通知。

  • 键值观察提供了一种机制,允许对象在其他对象的特定属性发生更改时收到通知。它对于应用程序中模型层和控制器层之间的通信特别有用。(在 OS X 中,控制器层绑定技术很大程度上依赖于键值观察。)控制器对象通常观察模型对象的属性,视图对象通过控制器观察模型对象的属性。然而,此外,模型对象可能会观察其他模型对象(通常是为了确定依赖值何时发生变化)甚至自身(再次确定依赖值何时发生变化)。

  • 您可以观察属性,包括简单属性、一对一关系和多对多关系。多对多关系的观察者被告知所做更改的类型——以及哪些对象参与了更改。

一个简单的例子说明了 KVO 如何在您的应用程序中发挥作用。假设一个Person对象与一个对象交互,该Account对象代表该人在银行的储蓄账户。实例Person可能需要知道实例的某些方面何时Account发生变化,例如余额或利率。

艺术/kvo_objects_properties.png

如果这些属性是 的公共属性Account,则Person可以定期轮询Account以发现更改,但这当然效率低下,而且通常不切实际。更好的方法是使用 KVO,这类似于Person在发生更改时接收中断。

要使用 KVO,首先您必须确保被观察的对象,Account在本例中,是 KVO 兼容的。通常,如果您的对象继承自NSObject并以通常的方式创建属性,您的对象及其属性将自动符合 KVO。也可以手动实施合规性。KVO 合规性描述了自动和手动键值观察之间的区别,以及如何实现两者。

接下来,您必须注册您的观察者实例Person,使用观察到的实例Account。对于每个观察到的关键路径,Person向 发送一条addObserver:forKeyPath:options:context:消息Account,将自己命名为观察者。

艺术/kvo_objects_add.png

为了从 接收更改通知AccountPerson实现observeValueForKeyPath:ofObject:change:context:所有观察者所需的方法。每当注册的密钥路径之一发生变化时,Account都会将此消息发送到Person。然后Person可以根据更改通知采取适当的行动。

艺术/kvo_objects_observe.png

最后,当它不再需要通知时,至少在它被解除分配之前,Person实例必须通过将消息发送removeObserver:forKeyPath:Account.

艺术/kvo_objects_remove.png

注册键值观察描述了注册、接收和取消注册键值观察通知的整个生命周期。

KVO 的主要好处是您不必实现自己的方案来在每次属性更改时发送通知。其定义良好的基础架构具有框架级支持,使其易于采用——通常您无需向项目添加任何代码。此外,基础设施已经功能齐全,可以轻松支持单个属性以及依赖值的多个观察者。

注册从属键解释了如何指定一个键的值依赖于另一个键的值。

与使用 的通知不同NSNotificationCenter,没有为所有观察者提供更改通知的中央对象。相反,当进行更改时,通知会直接发送到观察对象。NSObject提供了这个键值观察的基本实现,你应该很少需要覆盖这些方法。

Key-Value Observing Implementation Details描述了如何实现键值观察。

注册键值观察

在开中必须执行以下步骤才能使对象能够接收 KVO 兼容属性的键值观察通知:

  • 使用 方法向被观察对象注册观察者addObserver:forKeyPath:options:context:
  • observeValueForKeyPath:ofObject:change:context:在观察者内部实现以接受更改通知消息。
  • 当观察者removeObserver:forKeyPath:不再应该接收消息时,使用该方法取消注册观察者。至少,在观察者从内存中释放之前调用这个方法。

注册为观察员

观察对象首先通过发送addObserver:forKeyPath:options:context: 消息向被观察对象注册自己 ,将自身作为观察者和要观察的属性的关键路径传递。观察者另外指定一个选项参数和一个上下文指针来管理通知的各个方面。

options

options 参数指定为OR选项常量的按位,影响通知中提供的更改字典的内容以及生成通知的方式。

您可以通过指定 option 选择从更改之前接收观察到的属性的值NSKeyValueObservingOptionOld。您使用 option 请求属性的新值NSKeyValueObservingOptionNew。您可以OR通过这些选项的按位接收旧值和新值。

您指示被观察对象addObserver:forKeyPath:options:context:使用选项发送立即更改通知(在返回之前)NSKeyValueObservingOptionInitial。您可以使用这个额外的一次性通知来在观察者中建立属性的初始值。

您可以通过包含选项来指示被观察对象在属性更改之前发送通知(除了更改之后的通常通知之外)NSKeyValueObservingOptionPrior。更改字典通过包含NSKeyValueChangeNotificationIsPriorKey带有NSNumberwrapping值的键来表示预更改通知YES。该密钥不存在。当观察者自己的 KVO 合规性要求它为willChange…依赖于被观察属性的属性之一调用 -方法之一时,您可以使用预更改通知。通常的更改后通知来不及及时调用willChange…

context

addObserver:forKeyPath:options:context:消息中的上下文指针包含任意数据,这些数据将在相应的更改通知中传回给观察者。您可以指定NULL并完全依赖键路径字符串来确定更改通知的来源,但是这种方法可能会导致超类由于不同原因也在观察相同键路径的对象出现问题。

一种更安全、更可扩展的方法是使用上下文来确保您收到的通知是发送给您的观察者而不是超类的。

类中唯一命名的静态变量的地址是一个很好的上下文。在超类或子类中以类似方式选择的上下文不太可能重叠。您可以为整个类选择一个上下文,并依靠通知消息中的关键路径字符串来确定发生了什么变化。或者,您可以为每个观察到的键路径创建一个不同的上下文,这完全绕过了字符串比较的需要,从而提高了通知解析的效率。清单 1显示了以这种方式选择的balanceinterestRate属性的示例上下文。

清单 1   创建上下文指针

static void *PersonAccountBalanceContext = &PersonAccountBalanceContext;

static void *PersonAccountInterestRateContext = &PersonAccountInterestRateContext;

下面 中的示例演示了 Person 实例如何使用给定的上下文指针将自己注册为Account实例balanceinterestRate属性的观察者。

清单 2   将检查器注册为 balance 和 interestRate 属性的观察者

- (void)registerAsObserverForAccount:(Account*)account {
    [account addObserver:self
              forKeyPath:@"balance"
                 options:(NSKeyValueObservingOptionNew |
                          NSKeyValueObservingOptionOld)
                 context:PersonAccountBalanceContext];
 
    [account addObserver:self
              forKeyPath:@"interestRate"
                 options:(NSKeyValueObservingOptionNew |
                          NSKeyValueObservingOptionOld)
                  context:PersonAccountInterestRateContext];
}

注意: 键值观察addObserver:forKeyPath:options:context:方法不维护对观察对象、被观察对象或上下文的强引用。您应该确保根据需要维护对观察对象和被观察对象以及上下文的强引用。

接收变更通知

当对象的被观察属性的值发生变化时,观察者会收到一条observeValueForKeyPath:ofObject:change:context: 消息。所有观察者都必须实现这个方法。

观察对象提供触发通知的关键路径,本身作为相关对象,包含有关更改的详细信息的字典,以及当观察者为此关键路径注册时提供的上下文指针。

更改字典条目NSKeyValueChangeKindKey提供有关发生的更改类型的信息。如果观察对象的值已更改,则NSKeyValueChangeKindKey条目返回NSKeyValueChangeSetting。根据注册观察者时指定的选项,更改字典中的NSKeyValueChangeOldKeyNSKeyValueChangeNewKey条目包含更改之前和之后的属性值。如果属性是对象,则直接提供值。如果属性是标量或 C 结构,则该值被包装在一个NSValue对象中(与键值编码一样)。

如果所观察到的属性是一个对多的关系,所述NSKeyValueChangeKindKey条目还表示在关系对象是否插入,删除或替换通过返回NSKeyValueChangeInsertionNSKeyValueChangeRemoval或者NSKeyValueChangeReplacement,分别。

更改字典条目NSKeyValueChangeIndexesKey是一个NSIndexSet对象,指定更改的关系中的索引。如果在注册观察者时将NSKeyValueObservingOptionNewNSKeyValueObservingOptionOld指定为选项,则更改字典中的NSKeyValueChangeOldKeyNSKeyValueChangeNewKey条目是包含更改前后相关对象值的数组。

清单 3中的示例显示了观察者的observeValueForKeyPath:ofObject:change:context:实现,Person它记录了属性的旧值和新值balanceand interestRate,如清单 2 中注册的那样。

清单  3observeValueForKeyPath:ofObject:change:context 的实现:

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
 
    if (context == PersonAccountBalanceContext) {
        // Do something with the balance…
 
    } else if (context == PersonAccountInterestRateContext) {
        // Do something with the interest rate…
 
    } else {
        // Any unrecognized context must belong to super
        [super observeValueForKeyPath:keyPath
                             ofObject:object
                               change:change
                               context:context];
    }
}

如果您NULL在注册观察者时指定了上下文,则将通知的关键路径与您正在观察的关键路径进行比较,以确定发生了哪些变化。如果您对所有观察到的关键路径使用单个上下文,则首先针对通知的上下文对其进行测试,然后找到匹配项,使用关键路径字符串比较来确定具体发生了什么变化。如果您为每个关键路径提供了唯一的上下文,如此处所示,一系列简单的指针比较会同时告诉您通知是否针对此观察者,如果是,则哪个关键路径发生了变化。

在任何情况下,观察者都应该observeValueForKeyPath:ofObject:change:context:在无法识别上下文(或在简单的情况下,任何关键路径)时调用超类的实现,因为这意味着超类也已注册通知。

移除一个对象作为观察者

您可以通过向被观察对象发送removeObserver:forKeyPath:context:消息、指定观察对象、键路径和上下文来移除键值观察器。清单 4中的示例显示了Person作为balanceand的观察者删除自身interestRate

清单 4  删除作为余额和利率观察者的检查员

- (void)unregisterAsObserverForAccount:(Account*)account {
    [account removeObserver:self
                 forKeyPath:@"balance"
                    context:PersonAccountBalanceContext];
 
    [account removeObserver:self
                 forKeyPath:@"interestRate"
                    context:PersonAccountInterestRateContext];
}

接收到removeObserver:forKeyPath:context:消息后,观察对象将不再接收observeValueForKeyPath:ofObject:change:context:指定键路径和对象的任何消息。

移除观察者时,请记住以下几点:

  • 如果尚未注册为观察者,则要求将其移除为观察者会导致NSRangeException. 您可以removeObserver:forKeyPath:context:为相应的 调用只调用一次addObserver:forKeyPath:options:context:,或者如果这在您的应用程序中不可行,则将removeObserver:forKeyPath:context:调用放置在 try/catch 块中以处理潜在的异常。
  • 解除分配时,观察者不会自动删除自己。被观察的对象继续发送通知,而忽略了观察者的状态。但是,发送到已释放对象的更改通知与任何其他消息一样,会触发内存访问异常。因此,您要确保观察者在从记忆中消失之前将自己移除。
  • 该协议没有提供询问对象是观察者还是被观察者的方法。构建您的代码以避免与发布相关的错误。一个典型的模式是在观察者初始化期间注册为观察者(例如 ininitviewDidLoad)并在释放期间取消注册(通常是 in dealloc),确保正确配对和有序添加和删除消息,并且观察者在从内存中释放之前被取消注册.

KVO 的一些细节

手动开关

对于键值观察 某些特殊的情况下 我们 可以 对其做一个手动的开关,

  • 类中实现下面的方法:返回NO
/* 
Return YES 
如果键值观察机制应该自动调用 -willChangeValueForKey:
-didChangeValueForKey:, 
-willChange:valuesAtIndexes:forKey:
-didChange:valuesAtIndexes:forKey:,
or -willChangeValueForKey:withSetMutation:usingObjects:
-didChangeValueForKey:withSetMutation:usingObjects: 
每当类的实例接收键的键值编码消息时,或者调用键的可变键值编码遵从方法时. 

Return NO . 
从Mac OS 10.5开始,这个方法的默认实现会自动在接收类中搜索一个名称与pattern +匹配的方法,并返回调用该方法的结果(如果找到的话)。因此,任何这样的方法也必须返回BOOL。如果没有找到这样的方法,则返回YES。
*/
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key;
  • 在需要手动观察的属性set方法中如下实现:
    [self willChangeValueForKey:property];
    property = nick;
    [self didChangeValueForKey:property];

一对多处理

对于一对多的情况 例如 下载进度

@property (nonatomic, copy) NSString *downloadProgress;
@property (nonatomic, assign) double writtenData;
@property (nonatomic, assign) double totalData;
  • keyPathsForValuesAffectingValueForKey 返回属性的一组键路径,这些属性的值影响键控属性的值。
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
    
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"downloadProgress"]) {
        NSArray *affectingKeys = @[@"totalData", @"writtenData"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    }
    return keyPaths;
}

- (NSString *)downloadProgress{
    if (self.writtenData == 0) {
        self.writtenData = 10;
    }
    if (self.totalData == 0) {
        self.totalData = 100;
    }
    return [[NSString alloc] initWithFormat:@"%f",1.0f*self.writtenData/self.totalData];
}

集合属性的观察

关于 集合属性的观察

对集合属性的操作需使用如下方法 :

  • mutableArrayValueForKey: 和 mutableArrayValueForKeyPath: 它们返回一个行为类似于NSMutableArray对象的代理对象。

  • mutableSetValueForKey: 和 mutableSetValueForKeyPath: 它们返回一个行为类似于NSMutableSet对象的代理对象。

  • mutableOrderedSetValueForKey: 和 mutableOrderedSetValueForKeyPath: 它们返回一个行为类似于NSMutableOrderedSet对象的代理对象。

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    
    self.person.nick = [NSString stringWithFormat:@"%@+",self.person.nick];

    // KVC 集合 array
    [[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"1"];
    [[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"2"];

    [[self.person mutableArrayValueForKey:@"dateArray"] removeObject:@"1"];
 
    [[self.person mutableArrayValueForKey:@"dateArray"] replaceObjectAtIndex:0 withObject:@"99"];
}

所以, 一旦观察数组的KVO,就需要利用KVC的机制才能观察到变化,因为KVO的底层也是KVC实现的。

扩展

  • 在回调方法的日志中,可以看到 如下内容:

image.png indexes 可以看出来 是当前操作的集合属性的下标。

kind 是什么呢? 翻阅源码找到了下面的内容:

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

总结

  1. KVO探索到这里,我们会觉得它在底层的实现是对于被观察者的setter方法的处理(在上面手动模式中有涉及),那么实际上对不对呢?
  2. 以及在KVO编程的规范中,最后会有一个移除观察者的过程,为什么需要移除呢?
  3. 在KVO的使用中,是否会有一些中间层的产物(例如系统默默的做了一些事情)呢? 带着这些疑问,集合我们对于KVO概念的整理以及细节的探索,我们正式进入到对于KVO底层原理的探索。

KVO 的原理探索

在开始正式的探索之前,我们不妨先根据截止到现在的进程,对KVO的原理做一个小小的猜想:

猜想

  1. 当 addObserver 时, 将 被观察到实例的 isa 从 SMPerson类 改到 一个系统动态帮我们创建好的类
  2. setter 方法中 会有相关的代码 (willchange、didchange) 在修改的时候,可以在回调中得到
  3. 移除观察的时候 默默的再处理系统创建的类

问题:

  • 添加观察的时候,系统是否会帮我们动态的创建了一个类(isa是否发生了变化)?
  • 移除观察的时候,是否会销毁系统帮我们创建好的类?
  • 我们在代码中调用的setter方法会是哪个类的?
  • 我们自己定义的类SMPerson 和 系统帮我们创建的类 之间是什么关系?

带着上面的问题,我们开始一个一个的验证,一个一个的回答。

isa确实在添加观察之后指向发生了变化

  • 系统会帮我们动态的创建 NSKVONotifying_Person 并将被观察的实例的isa指向这个动态创建的类 image.png
  • 系统动态创建的时机时在我们添加观察的时候,因为在添加之前,是没有 NSKVONotifying_Person 类的

image.png

  • NSKVONotifying_PersonPerson会是什么关系呢? 我们定义一个打印类中所有子类的方法,然后在添加观察之前和之后打印下Person 的子类, 以及添加观察之后NSKVONotifying_Person 的子类。

image.png 从打印结果来看 Person 是 NSKVONotifying_Person 的父类

NSKVONotifying_Person

  • NSKVONotifying_Person 类中都包含什么(objc_method_list、objc_ivar_list、objc_protocol_list、objc_cache)?

通过打印类中所有的方法可以得到所有方法

image.png

  1. setName:-0x7fff207bf03f 重写了 name 的 setter 方法, 但是还是会修改 Person 类的实例中的属性值

  2. class-0x7fff207bdb49 一个类型

  3. dealloc-0x7fff207bd8f7 释放的方法 - 在销毁的时候,默默的将isa指回去

  4. _isKVOA-0x7fff207bd8ef 系统派生的类一个标识

isa在移除观察者后,又会指回来

image.png

之后 NSKVONotifying_Person 会被移除吗? 我们在第一个页面打印Person的所有子类,在第二个页面添加观察者,并在第二个页面销毁的时候移除观察者,再在第一个页面打印Person的所有子类。 发现,NSKVONotifying_Person 并没有被移除。

image.png

虽然isa在被观察期间有移动,但是打印 self.person.class的时候,还是会打印 PersonNSKVONotifying_Person 只是在后面默默的帮我门做事。 image.png

总结

KVO三部曲.001.jpeg

setter 中 NSKVONotifying_Person 做了什么

因为 NSKVONotifying_Person 这个 系统动态生成的类 是重写了setter方法,所以,实例的成员变量因为没有setter方法,所以,是无法被观察到(setter方法是可以区分属性和成员变量的)。下面做进一步的验证:

  1. 添加观察行打上断点
  2. watchpoint set variable self->_person->_name
  3. 断点放行
  4. 修改name值

image.png

在修改name值的过程中,通过堆栈信息可以看到系统处理了很多的内容

frame #2:  Foundation`-[NSObject(NSKeyValueObservingPrivate) _changeValueForKeys:count:maybeOldValuesDict:maybeNewValuesDict:usingBlock:] + 646
frame #3: Foundation`-[NSObject(NSKeyValueObservingPrivate) _changeValueForKey:key:key:usingBlock:] + 68
frame #4: Foundation`_NSSetObjectValueAndNotify + 269
_NSSetObjectValueAndNotify

image.png

NSObject(NSKeyValueObservingPrivate) _changeValueForKey:key:key:usingBlock:

根据堆栈信息,也会走到 [Person setName:] 所以Person类的属性值会发生变化

image.png

observeValueForKeyPath

image.png

observeValueForKeyPath 与 NSObject(NSKeyValueObservingPrivate) _changeValueForKey:key:key:usingBlock: 两处的 willchangedidchange 形成了完美的闭环

我们添加观察之后,系统会修改 isa 指向 到 NSKVONotifying_Person; 之后, 修改属性值后,其实是调用 NSKVONotifying_Person 重写的setter方法;其会调用 Person 的setter方法。接着会调用到KVO回调函数observeValueForKeyPath 中。最后,移除观察到时候,再将isa指回了 Person。

KVO流程

最后我们整理一下KVO流程:

KVO流程.001.jpeg

总结

KVO的原理经过我们的整个探索过程,其实并没有特别的难点,相信大家和我一起对于苹果的底层做了这么多的探索,其实整个的探索过程以及调试的内容,大家和我一样越来越熟练越来越上手了。下一篇,我们就手写KVO并结合函数式的特点,一起来手动实现下。大家,加油!!!