iOS探索 KVO原理及自定义

4,003 阅读13分钟

欢迎阅读iOS探索系列(按序阅读食用效果更加)

写在前面

KVC(键值编码)KVO(键值观察),可能读者老爷们都用的溜溜的,但是你真的了解它吗?本文就将全方位分析KVO的原理

一、KVO初探

KVO(Key-Value Observing)是苹果提供的一套事件通知机制,这种机制允许将其他对象的特定属性的更改通知给对象。iOS开发者可以使用KVO 来检测对象属性的变化、快速做出响应,这能够为我们在开发强交互、响应式应用以及实现视图和模型的双向绑定时提供大量的帮助。

Documentation Archieve中提到一句想要理解KVO,必须先理解KVC,因为键值观察是建立在键值编码的基础上

In order to understand key-value observing, you must first understand key-value coding.——Key-Value Observing Programming Guide

KVONSNotificatioCenter都是iOS观察者模式的一种实现,两者的区别在于:

  • 相对于被观察者和观察者之间的关系,KVO是一对一的,NSNotificatioCenter是一对多的
  • KVO对被监听对象无侵入性,不需要修改其内部代码即可实现监听

二、KVO使用及注意点

1.基本使用

KVO使用三部曲:

  • 注册观察者
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld) context:NULL];
  • 实现回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:@"name"]) NSLog(@"%@", change);
}
  • 移除观察者
[self.person removeObserver:self forKeyPath:@"name"];

2.context的使用

Key-Value Observing Programming Guide是这么描述context

消息中的上下文指针包含任意数据,这些数据将在相应的更改通知中传递回观察者;您可以指定NULL并完全依赖键路径字符串来确定更改通知的来源,但是这种方法可能会导致对象的父类由于不同的原因而观察到相同的键路径,因此可能会出现问题;一种更安全,更可扩展的方法是使用上下文确保您收到的通知是发给观察者的,而不是超类的。

这里提出一个假想,如果父类中有个name属性,子类中也有个name属性,两者都注册对name的观察,那么仅通过keyPath已经区分不了是哪个name发生变化了,现有两个解决办法:

  • 多加一层判断——判断object,显然为了满足业务需求而去增加逻辑判断是不可取的
  • 使用context传递信息,更安全、更可扩展

context使用总结:

  • 不使用context作为观察值
// context是 void * 类型,应该填 NULL 而不是 nil
[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
  • 使用context传递信息
static void *PersonNameContext = &PersonNameContext;
static void *ChildNameContext = &ChildNameContext;

[self.person addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:PersonNameContext];
[self.child addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:ChildNameContext];

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if (context == PersonNameContext) {
        NSLog(@"%@", change);
    } else if (context == ChildNameContext) {
        NSLog(@"%@", change);
    }
}

3.移除通知的必要性

也许在日常开发中你觉得是否移除通知都无关痛痒,但是不移除会带来潜在的隐患

以下是一段没有移除观察者的代码,页面push前后、键值改变前后都很正常

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.child = [FXChild new];
    self.child.name = @"Feng";
    
    [self.child addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld) context:ChildNameContext];
}

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

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

但当把FXChild单例的形式创建后,pop回上一页再次push进来程序就崩溃了

这是因为没有移除观察,单例对象依旧存在,再次进来时就会报出野指针错误

移除了观察者之后便不会发生这种情况了——移除观察者是必要的

苹果官方推荐的方式是——在init的时候进行addObserver,在deallocremoveObserver,这样可以保证addremove是成对出现的,这是一种比较理想的使用方式

4.手动触发键值观察

有时候业务需求需要观察某个属性值,一会儿要观察了,一会又不要观察了...如果把KVO三部曲整体去掉、再整体添上,必然又是一顿繁琐而又不必要的工作,好在KVO中有两种办法可以手动触发键值观察:

  • 将被观察者的automaticallyNotifiesObserversForKey返回NO(可以只对某个属性设置)
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    if ([key isEqualToString:@"name"]) {
        return NO;
    }
    return [super automaticallyNotifiesObserversForKey:key];
}
  • 使用willChangeValueForKeydidChangeValueForKey重写被观察者的属性的setter方法

    这两个方法用于通知系统该 key 的属性值即将和已经变更了

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

两种方式使用的排列组合如下,可以自由组合如何使用

情况 回调次数
正常情况 1
automaticallyNotifiesObserversForKey为NO 0
automaticallyNotifiesObserversForKey为NO且添加willChangeValueForKey、didChangeValueForKey 1
automaticallyNotifiesObserversForKey为YES且添加willChangeValueForKey、didChangeValueForKey 2

