阅读 486

iOS底层学习 - KVO探索之路

上一章节我们讲了KVC的使用和底层原理,并自己简单实现了一个简单的KVC,对KVC已经有了基本的了解,那么这一章节,就来讲一下,基于KVC的KVO是怎么一回事

传送门☞iOS底层学习 - KVC探索之路

什么是KVO

KVO:(Key-Value-Observer)是一种机制,也叫观察者模式,该机制允许将其他对象的特定属性的更改通知给对象。对于应用程序中模型层和控制器层之间的通信特别有用

KVO的使用

KVO的在平时的开发过程中,使用也比较多。基本就是3个步骤:

  1. 观察者注册
  2. 观察者接收通知
  3. 移除观察者

下面逐步讲解这3个步骤的使用

观察者注册

- (void)addObserver:(NSObject *)observer
         forKeyPath:(NSString *)keyPath
            options:(NSKeyValueObservingOptions)options
            context:(void *)context
复制代码

使用上述代码来进行观察者的注册,首先来看一下各参数的意义

  • observer:KVO 通知的对象,需要实现observeValueForKeyPath:ofObject:change:context:代理方法
  • keyPath: 被观察者的属性的名称
  • options: 枚举类型,主要是观察的属性的变化类型
  • context: 上下文,主要是传递给代理使用。

前两个参数的意思比较好理解,对于后两个参数,平时理解可能不那么深刻,我们来重点解释一下。

1.options

NSKeyValueObservingOptionNew:观察属性变化后的新值

NSKeyValueObservingOptionOld:观察属性变化后的旧值

NSKeyValueObservingOptionInitial:在属性发生变化后立即通知观察者,这个过程甚至早于观察者注册(使用较少)。简单来说就是这个枚举值会在属性变化前先触发一次回调。

NSKeyValueObservingOptionPrior:这个枚举值会先后连续出发两次 observeValueForKeyPath 回调。同时在回调中的可变字典中会有一个布尔值的 key - notificationIsPrior 来标识属性值是变化前还是变化后的。如果是变化后的回调,那么可变字典中就只有 new的值了

在平时的开发中,我们最尝试用的就是NSKeyValueObservingOptionNewNSKeyValueObservingOptionOld,从而进行逻辑编写

2.context

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.

A safer and more extensible approach is to use the context to ensure notifications you receive are destined for your observer and not a superclass.

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

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

通过阅读文档,我们知道,context并不是必须要传递的参数,如果不用时,我们最好传递NULL,这时候在回调中的判断就需要完全根据objectkeyPath来进行判断。但是不同如果有相同名称keyPath时,判断起来就要嵌套多层判断语句,使用context可以完美解决这个问题。

那么怎么使用比较好呢?

我推荐大家使用static void * XXContext = &XXContext;这种方法,静态变量存储着它的指针地址。在回调中可以直接使用context == XXContex来进行判断,方便快捷,而且更安全高效,扩展形强

观察者接收通知

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

复制代码

接收观察者的对象,必须实现以上代理,才能接收到变化前后的值和参数。

参数的含义都比较好理解,和注册时填写的参数是基本一致的。change字典中就包含了观察属性变化前后的值,我们所需要的数据也在里面。object是被观察的对象,context是注册时传递的上下文,我们一般用来做判断使用.

移除观察者

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(void *)context;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
复制代码

注册和删除是一一对应的关系。如果注册了观察者,则必须进行移除

一旦对某个对象上的属性注册了观察者,可以选择在收到属性值变化后取消注册,也可以在观察者声明周期结束之前(比如:dealloc 方法) 取消注册,如果忘记调用取消注册方法,那么一旦观察者被销毁后,KVO 机制会给一个不存在的对象发送变化回调消息导致野指针错误。导致崩溃

KVO的自动与手动

通过上述的3大步骤注册到一个观察者后,当被观察的keyPath出现变化时,对应的回调就能收到相关的数据,这属于系统给我们实现好的自动挡KVO。但是在日常开发中,我们可能有一部分需要监听,一部分不需要监听,这时候我们想要自己控制KVO变化,那我们就需要实现手动挡KVO了。

实现手动挡的KVO,需要修改下面的方法。系统默认为YES,如果我们改为NO,则说明被观察者需要手动进行观察,才能出发回调了。我们可以再此方法中通过判断key来进行自由的手动和自动的选择

+ (BOOL) automaticallyNotifiesObserversForKey:(NSString *)key{
    return NO;
}
复制代码

比如我们的nick属性需要进行手动处理,我们可以再他的set方法中添加willChangeValueForKeydidChangeValueForKey来标志属性即将发生变化和变化完成,这样就实现了一个手动挡的KVO

- (void)setNick:(NSString *)nick{
    [self willChangeValueForKey:@"nick"];
    _nick = nick;
    [self didChangeValueForKey:@"nick"];
}
复制代码

