[OC学习笔记]KVO原理

Posted Billy Miracle

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[OC学习笔记]KVO原理相关的知识,希望对你有一定的参考价值。

KVO介绍

KVO,全称为Key-Value observing,中文名为键值观察,KVO是一种机制,它允许将其他对象的指定属性的更改通知给对象。官方文档里说到:为了理解键值观察,必须首先了解键值编码(KVC)。
KVC是键值编码,在对象创建完成后,可以动态的给对象属性赋值,而KVO是键值观察,提供了一种监听机制,当指定的对象的属性被修改后,则对象会收到通知,所以可以看出KVO是基于KVC的基础上对属性动态变化的监听了。
KVO的主要好处是,不必每次属性更改时都实施自己的计划来发送通知。其定义明确的基础设施具有框架级支持,易于采用,通常不必向项目添加任何代码。此外,基础设施已经功能齐全,因此可以轻松支持单个属性的多个观察者以及依赖值。
与使用NSNotificationCenter的通知不同,KVO没有为所有观察者提供更改通知的中心对象。相反,当进行更改时,通知会直接发送到观察对象。NSObject提供了KVO的基本实现,您很少需要覆盖这些方法。

KVO与NSNotificatioCenter的区别

  • 相同点
    (1)两者的实现原理都是观察者模式,都是用于监听
    (2)都能实现一对多的操作
  • 不同点
    (1)KVO只能用于监听对象属性的变化,并且属性名都是通过NSString来查找,编译器不会自动检测对错和补全,会比较容易出错
    (2)NSNotification的发送监听(post)操作我们可以控制,KVO则是由系统控制
    (3)KVO可以记录新旧值变化

KVO使用

注册观察者

观察对象首先通过发送addObserver:forKeyPath:options:context:消息与观察到的对象一起注册,将自己作为要观察的属性的观察者和密钥路径传递给自己。观察者还指定了一个选项参数和一个上下文指针来管理通知。

[me addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:nil];

实现回调

当对象的观察属性值发生变化时,观察者会收到observeValueForKeyPath:ofObject:change:context:消息。所有观察者都必须实施这种方法。
观察对象提供触发通知的keyPath,本身作为相关对象,包含更改详细信息的字典,以及观察者注册此密钥路径时提供的上下文指针。
更改字典条目NSKeyValueChangeKindKey提供有关发生更改类型的信息。如果观察到的对象的值发生了变化,NSKeyValueChangeKindKey条目将返回NSKeyValueChangeSetting。根据注册观察者时指定的选项,更改字典中的NSKeyValueChangeOldKeyNSKeyValueChangeNewKey条目包含更改之前和之后的属性值。如果属性是对象,则直接提供该值。如果属性是标量或C结构,则该值包装在NSValue对象中(如KVC)。
如果观察到的属性是对多关系,NSKeyValueChangeKindKey条目还指示关系中的对象是否被插入、删除或替换为分别返回NSKeyValueChangeInsertionNSKeyValueChangeRemovalNSKeyValueChangeReplacement
NSKeyValueChangeIndexesKey的更改字典条目是一个NSIndexSet对象,指定更改关系中的索引。如果注册观察者时将NSKeyValueObservingOptionNewNSKeyValueObservingOptionOld指定为选项,则更改字典中的NSKeyValueChangeOldKeyNSKeyValueChangeNewKey条目是包含更改之前和之后相关对象值的数组。

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context 
    if ([keyPath isEqualToString:@"name"]) 
        NSLog(@"%@", change);
    

移除观察者

可以通过向观察到的对象发送removeObserver:forKeyPath:context:消息来删除键值观察者,指定观察对象、键路径和上下文。

[me removeObserver:self forKeyPath:@"name" context:nil];

收到removeObserver:forKeyPath:context:消息后,观察对象将不再接收指定密钥路径和对象的任何observeValueForKeyPath:ofObject:change:context:的消息。
移除观察者时,需要注意:

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

总的来说,KVO注册观察者移除观察者是需要成对出现的,如果只注册,不移除,会出现类似野指针的崩溃。

Context