最近发现[self willChangeValueForKey:name]和[self willChangeValueForKey:"name"]两种写法是不同的结果:重写setter方法取属性值操作不会额外发送通知;而使用“name”会额外发送一次通知

5.键值观察多对一

比如有一个下载任务的需求,根据总下载量Total当前已下载量Current来得到当前下载进度Process,这个需求就有两种实现:

  • 分别观察总下载量Total当前已下载量Current两个属性,其中一个属性发生变化时计算求值当前下载进度Process
  • 实现keyPathsForValuesAffectingValueForKey方法,并观察process属性

只要总下载量Total当前已下载量Current任意发生变化,keyPaths=process就能收到监听回调

+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"process"]) {
        NSArray *affectingKeys = @[@"total", @"current"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    }
    return keyPaths;
}

但仅仅是这样还不够——这样只能监听到回调,但还没有完成Process赋值——需要重写getter方法

- (NSString *)process {
    if (self.total == 0) {
        return @"0";
    }
    return [[NSString alloc] initWithFormat:@"%f",1.0f*self.current/self.total];
}

6.可变数组

如题:FXPerson下有一个可变数组dataArray,现观察之,问点击屏幕是否打印?

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person = [FXPerson new];
    [self.person addObserver:self forKeyPath:@"dataArray" options:(NSKeyValueObservingOptionNew) context:NULL];
}

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

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.person.dataArray addObject:@"Felix"];
}

答:不会

分析:

  • KVO是建立在KVC的基础上的,而可变数组直接添加是不会调用Setter方法
  • 可变数组dataArray没有初始化,直接添加会报错
// 初始化可变数组
self.person.dataArray = @[].mutableCopy;
// 调用setter方法
[[self.person mutableArrayValueForKey:@"dataArray"] addObject:@"Felix"];

三、KVO原理——isa-swizzling

1.官方解释

Key-Value Observing Programming Guide中有一段底层实现原理的叙述

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

2.代码探索

这段话说的云里雾里的,还是敲代码见真章吧

  • 注册观察者之前:类对象为FXPerson,实例对象isa指向FXPerson
  • 注册观察者之后:类对象为FXPerson,实例对象isa指向NSKVONotifying_FXPerson

从这两图中可以得出一个结论:观察者注册前后FXPerson类没发生变化,但实例对象的isa指向发生变化

那么这个动态生成的中间类NSKVONotifying_FXPersonFXPerson是什么关系呢?

在注册观察者前后分别调用打印子类的方法——发现NSKVONotifying_FXPersonFXPerson的子类

3.动态子类探索

①首先得明白动态子类观察的是什么?下面观察属性变量name成员变量nickname来找区别

两个变量同时发生变化,但只有属性变量监听到回调——说明动态子类观察的是setter方法

②通过runtime-API打印一下动态子类和观察类的方法

- (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);
}

通过打印可以看出:

  • FXPerson类中的方法没有改变(imp实现地址没有变化)
  • NSKVONotifying_FXPerson类中重写了父类FXPersondealloc方法
  • NSKVONotifying_FXPerson类中重写了基类NSObjectclass方法和_isKVOA方法
    • 重写的class方法可以指回FXPerson类
  • NSKVONotifying_FXPerson类中重写了父类FXPersonsetName方法
    • 因为子类只继承、不重写是不会有方法imp的,调用方法时会问父类要方法实现
    • 且两个setName的地址指针不一样
    • 每观察一个属性变量就重写一个setter方法(可自行论证)

dealloc之后isa指向谁?——指回原类

dealloc之后动态子类会销毁吗?——不会

页面pop后再次push进来打印FXPerson类,子类NSKVONotifying_FXPerson类依旧存在

automaticallyNotifiesObserversForKey是否会影响动态子类生成——会

动态子类会根据观察属性的automaticallyNotifiesObserversForKey的布尔值来决定是否生成

4.总结

  1. automaticallyNotifiesObserversForKeyYES时注册观察属性会生成动态子类NSKVONotifying_XXX
  2. 动态子类观察的是setter方法
  3. 动态子类重写了观察属性的setter方法、deallocclass_isKVOA方法
    • setter方法用于观察键值
    • dealloc方法用于释放时对isa指向进行操作
    • class方法用于指回动态子类的父类
    • _isKVOA用来标识是否是在观察者状态的一个标志位
  4. dealloc之后isa指向元类
  5. dealloc之后动态子类不会销毁