集合类型的使用

当我们观察一个集合类型的属性时,使用方法需要有细微的差别。是因为KVO是基于KVC的,所以必须有对应的set或者insert方法是才可以。

比如我们监听dateArray可变数组(需要初始化)

[self.person addObserver:self forKeyPath:@"dateArray" options:(NSKeyValueObservingOptionNew) context:NULL];
复制代码

如果我们直接调用add方法,这个时候是没有走KVC的,所以此时KVO是监听不到的。

[self.person.dateArray addObject:@"1"];
复制代码

这个时候我们应该使用KVC的方式,来对数组进行赋值,此时,就可以监听到数组的变化了

[[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"2"];
复制代码

一对多关系的使用

所谓一对多,就是被观察的属性的变化,取决于其他多个属性的变化。最常见的例子就是下载进度。下载进度=现在进度/总进度,所以当我们观察下载进度时,现在进度和总进度发生变化,都要触发对应的回调。

比如,下载进入的get方法如下,可以看到其收其他两个属性影响

- (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];
}
复制代码

当我们监听了downloadProgress属性,writtenDatatotalData发生变化时,都能在回调中收到对应值。

[self.person addObserver:self forKeyPath:@"downloadProgress" options:(NSKeyValueObservingOptionNew) context:NULL];
 
self.person.writtenData += 10;
self.person.totalData += 20;
 
复制代码

KVO底层原理

Automatic key-value observing is implemented using a technique called 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. 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. 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-swizzling的技术实现的。 该isa指针,顾名思义,指向对象的类,它保持一个调度表。该分派表实质上包含指向该类实现的方法的指针以及其他数据。 在为对象的属性注册观察者时,将修改观察对象的isa指针,指向中间类而不是真实类。结果,isa指针的值不一定反映实例的实际类。 您永远不要依靠isa指针来确定类成员。相反,您应该使用该class方法确定对象实例的类。

通过阅读文档内容,我们可以发现,KVO的实现原理很简单,即isa-swizzling,把对象指向类的isa指向了一个中间类

中间类是什么样的?

通过下图对象添加观察者前后,isa指向的变化,可以看到。在添加了观察者的注册之后,isa指向了一个名为NSKVONotifying_XXX的类

中间类是原来类的子类么?

我们可以通过如下代码,查看有关该类的所有相关继承的类。

- (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<count; i++) {
        if (cls == class_getSuperclass(classes[i])) {
            [mArray addObject:classes[i]];
        }
    }
    free(classes);
    NSLog(@"classes = %@", mArray);
}
复制代码

通过前后打印LGPerson的相关类,可以明显的看出,生成的中间类,是继承自原来的类的,是其子类

中间类具体作用?

我们知道,KVO是基于KVC的基础上的,所以改变时必有getset方法。那么是不是KVO是根据监听其set方法来达到目的的呢,我们可以通过打印中间类重写的方法来得到验证。

#pragma mark - 遍历方法-ivar-property
- (void)printClassAllMethod:(Class)cls{
    NSLog(@"*********************");
    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);
}
复制代码

通过在添加观察者前后方法列表,我们可以发现,继承自原来类的中间类,主要重写了set<keyPath>,class,dealloc,_isKVOA方法,从而达到KVO的实现。

方法的意义

set<keyPath>

KVO是基于KVC的,属性发生变化时,必然要走进set方法,所以重写此方法是必然的。KVO在重写set方法后,内部要调用willChangeValueForKeydidChangeValueForKey方法,并在中间触发observeValueForKeyPath:ofObject:change:context:回调,从而通知给观察者进行操作。

class

重写此方法是为了对中间类进行伪装,通过对添加观察前后,打印类的isa指向可以得知,获取到的元类还是LGPerson,说明系统内部对class方法的重写是对中间类的伪装,并在类调用calss方法时,还是获取到的原来的类。

dealloc

在添加KVO进行后,进行了isa_swizing,但是何时给交换回来呢。

通过对dealloc方法打断点,可以得知,在观察者销毁后,对象的指向就会交换回来

那么对象的isa交换回来后,中间类是否销毁了呢,我们可以再打印一下相关的类和子类列表看一下,由此可以发现,中间类并不会销毁

_isKVOA

该方法就是用来标识是否是在观察者状态的一个标志位。

自定义KVO

在了解了KVO的底层原理后,我们还是和KVC一样,尝试来简单的来自定义一个KVO来加深一下印象。

