重新认识KVO

523 阅读14分钟

KVO

Key-value observing is a mechanism that allows objects to be notified of changes to specified properties of other objects.

Import: In order to understand key-value observing, you must first understand key-value coding.

键值观察提供了一种机制,允许对象通知其他对象的特定属性的更改。它对应用程序中模型和控制器层之间的通信特别有用。(在OS X中,控制器层绑定技术严重依赖于键值观察。)控制器对象通常观察模型对象的属性,视图对象通过控制器观察模型对象的属性。另外,模型对象可以观察其他模型对象(通常用于确定从属值何时改变)或甚至自身(再次确定从属值何时改变)。

KVO初探

我们开发中肯定都用过KVO,因为有的时候挺好使的,下面是一个简单的使用样例:

@interface OMPerson : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *nick;
@end
// 监听tom的名字改变
self.tom = [OMPerson new];
[self.tom addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];
// 移除监听
- (void)dealloc{
  [self.tom removeObserver:self forKeyPath:@"name"];
}

以上就是最简单的使用样例了,KVO就是使用下面三个接口:

// 添加监听
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;

// 监听的处理回调
- (void)observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change context:(nullable void *)context;

// 移除监听
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context;

KVO细节

我们使用的时候都类似都是这样:

[self.tom addObserver:self forKeyPath:@"name" options:(NSKeyValueObservingOptionNew) context:NULL];

但是这样在少数的属性监听还好,但是属性一多的话需要在监听回调处理方法observeValueForKeyPath:ofObject:change:context:中进行分情况处理,但是很多人也许会使用keyPath来进行分情况处理,这种处理方式有很大的问题,所以还会使用object来进行判断是哪个类的,但这不是最优解。最优解是使用context来进行处理,context可以自己控制唯一的标识符。

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

还有一点就是**context不需要时不要使用nil应使用NULL。**这点在官方文档指明的选项。

The context pointer in the addObserver:forKeyPath:options:context: message contains arbitrary data that will be passed back to the observer in the corresponding change notifications. You may specify NULL and rely entirely on the key path string to determine the origin of a change notification, but this approach may cause problems for an object whose superclass is also observing the same key path for different reasons.

还有就是切记一定要移除观察者监听,也许有人知道在iOS11及以后一些键盘的监听不需要移除,但是观察者是必须移除的,因为会留下崩溃的隐患。在一些场景下可以重现崩溃。

KVO手动触发监听

有些场景我们希望手动触发,官方也提供了手动触发的方法,只需在更改值得前后手动调用方法进行通知。

/// 手动触发监听的方法
- (void)willChangeValueForKey:(NSString *)key;
- (void)didChangeValueForKey:(NSString *)key;

下面是一个简单的例子:

- (void)setBalance:(double)theBalance {
    [self willChangeValueForKey:@"balance"];
    _balance = theBalance;
    [self didChangeValueForKey:@"balance"];
}

上述我们重写了setter并进行了手动触发值改变,当很多值需要改变时也是和上述代码类似,在值改变的前后都应该是手动触发监听的方法。automaticallyNotifiesObserversOfSteps是自动监听属性变化的开关,我们可以关闭后使用上述的手动监听。

KVO多路径集合监听

在有的场景中我们需要两个及以上的属性变化时触发监听机制,来做一些自定义逻辑,比如一个人的firstName 和 lastName会影响这个人的fullname时。在类中实现+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key方法

+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
    NSSet *keyPath = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"fullname"]){
        NSArray *affectingKeys = [NSArray arrayWithObjects:@"firstName", @"lastName", nil];
        keyPath = [keyPath setByAddingObjectsFromArray:affectingKeys];
    }
    return keyPath;
}

实现了此方法后,当firstName和lastName中的任一值改变都会触发监听事件进行我们的逻辑进行处理。

KVO监听类型

我们知道添加监听然后进行监听,但是知道可以被监听的类型有哪些吗?NSKeyValueChange是一个包含可被监听类型的枚举:

