KVO使用与原理分析

面试题目

  1. iOS用什么方式实现对一个对象的KVO?(KVO的本质是什么?)
  2. 如何手动触发KVO

上面两道面试题目,都是在考察程序员对KVO的理解。KVO对于一个iOS程序员来讲并不陌生,在实际开发中我们或多或少都会使用到,在某些场景中,会让我们的开发变得更加简单。那么你了解KVO的本质吗?它是如何实现的呢?

KVO简介

在这里帮大家回忆一下KVO,对于熟悉的同学向下翻阅。

概述

NSKeyValueObserving或者 KVO,是一个非正式协议,它定义了对象之间观察和通知状态改变的通用机制的。KVO的中心思想其实是相当引人注意的。任意一个对象都可以订阅以便被通知到其他对象状态的改变。这个过程大部分是内建的,自动的,透明的。

KVO字面翻译就是键值对监听,允许对象监听另一个对象特定属性的改变,并在改变时接收到事件。一般继承自NSObject的对象都默认支持KVO

KVO可以监听单个属性的变化,也可以监听集合对象(NSArrayNSSet)的变化。

基础使用
  1. 添加观察者:通过addObserver:forKeyPath:options:context:方法注册观察者,观察者可以接收keyPath属性的变化事件。
  2. 回调:在观察者中实现observeValueForKeyPath:ofObject:change:context:方法,当keyPath属性发生改变后,KVO会回调这个方法来通知观察者。
  3. 当观察者不需要监听时,必须调用removeObserver:forKeyPath:方法将KVO移除。
注册观察者
/*
observer:注册KVO通知的对象。观察者必须实现 key-value observing 方法
observeValueForKeyPath:ofObject:change:context:。

keyPath:观察者的属性的 keypath,相对于接受者,值不能是 nil。

options: NSKeyValueObservingOptions 的组合,它指定了观察通知中包含了什
么,可以查看 "NSKeyValueObservingOptions"。

context:在 observeValueForKeyPath:ofObject:change:context: 传给observer
参数的随机数据
*/
- (void)addObserver:(NSObject *)observer 
         forKeyPath:(NSString *)keyPath 
            options:(NSKeyValueObservingOptions)options 
            context:(nullable void *)context;

options代表NSKeyValueObservingOptions的位掩码。

NSKeyValueObservingOptionNew = 0x01,  新值
NSKeyValueObservingOptionOld = 0x02,  旧值
NSKeyValueObservingOptionInitial = 0x04, 在注册观察者后,立即接收一次回调
NSKeyValueObservingOptionPrior = 0x08    会在变化前后收到两次回调

context它可以被用作区分那些绑定同一个keypath的不同对象的观察者。如何设置一个好的context?

static void *XXContext = &XXContext;

一个静态变量存着它自己的指针。在CocoaAsynSocket中也有类似的使用。

当然context还可以传入任意类型的对象,在接收消息回调的代码中可以接收到这个对象,是KVO中的一种传值方式。

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

这些参数跟我们指定的 –addObserver:forKeyPath:options:context: 是一样的,而change取决于哪个NSKeyValueObservingOptions选项被使用。

更好的KeyPath

传字符串做为keypath比直接使用属性更糟糕,因为任何错字或者拼写错误都不会被编译器察觉,最终导致不能正常工作。 一个聪明的解决方案是使用 NSStringFromSelector和一个@selector字面值。

NSStringFromSelector(@selector(isFinished))

因为@selector检查目标中的所有可用selector,这并不能阻止所有的错误,但它可用捕获大部分-包括捕获Xcode自动重构带来的改变。
那么在回调中可能是这样子写的:

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    if ([object isKindOfClass:[NSOperation class]]) {
        if ([keyPath isEqualToString:NSStringFromSelector(@selector(isFinished))]) {

        }
    } else if (...) {
        // ...
    }
}
取消注册

当一个观察者完成了监听一个对象的改变,需要调用removeObserver:forKeyPath:context:。它经常在observeValueForKeyPath:ofObject:change:context:,或者dealloc中被调用。

注意
  1. 在调用addObserver方法后,KVO并不会对观察者进行强引用,所以需要注意观察者的生命周期。
  2. KVOaddObserverremoveObserver必须成对出现,如果重复removeObserver则会导致Crash,如果在观察者释放前,忘记removeObserver,则会在再次接收到KVO回调时Crash
  3. 观察者必须实现key-value observing方法
    observeValueForKeyPath:ofObject:change:context:,否则会Crash
  4. KVO也有对应集合的实现,包括我们常用的NSArray,可以去KVO的头文件中查看,有对应的键值参数等。

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

KVO本质

我们可以翻看苹果文档

Key-Value Observing Implementation Details

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.

KVO是通过基于runtime技术实现的。当某个对象被观察时,系统的runtime运行时会动态的实现一个基于该类的一个中间类(派生类),在中间类中实现被观察基类属性的setter方法,中间类在重写的setter方法中实现真正的通知机制。

同时派生类还重写了- (Class)class;方法以“欺骗”外部调用者它就是起初的那个类。然后系统将被观察对象的isa指针指向这个新诞生的派生类,因此这个对象就成为该派生类的对象了,因而在该对象上对被观察属性setter的调用就会调用重写的 setter,从而激活键值通知机制。此外,派生类还重写了dealloc方法来释放资源。

通过代码验证一波:

/* 被观察对象模型 */
@interface KVOObject : NSObject
@property (nonatomic, copy) NSString *objName;
@end
/* 在控制器的viewDidLoad中 */
- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.object_1 = [[KVOObject alloc] init];
    self.object_1.objName = @"object_1---前";

    [self isaPointerOfObject:self.object_1];

    NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | 
    NSKeyValueObservingOptionOld;
    [self.object_1 addObserver:self forKeyPath:@"objName" options:options
     context:kObserverContext];
    
    self.object_1.objName = @"object_1---后";

    [self isaPointerOfObject:self.object_1];
}
/* 打印被观察对象所属类... */
- (void)isaPointerOfObject:(KVOObject *)obj{
    
    Class objectMethodClass = [obj class];
    Class objectRuntimeClass = object_getClass(obj);
    Class superClass = class_getSuperclass(objectRuntimeClass);
    
    NSLog(@"%@-objectMethodClass:%@",obj.objName,objectMethodClass);
    NSLog(@"%@-objectRuntimeClass:%@",obj.objName,objectRuntimeClass);
    NSLog(@"%@-superClass:%@",obj.objName,superClass);
}
控制台打印_1

通过控制台打印的内容对比,对象被KVO后,其真正类型变为了NSKVONotifying_KVOObject类,已经不是之前的类,其实是将被观察对象的isa从指向的KVOObject的class,改为系统“偷偷”帮我们创建的NSKVONotifying_KVOObject类的class。新类是原类的子类,命名规则是NSKVONotifying_xxx的格式。

同时我们发现- (Class)class;在注册观察者前后,结果却是一致的。这其实是苹果对我们的一种“欺骗”,在中间类中重写了- (Class)class方法。

/* 打印查看方法*/
- (void)methodListOfObject:(KVOObject *)obj
{
    Class objectRuntimeClass = object_getClass(obj);
    
    unsigned int count;
    Method *methodList = class_copyMethodList(objectRuntimeClass, &count);
    for (NSInteger i = 0; i < count; i++) {
        Method method = methodList[i];
        NSString *methodName = NSStringFromSelector(method_getName(method));
        NSLog(@"%@-method Name = %@\n",obj.objName,methodName);
    }
    free(methodList);
}
控制台打印_2

在新创建的中间类中,重写了setter:,dealloc,class方法,添加新的方法_isKVOAdealloc推测在添加观察者时,增加了一些依赖,需要在对象释放时销毁。_isKVOA方法,这个方法可以当做使用了KVO的一个标记,系统可能也是这么用的。如果我们想判断当前类是否是KVO动态生成的类,就可以从方法列表中搜索这个方法。

那么系统重写了setter:后是如何实现通知的呢?由于苹果API这部分没有开源,但是通过函数调用栈,我们推测一二。

/* 在KVOObject的.m文件中...*/
@implementation KVOObject

// 是否允许通知..
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key
{
    NSLog(@"automaticallyNotifiesObserversForKey:");
    return YES;
}

// 重写setter
- (void)setObjName:(NSString *)objName
{
    NSLog(@"setter方法,属性发生修改 - 前");
    _objName = objName;
    NSLog(@"setter方法,属性发生修改 - 后");
}

// 重写willChangeValueForKey
- (void)willChangeValueForKey:(NSString *)key
{
    [super willChangeValueForKey:key];
    NSLog(@"willChangeValueForKey");
}

// 重写didChangeValueForKey
- (void)didChangeValueForKey:(NSString *)key
{
    NSLog(@"didChangeValueForKey - 前");
    [super didChangeValueForKey:key];
    NSLog(@"didChangeValueForKey - 后");
}

@end

控制台打印结果:

控制台_3.png

函数调用栈:


调用栈_4.png
调用栈_5.png

控制台打印结果和函数调用栈推测:重写的setter:内部调用了_NSSetObjectValueAndNotify的方法(这是一个系列的方法,还有_NSSetIntValueAndNotify_NSSetDoubleValueAndNotify等)。

在这个明显C风格命名的方法内部,

  1. 先调用了willChangeValueForKey:
  2. 然后调用父类的-setter:方法,对值进行修改
  3. 修改完后再调用didChangeValueForKey:方法
  4. didChangeValueForKey:的内部最终调用了NSKeyValueNotifyObserver方法,通知属性的观察者,观察者收到了值修改的信息
  5. didChangeValueForKey:调用完毕

至此,在能力范围内的对KVO内部的分析已经完毕。相信看到这里的同学对 iOS用什么方式实现对一个对象的KVO?(KVO的本质是什么?)已经可以回答出来了。

如何手动触发KVO怎么弄...
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key

控制是否自动发送通知,如果返回NOKVO无法自动运作,需手动触发。

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key
{
    if ([key isEqualToString:@"objName"]) {
        return NO;
    }
    return YES;
}

- (void)setObjName:(NSString *)objName
{
    if (<#条件判断##>){
        [self willChangeValueForKey:@"objName"];
    }
    _objName = objName;
    if (<#条件判断##>) {
        [self didChangeValueForKey:@"objName"];
    }
}

比如我们想对objName属性手动出发KVO,就在+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key中对objName返回NO,并在属性-setter方法中,手动调用willChangeValueForKeydidChangeValueForKey必须两者都调用),这样就可以实现手动来出发KVO了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,099评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,828评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,540评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,848评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,971评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,132评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,193评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,934评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,376评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,687评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,846评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,537评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,175评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,887评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,134评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,674评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,741评论 2 351

推荐阅读更多精彩内容