首页 >iOS开发

透彻理解 KVO 观察者模式(附基于runtime实现代码)

2018-04-11 11:22 编辑: yyuuzhu 分类:iOS开发 来源:indulge_in

作者的话:空谈原理不如动手实现,猜测苹果工程师的设计思路,实在有趣。

推荐另一篇文章:透彻理解 NSNotificationCenter 通知(含实现代码)

前言

iOS开发中,有一种设计模式应用广泛,那就是观察者模式。苹果称其为 KVO(Key-Value Observing),既键值观察,总是有人把 KVC 和 KVO 混为一谈,实则它们只是名字长得像。相信看完本篇博客,并且看明白 github 中本人对其的代码实现,可以把 KVO 理解得更深刻。

KVO基于runtime实现代码

当然要实现它,我们就得先了解它,所以接下来会讲解 KVO 的用法以及简单原理。

一、KVO 用法

虽然用法很基础,还是简单提一下。

添加观察

[_obj addObserver:self forKeyPath:@"aName" options:NSKeyValueObservingOptionOld|NSKeyValueObservingOptionNew context:(__bridge void *)(self)];
  1. 方法的调用者(就是这个_obj)就是被观察的对象。

  2. observer参数是回调的接收者。

  3. keyPath是一个寻找路径,最终落脚点是一个有效的可观察属性。

  4. options有几个配置回调可选项,NSKeyValueObservingOptionOld表示获取旧值,NSKeyValueObservingOptionNew表示获取新值,NSKeyValueObservingOptionInitial表示在添加观察的时候就立马响应一个回调,NSKeyValueObservingOptionPrior表示在被观察属性变化前后都回调一次。

  5. context是一个指针,用来准确匹配添加观察和接收观察,主要是在特定情况下无法区分该观察回调是否需要处理进行精确判断。

获取回调

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    if ((__bridge id)context == self) {
        NSLog(@"keyPath: %@, object: %@, change: %@, context: %@", keyPath, object, change, context);
    }
}

变换一个属性的值,打印如下:

keyPath: aName, object: , change: {
kind = 1;
new = jack;
old = "";
}, context: 

change字典里面的new和old就是我们需要的值了,kind是关键路径属性的类型标识,具体可以去看api。

移除观察

[_obj removeObserver:self forKeyPath:@"aName"];

移除观察很简单,和移除通知比较类似,我们需要在不用继续观察的时候移除它,比如在控制器的dealloc方法里面释放,值得注意的是重复移除会 crash。

KVO 设计的槽点

其实作为开发者,大家应该经常听到对 KVO 的吐槽:

  1. 回调方式单一。

  2. keypath设计容易写错。

  3. KVO 的回调有一个传递链,子类若不调用父类方法,传递链会中断,这个设计感觉有些繁琐。

  4. 多次移除同一 KVO 会 crash。

    ......

二、KVO 基本原理

苹果官方对 KVO 的讲解很少,很多大牛对 KVO 做了深入的探究,比如 Mike Ash 的一篇博客

大致原理描述:

KVO 是基于 runtime 运行时来实现的,当你观察了某个对象的属性,内部会生成一个该对象所属类的子类,然后从写被观察属性的setter方法,当然在重写的方法中会调用父类的setter方法从而不会影响框架使用者的逻辑,之后会将该对象的isa指针指向新创建的这个类,最后会重写-(Class)class;方法,让使用者通过[obj class]查看当前对象所属类的时候会返回其父类,达到移花接木的目的。

好了,原理不难,下面通过一小段代码测试一下:

    NSLog(@"class-withOutKVO: %@ \n", object_getClass(_obj));
    NSLog(@"setterAdress-withOutKVO: %p \n", [_obj methodForSelector:@selector(setAName:)])
    [_obj addObserver:self forKeyPath:@"aName" options:NSKeyValueObservingOptionOld|NSKeyValueObservingOptionNew context:(__bridge void *)(self)];
    NSLog(@"class-addKVO: %@ \n", object_getClass(_obj));
    NSLog(@"setterAdress-addKVO: %p \n", [_obj methodForSelector:@selector(setAName:)])
    [_obj removeObserver:self forKeyPath:@"aName"];
    NSLog(@"class-removeKVO: %@", object_getClass(_obj));
    NSLog(@"setterAdress-removeKVO: %p \n", [_obj methodForSelector:@selector(setAName:)])

打印如下

class-withOutKVO: TestObj

setterAdress-withOutKVO: 0x10e819030

class-addKVO: NSKVONotifying_TestObj

setterAdress-addKVO: 0x10f050efe

class-removeKVO: TestObj