/** 可被监听的类型*/
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
    NSKeyValueChangeSetting = 1,    // 设置一个新值时
    NSKeyValueChangeInsertion = 2,  // 插入值
    NSKeyValueChangeRemoval = 3,    // 移除值
    NSKeyValueChangeReplacement = 4,// 替换值
};

还有一个常见问题在开发中碰到过,就是集合类型改变值得不会触发回调:

❌不会有作用
[self.tom.card addObject:@"xx白金VIP卡"];
✅这种做法才是正确的
[[self.tom mutableArrayValueForKey:@"card"] addObject:@"xx白金VIP卡"];

至于为啥不会触发回调呢,文章开篇就提到过想要学习KVO必须先学习KVC,为啥?因为KVO是基于KVC之上的产物,所以答案还是在KVC中,我们可以再官方文档的Accessor Search Patterns章节的Search Pattern for Mutable Arrays找到答案。所以出现问题不知道的时候还是直接去查看官方文档是正确的,会指引你找到answer。

KVC原理

在了解一些基础的用法后我们总希望可以了解到一些原理和运作流程,但是之前说过KVC没有开源,而KVO在KVC之上,那要怎样才能了解KVO的原理和流程呢,我们可以猜着来😃😆。

我们很多人都知道或听过KVO会生成NSKVONotifying_xxx的类,到底是真的还是假的呢?但是我们怎么验证呢?这也是我们的第一步。既然如果会生成子类,那么我们可以通过Runtime获取该类以及子类,我们写个获取指定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);
    if (count == 0){
        return;
    }
    for (int i = 0; i<count; i++) {
        if (cls == class_getSuperclass(classes[i])) {
            [mArray addObject:classes[i]];
        }
    }
    free(classes);
    NSLog(@"ViewController == classes = %@", mArray);
}

我们利用这个方法在我们添加监听的地方都打印一下就知道有没有:

[self printClasses:[self.person class]];
[self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL];
[self printClasses:[self.person class]];

想知道结果吗?😃,你会发现还真的有哦,所以不是假的。我们也能通过LLDB分别打印一下前后的person的类,po object_getClass(self.person)一样知道结果。

ViewController == classes = (
    OMPerson
)

ViewController == classes = (
    OMPerson,
    "NSKVONotifying_OMPerson"
)

我们既然知道有这个隐藏类的生成,我们就探探这个隐藏类的方法是啥,我们需要给属性赋值就需要通过setter,为了方便在写个打印类的方法列表的方法:

- (void)printClassAllMethod:(Class)cls{
    unsigned int count = 0;
    Method *methodList = class_copyMethodList(cls, &count);
    if (count == 0) {return}
    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);
}

利用这个办法老样子在注册的前后打印类的方法列表,如果像上面那样只改个方法可以吗?为啥?🤔🤔,不能,因为我们需要知道隐藏类的方法列表,所以略有不同:

[self printClassAllMethod:[self.person class]];
[self.person addObserver:self forKeyPath:@"nickName" options:(NSKeyValueObservingOptionNew) context:NULL];
NSLog(@"******添加监听之后********");
// 获取隐藏类
Class cls = NSClassFromString(@"NSKVONotifying_OMPerson");
[self printClassAllMethod:cls];

结果还是符合我们的预期:

2019-01-28 19:29:42.210767+0800 003---自定义KVO[5399:258579].cxx_destruct-0x10e6ed640
2019-01-28 19:29:42.218558+0800 003---自定义KVO[5399:258579] copyWithZone:-0x10e6ed3f0
2019-01-28 19:29:42.220167+0800 003---自定义KVO[5399:258579] setNickName:-0x10e6ed430
2019-01-28 19:29:42.228732+0800 003---自定义KVO[5399:258579] nickName-0x10e6ed610
2019-01-28 19:29:42.231155+0800 003---自定义KVO[5399:258579] ******添加监听之后********
2019-01-28 19:29:42.231939+0800 003---自定义KVO[5399:258579] setNickName:-0x10ea4963a
2019-01-28 19:29:42.233139+0800 003---自定义KVO[5399:258579] class-0x10ea4806e
2019-01-28 19:29:42.234472+0800 003---自定义KVO[5399:258579] dealloc-0x10ea47e12
2019-01-28 19:29:42.235741+0800 003---自定义KVO[5399:258579] _isKVOA-0x10ea47e0a