四、自定义KVO

根据KVO的官方文档和上述结论,我们将自定义KVO——下面的自定义会有runtime-API的使用和接口设计思路的讲解,最终的自定义KVO能满足基本使用的需求但仍不完善。系统的KVO回调和自动移除观察者都与注册逻辑分层,自定义的KVO将使用block回调和自动释放来优化这一点不足

新建一个NSObject+FXKVO的分类,开放注册观察者方法

-(void)fx_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath block:(FXKVOBlock)block;

1.注册观察者

  1. 判断当前观察值keypath是否存在/setter方法是否存在

一开始想的是判断属性是否存在,虽然父类的属性不会对子类造成影响,但是分类中的属性虽然没有setter方法,但是会添加到propertiList中去——最终改为去判断setter方法

if (keyPath == nil || keyPath.length == 0) return;
// if (![self isContainProperty:keyPath]) return;
if (![self isContainSetterMethodFromKeyPath:keyPath]) return;

// 判断属性是否存在
- (BOOL)isContainProperty:(NSString *)keyPath {
    unsigned int number;
    objc_property_t *propertiList = class_copyPropertyList([self class], &number);
    for (unsigned int i = 0; i < number; i++) {
        const char *propertyName = property_getName(propertiList[i]);
        NSString *propertyString = [NSString stringWithUTF8String:propertyName];
        
        if ([keyPath isEqualToString:propertyString]) return YES;
    }
    free(propertiList);
    return NO;
}

/// 判断setter方法
- (BOOL)isContainSetterMethodFromKeyPath:(NSString *)keyPath {
    Class superClass    = object_getClass(self);
    SEL setterSeletor   = NSSelectorFromString(setterForGetter(keyPath));
    Method setterMethod = class_getInstanceMethod(superClass, setterSeletor);
    if (!setterMethod) {
        NSLog(@"没找到该属性的setter方法%@", keyPath);
        return NO;
    }
    return YES;
}
  1. 判断观察属性的automaticallyNotifiesObserversForKey方法返回的布尔值
BOOL isAutomatically = [self fx_performSelectorWithMethodName:@"automaticallyNotifiesObserversForKey:" keyPath:keyPath];
if (!isAutomatically) return;

// 动态调用类方法
- (BOOL)fx_performSelectorWithMethodName:(NSString *)methodName keyPath:(id)keyPath {

    if ([[self class] respondsToSelector:NSSelectorFromString(methodName)]) {

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        BOOL i = [[self class] performSelector:NSSelectorFromString(methodName) withObject:keyPath];
        return i;
#pragma clang diagnostic pop
    }
    return NO;
}
  1. 动态生成子类,添加class方法指向原先的类
// 动态生成子类
Class newClass = [self createChildClassWithKeyPath:keyPath];

- (Class)createChildClassWithKeyPath:(NSString *)keyPath {
    NSString *oldClassName = NSStringFromClass([self class]);
    NSString *newClassName = [NSString stringWithFormat:@"%@%@", kFXKVOPrefix, oldClassName];
    Class newClass = NSClassFromString(newClassName);
    // 防止重复创建生成新类
    if (newClass) return newClass;
    
    // 申请类
    newClass = objc_allocateClassPair([self class], newClassName.UTF8String, 0);
    // 注册类
    objc_registerClassPair(newClass);
    // class的指向是FXPerson
    SEL classSEL = NSSelectorFromString(@"class");
    Method classMethod = class_getInstanceMethod([self class], classSEL);
    const char *classTypes = method_getTypeEncoding(classMethod);
    class_addMethod(newClass, classSEL, (IMP)fx_class, classTypes);
    
    return newClass;
}
  1. isa重指向——使对象的isa的值指向动态子类
object_setClass(self, newClass);
  1. 保存信息

由于可能会观察多个属性值,所以以属性值-模型的形式一一保存在数组中

typedef void(^FXKVOBlock)(id observer,NSString *keyPath,id oldValue,id newValue);

@interface FXKVOInfo : NSObject
@property (nonatomic, weak) NSObject *observer;
@property (nonatomic, copy) NSString *keyPath;
@property (nonatomic, copy) FXKVOBlock handleBlock;
@end

@implementation FXKVOInfo

- (instancetype)initWitObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath handleBlock:(FXKVOBlock)block {
    if (self=[super init]) {
        _observer = observer;
        _keyPath  = keyPath;
        _handleBlock = block;
    }
    return self;
}
@end