addObserver:forKeyPath:options:context:消息中的context指针包含任意数据,这些数据将在相应的更改通知中传递回观察者。您可以指定NULL,并完全依赖密钥路径字符串来确定更改通知的来源,但这种方法可能会给超类出于不同原因也观察相同密钥路径的对象带来问题。
一个更安全、更可扩展的方法是使用上下文来确保您收到的通知是针对您的观察者而不是超类的。
类中唯一命名的静态变量的地址是一个很好的context。在超类或子类中以类似方式选择的context不太可能重叠。您可以为整个类选择单个context,并依赖通知消息中的键路径字符串来确定更改的内容。或者,也可以为每个观察到的键路径创建一个不同的context,这完全绕过了字符串比较的需求,从而提高通知解析效率。
也就是说,context主要是用于区分不同对象的同名属性,从而在KVO回调方法中可以直接使用context进行区分,可以大大提升性能,以及代码的可读性。
官方文档给了两个代码示例:

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

KVO的自动与手动触发

自动触发

返回NO,就监听不到,返回YES,表示可以监听。

// 自动
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key 
    return YES;

手动触发

- (void)setName:(NSString *)name 
    //手动
    [self willChangeValueForKey:@"name"];
    _name = name;
    [self didChangeValueForKey:@"name"];

KVO的一对多

KVO中的一对多,意思是通过注册一个KVO观察者,可以监听多个属性的变化。
举个栗子:下载过程中不断更新总任务量和当前完成任务量,进度就是_currentData / _totalData
先加入监听:

[me addObserver:self forKeyPath:@"progress" options:NSKeyValueObservingOptionNew context:nil];

再去自己的类里面实现下面的方法:

+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key 
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"progress"]) 
        NSArray *affectingKeys = @[@"totalData", @"currentData"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    
    return keyPaths;

或者实现一个遵循命名规则的类方法keyPathsForValuesAffecting<Key>以达到同样的效果:

+ (NSSet<NSString *> *)keyPathsForValuesAffectingProgress 
    return [NSSet setWithObjects:@"totalData", @"currentData", nil];

那么当totalDatacurrentData改动的时候,该值(progress)必须被通知。这是一种依赖方法。把progress写成如下:

- (float)progress 
    return _currentData / _totalData;

修改_currentData或者_totalData,会发现,通知了progress,就可以自动修改啦。

KVO观察可变数组

KVO是基于KVC基础之上的,所以可变数组如果直接添加数据,是不会调用setter方法的,所有对可变数组的KVO观察下面这种方式不生效的,即直接通过[me.dateArray addObject:@"1"];向数组添加元素,是不会触发KVO通知回调的。

将代码修改成如下方式:

[[me mutableArrayValueForKey:@"dataArray"] addObject:@"1"];

或者启动手动触发,就可以监听可变数组了,下面是监听结果

其中的kind表示键值变化的类型,是一个枚举,有以下4种:

/* Possible values in the NSKeyValueChangeKindKey entry in change dictionaries. See the comments for -observeValueForKeyPath:ofObject:change:context: for more information.
*/
typedef NS_ENUM(NSUInteger, NSKeyValueChange) 
    NSKeyValueChangeSetting = 1,//设值
    NSKeyValueChangeInsertion = 2,//插入
    NSKeyValueChangeRemoval = 3,//移除
    NSKeyValueChangeReplacement = 4,//替换
;

可以看到,这里我们可变数组的类型就是插入了。

KVO底层原理探索

在官方文档里面,可以看到如下说明:

解释(oh,不,翻译)一下:

  • KVO是使用一种称为isa-swizzling的技术实现的。
  • 顾名思义,isa指针指向维护调度表的对象的类。此调度表实质上包含指向类实现的方法的指针以及其他数据。
  • 为对象的属性注册观察者时,将修改被观察对象的 isa 指针,其指向中间类而不是实际的类。因此,isa 指针的值不一定反映实例的实际类。
  • 永远不应该依赖isa指针来确定类的成员身份。相反,应该使用class方法来确定对象实例的类。

代码调试探索

属性与成员变量

我们为类创建属性和成员变量,分别注册KVO观察,看看有什么神奇效果。

[me addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:nil];
[me addObserver:self forKeyPath:@"memberVariable" options:NSKeyValueObservingOptionNew context:nil];
me.name = @"Billy";
me->memberVariable = @"Billy Member";

结果:

结论:KVO对成员变量不观察,只对属性观察,属性和成员变量的区别在于属性多一个 setter 方法,而KVO恰好观察的是setter方法。

中间类

前面我们也看到了,官方文档里的描述,注册KVO观察者后,观察对象的isa指针指向会发生改变
我们在控制台调试,注册观察者之前

实例对象meisa指针指向Person
注册观察者之后

实例对象meisa指针指向NSKVONotifying_Person。在注册观察者后,实例对象的isa指针指向由Person类变为NSKVONotifying_Person中间类,即实例对象的isa指针指向发生了变化。

中间类是子类

接下来判断关系,使用下面方法查看子类:

#pragma mark - 遍历类以及子类
- (void)printClasses:(Class)cls 
    int count = objc_getClassList(NULL, 0);
    NSMutableArray *array = [NSMutableArray arrayWithObject:cls];
    Class* classes = (Class*)malloc(sizeof(Class)*count);
    objc_getClassList(classes, count);
    for (int i = 0; i < count; i++) 
        if (cls == class_getSuperclass(classes[i])) 
            [array addObject:classes[i]];
        
    
    free(classes);
    NSLog(@"%@'s classes = %@", cls, array);

我们观察注册观察者前后子类,可以发现NSKVONotifying_Person中间类是Person类的子类。

中间类的方法

我们通过下面的方法查看中间类的方法列表:

#pragma mark - 遍历方法-ivar-property
- (void)printClassAllMethod:(Class)cls 
    unsigned int count = 0;
    Method *methodList = class_copyMethodList(cls, &count);
    for (int i = 0; i<count; i++) 
        Method method = methodList[i];
        SEL sel = method_getName(method);
        IMP imp = class_getMethodImplementation(cls, sel);
        NSLog(@"%@ - %p",NSStringFromSelector(sel), imp);
    
    free(methodList);

结果如下:

继续探究,这四个方法是继承还是重写呢?
Student中重写setName:方法,获取Student类的所有方法:

我们不难发现,获取到是重写过的方法。所以,可以说明上面的中间类的setName:方法是重写Person类的,继承的不会在子类遍历出来。

移除观察者中对中间类的思索

我们尝试移除观察者。先探究移除观察者后的isa指针变化:

可以看出,移除观察者以后实例对象的isa指向更改为Person类。接下来探究那么中间类被创建了之后,并且在后续的dealloc方法中被移除观察者之后,是否还存在?在dealloc以后,我们查看Person类的子类:

通过子类的打印结果可以看出,中间类一旦生成,没有移除,没有销毁,还在内存中,也许主要是考虑重用的想法,即中间类注册到内存中,为了考虑后续的重用问题,所以中间类一直存在,没有被销毁。也许也是害怕还有其他的观察者,所以没有注销。

中间类小结

  • 实例对象isa的指向在注册KVO观察者之后,由原有类更改为指向中间类
  • 中间类重写了观察属性的setter方法、classdealloc_isKVOA方法
  • dealloc方法中,移除KVO观察者之后,实例对象isa指向由中间类更改为原有类
  • 中间类从创建后,就一直存在内存中,不会被销毁

自定义KVO

在系统中,注册观察者和KVO响应属于响应式编程,是分开写的,在自定义为了代码更好的协调,使用block的形式,将注册和回调的逻辑组合在一起,即采用函数式编程
创建NSObject类的分类MYKVO

- (void)billyAddObserver:(NSObject *)observer
               keyPath:(NSString *)keyPath
               options:(BillyKeyValueObservingOptions)options
               context:(nullable void *)context
           handleBlock:(BillyKVOBlock)handleBlock;

- (void)billyRemoveObserver:(NSObject *)observer
                  keyPath:(NSString *)keyPath;

注册观察者

#pragma mark - 验证setter方法是否存在
- (void)judgeSetterMethodFromeKeyPath:(NSString *)keyPath 
    Class superClass = object_getClass(self);
    //根据keyPath获得setter方法
    SEL setterSelector = NSSelectorFromString(setterForGetter(keyPath));
    Method setterMethod = class_getInstanceMethod(superClass, setterSelector);
    if (!setterMethod) //不存在则抛出异常
        @throw [NSException exceptionWithName:NSInvalidArgumentException reason:[NSString stringWithFormat:@"没有找到当前%@的setter方法",keyPath] userInfo:nil];
    

//动态生成KVO的派生类NSObservingKVO
- (Class)billyKVONotifingObservingKVOWithKeyPath:(NSString *)keyPath 
    NSString *oldClassName = NSStringFromClass(object_getClass(self));
    //kSafeKVOPrefix = @"SafeKVONotifying_"
    NSString *newClassName = [NSString stringWithFormat:@"%@%@", billyKVOPrefix, oldClassName];
    Class newClass = NSClassFromString(newClassName);
    if (newClass) //防止重复创建
        return newClass;
    
    //不存在则创建
    //申请class
    newClass = objc_allocateClassPair([self class], newClassName.UTF8String, 0);
    //注册类
    objc_registerClassPair(newClass);
    //添加class
    SEL classSel = NSSelectorFromString(@"class");
    Method classMethod = class_getInstanceMethod([self class], classSel);
    const char *classType = method_getTypeEncoding(classMethod);
    class_addMethod(newClass, classSel, (IMP)billy_class, classType);
    //添加重写的setter
    SEL setterSel = NSSelectorFromString(setterForGetter(keyPath));
    Method setterMethod = class_getInstanceMethod([self class], setterSel);
    const char *setterType = method_getTypeEncoding(setterMethod);
    class_addMethod(newClass, setterSel, (IMP)billy_setter, setterType);
    
    //派生类dealloc,主要是为了不移除KVO监听时自动处理的
    SEL dealSel = NSSelectorFromString(@"dealloc");
    Method dealMethod = class_getInstanceMethod([self class], dealSel);
    const char *dealType = method_getTypeEncoding(dealMethod);
    class_addMethod(newClass, dealSel, (IMP)billy_dealloc, dealType);
    return newClass;

- (void)billyAddObserver:(NSObject *)observer
               keyPath:(NSString *)keyPath
               options:(BillyKeyValueObservingOptions)options
               context:(nullable void *)context
           handleBlock:(BillyKVOBlock)handleBlock 
    [self judgeSetterMethodFromeKeyPath:keyPath];
    //获得派生类
    Class newClass = [self billyKVONotifingObservingKVOWithKeyPath:keyPath];
    //修改isa指针指向派生类
    object_setClass(self, newClass);
    //保存信息
    BillyKVOManager *info = [[BillyKVOManager alloc] initWithObserver:observer keyPath:keyPath options:options handleBlock:handleBlock];
    NSMutableArray *infoArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(billyInfoArray));
    if (!infoArray) 
        infoArray = [NSMutableArray arrayWithCapacity:1];
        objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(billyInfoArray), infoArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    
    [infoArray addObject:info];