**至此,我们可以非常的肯定就是监听的实现生成了派生类而且基于此实现的监听。我们知道派生类手动添加了setter方法以及class、dealloc方法。**而且我们知道添加了setter方法后是通过手动触发监听的,然后在回调之前的类,通过Runtime的强大能力完成。我们大概猜完了整个流程,如何验证呢?🤔🤔

自定义KVO

还没验证完流程呢就开始自定义KVO?没办法,一切的猜想必须用实际行动去验证,我们可以写个自定义的KVO,来模拟这个过程,就能知道结果。

开始实现雏形版本

KVO的三部曲中的添加监听和移除监听我们可以照搬过来改后缀,在实现添加监听。

@interface NSObject (OMKVO)

- (void)om_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context;

- (void)om_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0));

@end
- (void)om_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context{
    // 1. 判断是否有setter方法
    judgeHasSetterMethod([self class],keyPath);
    // 2. 生成指定前缀的派生类
    Class newClass = [self createCalssForKeyPath:keyPath];
    // 3. 指向新创建的类
    object_setClass(self, newClass);
    // 4. 保存观察者
    objc_setAssociatedObject(self, &OMObserverKey, observer, OBJC_ASSOCIATION_RETAIN);
}

我们需要先进行判断原本的类是否有该setter方法,如果没有没必要进行下面的操作。其次生成指定的派生类:

/** 生成指定的OMKVONotifying_xxx类*/
- (Class)createCalssForKeyPath: (NSString *)keyPath{
    NSString *className = [NSString stringWithFormat:@"OMKVONotifying_%@", [self class]];
    Class newClass = NSClassFromString(className);
    // 如果已存在则不创建
    if (newClass) {
        return newClass;
    }
    newClass = objc_allocateClassPair([self class], className.UTF8String, 0);
    objc_registerClassPair(newClass);

    // 添加setter方法
    SEL setter = NSSelectorFromString(setterForKeyPath(keyPath));
    Method setterMethod = class_getInstanceMethod([self class], setter);
    const char* setterType = method_getTypeEncoding(setterMethod);
    class_addMethod(newClass, setter, (IMP)om_setter, setterType);
    
    return newClass;
}

我们得根据传入的keyPath自动创建并添加指定的setter方法才是可选的:

static void om_setter(id self, SEL _cmd, id newValue){
    NSLog(@"*******进入自定义的setter********");
    /// 自定义结构体
    struct objc_super superStruct = {
        .receiver = self,
        .super_class = class_getSuperclass(object_getClass(self)),
    };
    objc_msgSendSuper(&superStruct, _cmd, newValue);
    
    // 获取观察者
    id observer = objc_getAssociatedObject(self, &OMObserverKey);
    // 消息发送后,手动调用观察者的方法
    SEL observerSel = @selector(observeValueForKeyPath:ofObject:change:context:);
    NSString *keyPath = getterForKeyPath(NSStringFromSelector(_cmd));
    objc_msgSend(observer, observerSel, keyPath, self, @{keyPath: newValue}, NULL);
}

上述我们需要通知原来的类,所以必须向父类发送消息,那么通过objc_msgSendSuper需要传入结构体,我们只需新建一个就行。下面是objc_msgSendSuper和结构体的源码。

objc_msgSendSuper(struct objc_super * _Nonnull super, SEL _Nonnull op, ...)
    OBJC_AVAILABLE(10.0, 2.0, 9.0, 1.0, 2.0);
/// Specifies the superclass of an instance. 
struct objc_super {
    /// Specifies an instance of a class.
    __unsafe_unretained _Nonnull id receiver;

    /// Specifies the particular superclass of the instance to message. 
#if !defined(__cplusplus)  &&  !__OBJC2__
    /* For compatibility with old objc-runtime.h header */
    __unsafe_unretained _Nonnull Class class;
#else
    __unsafe_unretained _Nonnull Class super_class;
#endif
    /* super_class is the first class to search */
};