// 保存信息
FXKVOInfo *info = [[FXKVOInfo alloc] initWitObserver:observer forKeyPath:keyPath handleBlock:block];
NSMutableArray *mArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kFXKVOAssiociateKey));
if (!mArray) {
    mArray = [NSMutableArray arrayWithCapacity:1];
    objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(kFXKVOAssiociateKey), mArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
[mArray addObject:info];

2.添加setter方法并回调

往动态子类添加setter方法

- (Class)createChildClassWithKeyPath:(NSString *)keyPath {
    ...
    // 添加setter
    SEL setterSEL = NSSelectorFromString(setterForGetter(keyPath));
    Method setterMethod = class_getInstanceMethod([self class], setterSEL);
    const char *setterTypes = method_getTypeEncoding(setterMethod);
    class_addMethod(newClass, setterSEL, (IMP)fx_setter, setterTypes);
    
    return newClass;
}

setter方法的具体实现

static void fx_setter(id self,SEL _cmd,id newValue) {
    NSString *keyPath = getterForSetter(NSStringFromSelector(_cmd));
    id oldValue = [self valueForKey:keyPath];
    
    // 改变父类的值 --- 可以强制类型转换
    void (*lg_msgSendSuper)(void *,SEL , id) = (void *)objc_msgSendSuper;
    struct objc_super superStruct = {
        .receiver = self,
        .super_class = class_getSuperclass(object_getClass(self)),
    };
    lg_msgSendSuper(&superStruct,_cmd,newValue);
    
    // 信息数据回调
    NSMutableArray *mArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kFXKVOAssiociateKey));
    
    for (FXKVOInfo *info in mArray) {
        if ([info.keyPath isEqualToString:keyPath] && info.handleBlock) {
            info.handleBlock(info.observer, keyPath, oldValue, newValue);
        }
    }
}

3.销毁观察者

往动态子类添加dealloc方法

- (Class)createChildClassWithKeyPath:(NSString *)keyPath {
    ...
    // 添加dealloc
    SEL deallocSEL = NSSelectorFromString(@"dealloc");
    Method deallocMethod = class_getInstanceMethod([self class], deallocSEL);
    const char *deallocTypes = method_getTypeEncoding(deallocMethod);
    class_addMethod(newClass, deallocSEL, (IMP)fx_dealloc, deallocTypes);
    
    return newClass;
}

由于页面释放时会释放持有的对象,对象释放时会调用dealloc,现在往动态子类的dealloc方法名中添加实现将isa指回去,从而在释放时就不会去找父类要方法实现

static void fx_dealloc(id self, SEL _cmd) {
    Class superClass = [self class];
    object_setClass(self, superClass);
}

但仅仅是这样还是不够的,只把isa指回去,但对象不会调用真正的dealloc方法,对象不会释放

出于这种情况,根据iOS探索 runtime面试题分析讲过的方法交换进行一波操作

  • 取出基类NSObject的dealloc实现与fx_dealloc进行方法交换
  • isa指回去之后继续调用真正的dealloc进行释放
  • 之所以不在+load方法中进行交换,一是因为效率低,二是因为会影响到所有类
- (Class)createChildClassWithKeyPath:(NSString *)keyPath {
    ...
    // 添加dealloc
//    SEL deallocSEL = NSSelectorFromString(@"dealloc");
//    Method deallocMethod = class_getInstanceMethod([self class], deallocSEL);
//    const char *deallocTypes = method_getTypeEncoding(deallocMethod);
//    class_addMethod(newClass, deallocSEL, (IMP)fx_dealloc, deallocTypes);
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [self FXMethodSwizzlingWithClass:[self class] oriSEL:NSSelectorFromString(@"dealloc") swizzledSEL:@selector(fx_dealloc)];
    });
    
    return newClass;
}

- (void)fx_dealloc {
    Class superClass = [self class];
    object_setClass(self, superClass);
    [self fx_dealloc];
}

就这样自定义KVO将KVO三部曲用block形式合成一步

写在后面

本文demoJ_Knight_写的SJKVOControllerFBKVO(建议看看这个成熟的自定义KVO)

最近在掘金上看到一个沸点——“很多人明白原理,但到了真正敲代码的时候就不会了”

学习如同踩坑爬坑,有些坑看过别人踩过,自己不去尝试过都不知道是怎么回事。或许你会有抓耳挠腮迷惑的时候,但是你不去解决困难,困难永远会挡在你成长的路上

你要悄悄拔尖,然后惊艳所有人🌺——————与君共勉