KVO响应

#pragma mark - 重写的setter
static void billy_setter(id self, SEL _cmd, id newValue) 
    NSString *keyPath = getterForSetter(NSStringFromSelector(_cmd));
    //获得原来的值
    id oldValue = [self valueForKey:keyPath];
    //消息转发,转发给父类处理(setKeyPath:)
    void (*billy_msgSendSuper)(void *, SEL, id) = (void *)objc_msgSendSuper;
    struct objc_super superStruct = 
        .receiver = self,
        .super_class = class_getSuperclass(object_getClass(self)),
    ;
    billy_msgSendSuper(&superStruct, _cmd, newValue);
    //获得信息数据
    NSMutableArray *infoArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(billyInfoArray));
    for (BillyKVOManager *info in infoArray) 
        if ([info.keyPath isEqualToString:keyPath]) 
            if (info.handleBlock) //有block回调
                info.handleBlock(info.observer, info.keyPath, oldValue, newValue);
            
            //实现observeValueForKeyPath:可监听值的变化
            dispatch_async(dispatch_get_global_queue(0, 0), ^
                NSMutableDictionary<NSKeyValueChangeKey,id> *change = [NSMutableDictionary dictionaryWithCapacity:1];
                // 对新旧值进行处理
                if (info.options & BillyKeyValueObservingOptionNew) 
                    [change setObject:newValue forKey:NSKeyValueChangeNewKey];
                
                if (info.options & BillyKeyValueObservingOptionOld) 
                    [change setObject:@"" forKey:NSKeyValueChangeOldKey];
                    if (oldValue) 
                        [change setObject:oldValue forKey:NSKeyValueChangeOldKey];
                    
                
                //消息发送给观察者
                // (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
                SEL observerSEL = @selector(observeValueForKeyPath:ofObject:change:context:);
//                objc_msgSend(id _Nullable self, SEL _Nonnull op, ...)
                void (*billy_msgSend)(id, SEL, NSString *, id, NSDictionary<NSKeyValueChangeKey, id> *, void *) = (void *)objc_msgSend;
                billy_msgSend(info细说OC中的KVO

细说OC中的KVO

[OC学习笔记]weak的实现原理

[OC学习笔记]weak的实现原理

[OC学习笔记]KVC原理

[OC学习笔记]KVC原理