我们把这些都完成后就算写完了第一版的KVO,我们运行后使用我们自定义的监听后,像之前一样前后打印了类,发现和系统的是差不多的,打印方法列表的时候发现还有class、dealloc没有添加对比系统生成的类。但是当我们在控制台:po self.person时发现和系统不太一样,之前是OMPerson,之后是OMKVONotifying_OMPerson,这和我们预期的不一致,对比系统生成的类的方法列表就知道这个class是用来指回原类的作用,是不是瞬间恍然大悟。

classes = (
    OMPerson
)

classes = (
    OMPerson,
    "OMKVONotifying_OMPerson"
)

于是我们在添加setter方法里面再添加class方法以及dealloc方法,让他指回原类。为啥不使用[self class]来获取呢,试了的同学肯定知道是不行的,原因就是会造成循环指向。

Class om_class(id self,SEL _cmd){
    return class_getSuperclass(object_getClass(self));
}

当前版本可以用,但是会内存泄漏的问题,因为observer被强引用了。我们都说函数式编程好,那我们可以在这个基础之上把添加监听和监听的步骤在一个函数内处理将会很大的便利,其实也就是把监听逻辑处理放在闭包内,所以上述问题我们在新版本看看有没有问题。

添加多对象、函数式

为了能够让多个对象监听,已有的关联模式不能实现,那么我们必须得保留这些信息,我们可以用一个类来保存。

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

@interface OMInfo : NSObject
@property (nonatomic, weak) NSObject  *observer; // 弱引用,防止引用循环
@property (nonatomic, copy) NSString  *keyPath;
@property (nonatomic, copy) OMKVOBlock  handleBlock;
@end

@implementation OMInfo

- (instancetype)initWitObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath handleBlock:(OMKVOBlock)block{
    if (self=[super init]) {
        _observer = observer;
        _keyPath  = keyPath;
        _handleBlock = block;
    }
    return self;
}
@end
// 4: 替换原来的关联,使用数组来进行关联
    NSMutableArray *mArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kOMKVOAssiociateKey));
    OMInfo *info = [[OMInfo alloc] initWitObserver:observer forKeyPath:keyPath handleBlock:block];
    
    if (!mArray) {
        mArray = [NSMutableArray arrayWithCapacity:1];
        objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(kOMKVOAssiociateKey), mArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    
    for (OMInfo *info1 in mArray) {
        if (info1.observer == observer && [info1.keyPath isEqualToString:keyPath]) {
            break;
        }
        [mArray addObject:info];
    }

然后我们在之前向父类发送消息的地方改为:

NSMutableArray *mArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kOMKVOAssiociateKey));
    
for (OMInfo *info in mArray) {
  // 逐个回调
   if ([info.keyPath isEqualToString:keyPath] && info.handleBlock) {
      info.handleBlock(info.observer, keyPath, oldValue, newValue);
   }
}

至此完成了函数式的转变和支持对象的需求,使用非常方便:

// 监听和处理一起写
[self.person om_addObserver:self forKeyPath:@"nickName" block:^(id  _Nonnull observer, NSString * _Nonnull keyPath, id  _Nonnull oldValue, id  _Nonnull newValue) {
        NSLog(@"%@-%@",oldValue,newValue);
}];

其实这里还可以在进行优化,支持同对象多属性监听,然后丰富这个监听的类型和返回值,可以简化一些代码。到这里我们还是需要手动移除监听,这也是我们非常不愿意却又不得不做的事,有没有办法实现自动移除监听呢?

自动移除监听

只需添加和写逻辑那很爽啊,不需要移除,简直是嗨到不行。我们肯定第一时间就想到使用swizzling来进行交换:

Method m1 = class_getInstanceMethod([self class], NSSelectorFromString(@"dealloc"));
Method m2 = class_getInstanceMethod([self class], @selector(om_dealloc));
method_exchangeImplementations(m1, m2);