首先我们还是还是新建一个NSObject的分类用来处理KVO相关的逻辑,并对系统的KVO进行了函数式编程自动销毁观察者等优化,主体思路如下:

  1. 注册观察者
    • 判断是否存在setter的方法
    • 动态生成继承自原来类的中间类
    • 进行isa-swizzling到中间类
    • 建立Model并保存在数组或者字典中,用来输出变化前后的数据,通过关联对象保存
  2. 重写setter并进行回调
    • set方法获取getter方法的名称 set<Key>:===> key,从而获取到key
    • 根据keyPathKVC的方式获取到旧值
    • 向父类发送消息(objc_msgSendSuper),从来调用原来类的setter方法
    • 通过函数式编程思想,利用block回调,将保存的Model信息传递给观察者
  3. 销毁观察者
    • 通过method-swizzling交换dealloc方法
    • 先将对象isa指回给原来的类
    • 执行原来类的dealloc原方法

以上为自定义KVO的主体思路,下面我们通过代码来看一下。

注册观察者

首先我们建立一个Model,用来保存我们每次观察者注册时候的信息,用来返回旧值,保存传递block回调等。

static NSString *const kLGKVOPrefix = @"LGKVONotifying_";
static NSString *const kLGKVOAssiociateKey = @"kLGKVO_AssiociateKey";

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

@implementation LGInfo

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

接着提供注册的方法,和系统的基本一样,我们应该加上自定义的前缀和block回调

- (void)lg_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath block:(LGKVOBlock)block;
复制代码

下面是实现注册的主要代码

- (void)lg_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath block:(LGKVOBlock)block{
    
    ✅// 1: 验证是否存在setter方法 : 不让实例进来
    [self judgeSetterMethodFromKeyPath:keyPath];
    ✅// 2: 动态生成子类
    Class newClass = [self createChildClassWithKeyPath:keyPath];
    ✅// 3: isa的指向 : LGKVONotifying_LGPerson
    object_setClass(self, newClass);
    ✅// 4: 保存信息,使用关联对象保存数组
    LGInfo *info = [[LGInfo alloc] initWitObserver:observer forKeyPath:keyPath handleBlock:block];
    NSMutableArray *mArray = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kLGKVOAssiociateKey));
    if (!mArray) {
        mArray = [NSMutableArray arrayWithCapacity:1];
        objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(kLGKVOAssiociateKey), mArray, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    [mArray addObject:info];
}
复制代码

以下是各步骤的具体实现。

1.验证是否存在setter方法

#pragma mark - 验证是否存在setter方法
- (void)judgeSetterMethodFromKeyPath:(NSString *)keyPath{
    ✅// 获取到当前的类
    Class superClass    = object_getClass(self);
    ✅// 获取到当前的set方法IMP并进行判断
    SEL setterSeletor   = NSSelectorFromString(setterForGetter(keyPath));
    Method setterMethod = class_getInstanceMethod(superClass, setterSeletor);
    if (!setterMethod) {
        @throw [NSException exceptionWithName:NSInvalidArgumentException reason:[NSString stringWithFormat:@"老铁没有当前%@的setter",keyPath] userInfo:nil];
    }
}

复制代码

2.动态生成子类。通过原理的探究,我们知道,中间类主要是重写了3个方法,所以我们在创建类的时候,要把这3个方法也动态创建出来,对原来类的方法进行重写

#pragma mark - 动态生成子类
- (Class)createChildClassWithKeyPath:(NSString *)keyPath{
    
    NSString *oldClassName = NSStringFromClass([self class]);
    ✅// 在原来类的类名基础上进行拼接
    NSString *newClassName = [NSString stringWithFormat:@"%@%@",kLGKVOPrefix,oldClassName];
    Class newClass = NSClassFromString(newClassName);
    ✅// 防止重复创建生成新类
    if (newClass) return newClass;
    /**
     * 如果内存不存在,创建生成
     * 参数一: 父类
     * 参数二: 新类的名字
     * 参数三: 新类的开辟的额外空间
     */// 2.1 : 申请类
    newClass = objc_allocateClassPair([self class], newClassName.UTF8String, 0);
    ✅// 2.2 : 注册类
    objc_registerClassPair(newClass);
    ✅// 2.3.1 : 添加class : class的指向是LGPerson,即原来类,进行伪装
    SEL classSEL = NSSelectorFromString(@"class");
    Method classMethod = class_getInstanceMethod([self class], classSEL);
    const char *classTypes = method_getTypeEncoding(classMethod);
    class_addMethod(newClass, classSEL, (IMP)lg_class, classTypes);
    ✅// 2.3.2 : 添加setter
    SEL setterSEL = NSSelectorFromString(setterForGetter(keyPath));
    Method setterMethod = class_getInstanceMethod([self class], setterSEL);
    const char *setterTypes = method_getTypeEncoding(setterMethod);
    class_addMethod(newClass, setterSEL, (IMP)lg_setter, setterTypes);
    ✅// 2.3.3 : 添加dealloc
    SEL deallocSEL = NSSelectorFromString(@"dealloc");
    Method deallocMethod = class_getInstanceMethod([self class], deallocSEL);
    const char *deallocTypes = method_getTypeEncoding(deallocMethod);
    class_addMethod(newClass, deallocSEL, (IMP)lg_dealloc, deallocTypes);
    
    return newClass;
}
复制代码