setterAdress-removeKVO: 0x10e819030

看到了么,我们使用object_getClass ()方法成功躲开了 KVO 的障眼法,发现添加观察过后,_obj的类变成了NSKVONotifying_TestObj,在移除观察过后,_obj的类又变回TestObj。同时,我们还观察了setAName:方法的地址,发现同样是有变化,同样验证了重写setter方法的逻辑。

通过这一块小代码,就基本可以验证以上的原理(当然更详细的分析可看Mike Ash大神的那篇文章)。

三、代码实现

KVO基于runtime实现代码

通过前面两节的分析,相信都会有一点思路了。本人对其的实现尽量符合原生的逻辑,但是出于各种原因,有些地方是不一样的,比如我没有采取使用响应链的方式回调,而是单个回调保证不会有依赖;对于非 id 类型的观察源码里面没有做处理,因为感觉太累了,偷个懒??。不过这些都不是重点,核心逻辑才是重点。

首先,我同样是仿照系统方法写了一个分类:

typedef NS_OPTIONS(NSUInteger, YB_NSKeyValueObservingOptions) {
    YB_NSKeyValueObservingOptionNew = 0x01,
    YB_NSKeyValueObservingOptionOld = 0x02,
    YB_NSKeyValueObservingOptionInitial = 0x04,
    YB_NSKeyValueObservingOptionPrior = 0x08
};
@interface NSObject (YB_KVO)
- (void)yb_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(YB_NSKeyValueObservingOptions)options context:(nullable void *)context;
- (void)yb_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context;
- (void)yb_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
- (void)yb_observeValueForKeyPath:(nullable NSString *)keyPath ofObject:(nullable id)object change:(nullable NSDictionary*)change context:(nullable void *)context;
@end

嗯,方法很熟悉,只是加了一个前缀,对于最后这个回调的方法,分类里面肯定是不会实现的,为了去除警告,在实现文件的这个地方加了一个忽略操作:

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wincomplete-implementation"
@implementation NSObject (YB_KVO)
#pragma clang diagnostic pop

然后第一步,就是找到需要监听的对象及其属性:

- (void)yb_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(YB_NSKeyValueObservingOptions)options context:(void *)context {
    if (!observer || !keyPath) return;
    @synchronized(self){
        //给 keyPath 链条最终类做逻辑
        NSArray *keyArr = [keyPath componentsSeparatedByString:@"."];
        if (keyArr.count <= 0) return;
        id nextTarget = self;
        for (int i = 0; i < keyArr.count-1; i++) {
            nextTarget = [nextTarget valueForKey:keyArr[i]];
        }
        if (![self yb_coreLogicWithTarget:nextTarget getterName:keyArr.lastObject]) {
            return;
        }
        //给目标类绑定信息
        YbKVOInfoModel *info = [YbKVOInfoModel new];
        info.target = self;
        info.observer = observer;
        info.keyPath = keyPath;
        info.options = options;
        [info setContext:context];
        [self yb_bindInfoToTarget:nextTarget info:info key:keyArr.lastObject options:options];
    }
}

这一步做了两件事,一是找到最终观察的对象及其属性,这和苹果 KVO 的做法不太一样,如果你做个试验就会知道,苹果的实现会将 keypath 所有涉及的对象都更换一个动态实现的子类,当然,我这里这么做一是为了减少复杂的判断,也是为了更清晰的实现 KVO;二是将回调需要的信息绑定在观察的对象上,当然,这里的数据结构实现上有点小复杂。

{"getter0":[info0, info1, info2...], "getter1":..}

具体的做法可以去看 github 里面的代码,太多了不方便贴出来。

核心逻辑

接下来就是核心逻辑了:

- (BOOL)yb_coreLogicWithTarget:(id)target getterName:(NSString *)getterName {
    //若 setter 不存在
    NSString *setterName = setterNameFromGetterName(getterName);
    SEL setterSel = NSSelectorFromString(setterName);
    Method setterMethod = class_getInstanceMethod(object_getClass(target), setterSel);
    if (!setterMethod) return NO;
    
    //创建派生类并且更改 isa 指针
    [self yb_creatSubClassWithTarget:target];
    
    //给派生类添加 setter 方法体
    if (!classHasSel(object_getClass(target), setterSel)) {
        const char *types = method_getTypeEncoding(setterMethod);
        return class_addMethod(object_getClass(target), setterSel, (IMP)yb_kvo_setter, types);
    }
    return YES;
}
- (void)yb_creatSubClassWithTarget:(id)target {
    //若 isa 指向是否已经是派生类
    Class nowClass = object_getClass(target);
    NSString *nowClass_name = NSStringFromClass(nowClass);
    if ([nowClass_name hasPrefix:kPrefixOfYBKVO]) {
        return;
    }
    //若派生类存在
    NSString *subClass_name = [kPrefixOfYBKVO stringByAppendingString:nowClass_name];
    Class subClass = NSClassFromString(subClass_name);
    if (subClass) {
        //将该对象 isa 指针指向派生类
        object_setClass(target, subClass);
        return;
    }
    
    //添加派生类,并且给派生类添加 class 方法体
    subClass = objc_allocateClassPair(nowClass, subClass_name.UTF8String, 0);
    const char *types = method_getTypeEncoding(class_getInstanceMethod(nowClass, @selector(class)));
    IMP class_imp = imp_implementationWithBlock(^Class(id target){
        return class_getSuperclass(object_getClass(target));
    });
    class_addMethod(subClass, @selector(class), class_imp, types);
    objc_registerClassPair(subClass);
    //将该对象 isa 指针指向派生类
    object_setClass(target, subClass);
}

其实了解 runtime 底层方法的朋友应该看起来比较轻松,不太会 runtime 的朋友可以搜索对应的方法了解其用法,当你熟悉一下之后发现并没有那么难。这里需要提出的是,给一个类添加方法有两种方式,一种是class_addMethod ()方法,一种是imp_implementationWithBlock ()block的方式。在生成派生类的时候,一定要判断是否当前对象 isa 指针已经指向了派生类了。若想看细看具体实现,还是建议下载demo。

当然,我这里三两句话说完,实际上写这一段核心代码花了不少时间,纠结了各种方法的含义过后,才逐步完善容错机制。

回调相关问题

接下来就是回调的情况了,在重写的setter里面逻辑是这样的:

static void yb_kvo_setter (id taget, SEL sel, id p0) {
    //拿到调用父类方法之前的值
    NSString *getterName = getterNameFromSetterName(NSStringFromSelector(sel));
    id old = [taget valueForKey:getterName];
    callBack(taget, nil, old, getterName, YES);
    
    //给父类发送消息
    struct objc_super sup = {
        .receiver = taget,
        .super_class = class_getSuperclass(object_getClass(taget))
    };
    ((void(*)(struct objc_super *, SEL, id)) objc_msgSendSuper)(&sup, sel, p0);
    
    //回调相关
    callBack(taget, p0, old, getterName, NO);
}

值得注意的是,objc_msgSendSuper方法调用现在必须要强转一下,objc_super是父类的一个结构体,receiver指向当前对象。在调用父类方法之前,使用KVC方式就可以拿到old值,在调用父类方法之后该值就会改变。

可能大家也注意到,这里有个callBack函数,这里就是回调的逻辑了,这个就不贴出来了,主要就是通过getter方法的名字拿到当前类的回调消息,然后同时根据options做相应的处理。

另外一些值得注意的地方

回调信息类是这样定义的:

@interface YbKVOInfoModel : NSObject {
    void *_context;
}
- (void)setContext:(void *)context;
- (void *)getContext;
@property (nonatomic, weak) id target;
@property (nonatomic, weak) id observer;
@property (nonatomic, copy) NSString *keyPath;
@property (nonatomic, assign) YB_NSKeyValueObservingOptions options;
@end
@implementation YbKVOInfoModel
- (void)dealloc {
    _context = NULL;
}
- (void)setContext:(void *)context {
    _context = context;
}
- (void *)getContext {
    return _context;
}
@end

注意变量_context是一个指针,特意写了两个方法来读写它,在dealloc中将其指针内存的值清除。

尾声

KVO 的原理看起来简单,实际上实现起来有一定的难度,特别是核心部分,需要使用一些观感不好的底层方法。还有就是对回调信息保存的数据结构,个人认为代码中的处理方式已经是效率比较高的了(当然这里主要考虑了时间复杂度)。

通过对KVO的实现,本人对其的理解更加深入了,这不失为一种学习方法,探究苹果工程师的设计思路,乐在其中,获益匪浅。

欢迎大家提出意见,一起交流。

KVO基于runtime实现代码

作者:indulge_in

链接:https://www.jianshu.com/p/7ea7d551fc69

搜索CocoaChina微信公众号:CocoaChina
微信扫一扫
订阅每日移动开发及APP推广热点资讯
公众号:
CocoaChina
我要投稿   收藏文章
上一篇:iOS 从实际出发理解多线程
下一篇:iOS面试招人分享
我来说两句
发表评论
您还没有登录!请登录注册
所有评论(0

综合评论

相关帖子

sina weixin mail 回到顶部