但是我们创建的类还没有添加dealloc方法,系统创建的类添加dealloc的原因是啥呢?其实我们仔细想想还是可以得知一二的,如果我们创建的OMPerson里没有重写dealloc方法,那么在进行交换的时候就会交换NSObject的dealloc,那么就乱套直接崩溃。当然我们也可以给通过Runtime手动给OMPerson添加一个dealloc方法,这样可以解决问题但是不采用,因为KVO追求的就是一种不侵入的思想,所以才创建一个类去完成,也许聪明的同学可能想到了给我们创建的子类添加dealloc方法,这就能解释为啥系统创建的类里添加了dealloc方法。

SEL deallocSEL = NSSelectorFromString(@"dealloc");
Method deallocMethod = class_getInstanceMethod([self class], deallocSEL);
const char *deallocTypes = method_getTypeEncoding(deallocMethod);
class_addMethod(newClass, deallocSEL, (IMP)OM_dealloc, deallocTypes);

给子类添加dealloc后再进行交换就能解决这个问题,所以就解决完自动移除监听的操作了。就可以随心所欲使用这个分类啦。

+ (BOOL)om_hookOrigInstanceMenthod:(SEL)oriSEL newInstanceMenthod:(SEL)swizzledSEL {
    Class cls = self;
    Method oriMethod = class_getInstanceMethod(cls, oriSEL);
    Method swiMethod = class_getInstanceMethod(cls, swizzledSEL);
    
    if (!swiMethod) {
        return NO;
    }
    if (!oriMethod) {
        class_addMethod(cls, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(swiMethod));
        method_setImplementation(swiMethod, imp_implementationWithBlock(^(id self, SEL _cmd){ }));
    }
    
    BOOL didAddMethod = class_addMethod(cls, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(swiMethod));
    if (didAddMethod) {
        class_replaceMethod(cls, swizzledSEL, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
    }else{
        method_exchangeImplementations(oriMethod, swiMethod);
    }
    return YES;
}


+ (void)load{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{

        [self om_hookOrigInstanceMenthod:NSSelectorFromString(@"dealloc") newInstanceMenthod:@selector(om_ealloc)];
    });
}

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

浅谈FBKKVOController

我们上面自定义了一个KVO,看看牛人写的KVOFBKVOController是怎样实现的呢?使用的单例来管理KVO,并且我们保存监听者等信息用数组保存,FB用的是哈希表来进行保存且效率更为高,而且采用了中间层的思想,就可以实现代理、闭包、target的方式来进行回调。当然各方面都考虑的周到,我们自定义的版本用在项目上还是需要一些打磨的,比如线程安全、更为详细的回调这些问题。所以FB中的中间层思想很值得我们去学习。

self.person = [[OMPerson alloc] init];
self.person.name = @"Uro";
self.person.age = 18;
self.person.mArray = [NSMutableArray arrayWithObject:@"1"];

// target的方式进行监听
[self.kvoCtrl observe:self.person keyPath:@"age" options:0 action:@selector(om_observerAge)];
// 闭包形式   
[self.kvoCtrl observe:self.person keyPath:@"name" options:(NSKeyValueObservingOptionNew) block:^(id  _Nullable observer, id  _Nonnull object, NSDictionary<NSString *,id> * _Nonnull change) {
        NSLog(@"****%@****",change);
 }];
// 监听数组改变    
[self.kvoCtrl observe:self.person keyPath:@"mArray" options:(NSKeyValueObservingOptionNew) block:^(id  _Nullable observer, id  _Nonnull object, NSDictionary<NSString *,id> * _Nonnull change) {
        NSLog(@"****%@****",change);
 }];
 - (void)om_observerAge{
    NSLog(@"来了 改变年级");
}

// 必须通过懒加载
- (FBKVOController *)kvoCtrl{
    if (!_kvoCtrl) {
        _kvoCtrl = [FBKVOController controllerWithObserver:self];
    }
    return _kvoCtrl;
}

总结

从初探到自定义的过程很丰富很精彩,但是官方为啥没有采用类似的做法呢?我觉得官方觉得这种方案太过于耦合,不符合他们的思想,官方更注重于逻辑分离层面,让代码不那么混乱。当然喽,这是我的个人猜测😃😃。