重写setter并进行回调

由于我们重写了原来类的setter方法,所以新的setter方法要我们自己来实现,道理也很简单,就是获取到新值后,发送给父类,即原来类,然后将存储的Model取出,进行回调

tatic void lg_setter(id self,SEL _cmd,id newValue){
    ✅// 根据setter方法,获取到key,并取出旧值
    NSString *keyPath = getterForSetter(NSStringFromSelector(_cmd));
    id oldValue = [self valueForKey:keyPath];
    ✅// 消息转发 : 转发给父类
    // 改变父类的值 --- 可以强制类型转换
    void (*lg_msgSendSuper)(void *,SEL , id) = (void *)objc_msgSendSuper;
    // void /* struct objc_super *super, SEL op, ... */
    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)(kLGKVOAssiociateKey));
    
    for (LGInfo *info in mArray) {
        if ([info.keyPath isEqualToString:keyPath] && info.handleBlock) {
            info.handleBlock(info.observer, keyPath, oldValue, newValue);
        }
    }
}
复制代码
#pragma mark - 从get方法获取set方法的名称 key ===>>> setKey:
static NSString *setterForGetter(NSString *getter){
    
    if (getter.length <= 0) { return nil;}
    
    NSString *firstString = [[getter substringToIndex:1] uppercaseString];
    NSString *leaveString = [getter substringFromIndex:1];
    
    return [NSString stringWithFormat:@"set%@%@:",firstString,leaveString];
}

#pragma mark - 从set方法获取getter方法的名称 set<Key>:===> key
static NSString *getterForSetter(NSString *setter){
    
    if (setter.length <= 0 || ![setter hasPrefix:@"set"] || ![setter hasSuffix:@":"]) { return nil;}
    
    NSRange range = NSMakeRange(3, setter.length-4);
    NSString *getter = [setter substringWithRange:range];
    NSString *firstString = [[getter substringToIndex:1] lowercaseString];
    return  [getter stringByReplacingCharactersInRange:NSMakeRange(0, 1) withString:firstString];
}
复制代码

销毁观察者

销毁观察者的这一步,我们可以使用常规的方法,即和系统实现一样,提供一个移除观察者的方法,在里面进行isa的指回和关联对象接触等操作,但是这样是比较麻烦的,每次注册后,都需要记得手动去移除,代码鲁棒性差。

所以我们想到,如果当前对象要进行销毁了,那么其中间类自然不必存在,这时候就可以移除观察者了。所以我们hook原来类的dealloc方法,和我们自己写的方法进行交换,这样就可以添加上我们自己的逻辑了。

具体代码如下:

首先在load方法里面进行交换,不要等到注册的时候才进行交换

+ (void)load{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [self lg_hookOrigInstanceMenthod:NSSelectorFromString(@"dealloc") newInstanceMenthod:@selector(myDealloc)];
    });
}

+ (BOOL)lg_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)myDealloc{
    ✅// 从关联对象中删除现有的keyPtah的Model
     NSMutableArray *observerArr = objc_getAssociatedObject(self, (__bridge const void * _Nonnull)(kLGKVOAssiociateKey));
    if (observerArr.count<=0) {
        return;
    }
    
    for (LGInfo *info in observerArr) {
        if ([info.keyPath isEqualToString:keyPath]) {
            [observerArr removeObject:info];
            objc_setAssociatedObject(self, (__bridge const void * _Nonnull)(kLGKVOAssiociateKey), observerArr, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
            break;
        }
    }
    
    if (observerArr.count<=0) {
        ✅// isa指回给父类
        Class superClass = [self class];
        object_setClass(self, superClass);
    }
    [self myDealloc];
}
复制代码

至此,一个简单的KVO我们已经实现好了。但是这个并不完美,因为没有考虑到多线程等情况。大家可以查看脸书的`FBKVOController`开源框架等进行加深学习

总结

KVO本质

  1. 当我们给对象注册一个观察者添加了KVO监听时,系统会修改这个对象的isa指针指向
  2. 在运行时,动态创建一个新的子类,NSKVONotifying_XXX类,将对象的isa指针指向这个子类
  3. 来重写原来类的set方法;set方法实现内部会顺序调用willChangeValueForKey方法、原来的setter方法实现、didChangeValueForKey方法,而didChangeValueForKey方法内部又会调用监听器的observeValueForKeyPath:ofObject:change:context:监听方法
  4. 重写class方法,对中间类进行伪装,返回原来类的class
  5. 在观察者销毁时,isa指回原来的类
  6. 观察者销毁后不删除中间类

参考资料

KeyValueObserving 官方文档

iOS底层原理探索—KVO的本质

iOS 底层探索 - KVO