iOS 中的 KVO

一、KVO 的定义

KVO,也就是 Key-Value Observing,字面意思也就是键-值观察。在苹果的官方文档里面,对于 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.

我们可以看到 KVO 的实现使用 isa-swizzling 这个技术实现的,那么说明底层的实现是使用 runtime,后面我们在分析原理的时候会详细讲解这一部分。

二、KVO 的使用

1、基本使用

对于 KVO 的基本使用,一本如下

@interface Person : NSObject

@property (nonatomic, copy) NSString *name;

@end

...

- (void)viewDidLoad {
    
    _p = [[Person alloc] init];
    
    [_p addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:nil];
    
}

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

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    
    _p.name = @"mm";
}

- (void)dealloc {
    [self removeObserver:self forKeyPath:@"name"];
}

如上,我们有一个 Person 类,里面有一个 name 属性,我们在 VC 里面创建 Person 对象并且添加观察,然后在 - (void)dealloc 的时候再移除这个观察,点击屏幕改变 name,打印如下

2020-03-01 18:14:14.951348+0800 OC_test[5259:331033] {
    kind = 1;
    new = mm;
}

可以看到我们观察到了 name 属性的变化,需要注意的是我们在 dealloc 的时候需要移除这个观察,有几个就移除几个,如果我有一个观察,在 dealloc 时候移除两次,那么就会造成崩溃。

如上面例子中的 self 对 p 这个对象是强引用(strong),那么给 p 添加观察者 self,p 对 self 就不是强引用了(强引用了不就造成循环引用了嘛,苹果这么聪明肯定不会这么设计的),所以在 self 消失的时候(dealloc)我们需要将自己从 p 的观察者中移除掉。否则就会造成 p 继续向 self 的 observeValueForKeyPath: ofObject: change:context: 方法发送消息,而 self 已经释放了,造成 crash。

对于 addObserver: forKeyPath:options: context: 这个方法中的 options 这个选项我们可以看到四个枚举值,设置不同的 options 监听到的结果都不同,具体如下

typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
    //接受新值,收到监听后 change 里面会有一个 new
    NSKeyValueObservingOptionNew
    //接收旧值,收到监听后 change 里面会有一个 old
    NSKeyValueObservingOptionOld
    //在添加监听的时候(也就是调用 `addObserver: forKeyPath:options: context:` 这个方法时会接收到一次回调),在值改变时也会接收到回调
    NSKeyValueObservingOptionInitial 
    //在值改变之前和之后都会收到回调,也就是改变值之后会收到两次回调
    NSKeyValueObservingOptionPrior 
};

2、触发方式

KVO 默认的是自动触发的,但是有时候我们改变了对象的一个值,并不想收到通知,那么该怎么办呢?我们可以在 NSObject(NSKeyValueObservingCustomization) 里面看到 + (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key 这个方法,这个方法默认返回为 YES,也就是自动触发 KVO,我们可以在子类中重写这个方法,如下

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    return NO;
}

当我们在我们之前的 Person 类中重写了这个方法以后,重新运行项目点击屏幕,发现没有接收到值改变的信息,这是因为因为我们把触发模式改成了手动触发。如果 automaticallyNotifiesObserversForKey 设置为 NO,此刻仍然想收到通知,我们只有手动触发了,代码如下

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    
    [_p willChangeValueForKey:@"name"];
    _p.name = @"mm";
    [_p didChangeValueForKey:@"name"];
}

这样我们点击屏幕就可以重新收到消息了。下面我们来思考一个问题,我们把 _p.name = @"mm"; 这行代码去掉,点击屏幕还会不会触发 KVO?如下

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    
    [_p willChangeValueForKey:@"name"];
//    _p.name = @"mm";
    [_p didChangeValueForKey:@"name"];
}

测试以后我们发现仍然会收到通知,这说明 KVO 的触发与属性有没有赋值没有关系,与 willChangeValueForKeydidChangeValueForKey 这两个方法的调用有关系。
不过我们手动触发的时候一般不直接全部返回 NO,我们一般自己过滤一下,如下

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

3、属性依赖

现在我们新建一个 Man 类,里面有 age、address 两个属性,然后在 Person 里面创建一个 man 属性,如下

@interface Person : NSObject

@property (nonatomic, copy) NSString *name;
@property (nonatomic, strong) Man *man;

@end

...

@interface Man : NSObject

@property (nonatomic, assign) NSInteger age;
@property (nonatomic, copy) NSString *address;

@end

我们重写 Person 的 init 方法

- (instancetype)init {
    if (self == [super init]) {
        _man = [[Man alloc] init];
    }
    return self;
}

如果我想观察 person 的 man 的 age 属性,如下

[_p addObserver:self forKeyPath:@"man.age" options:NSKeyValueObservingOptionNew context:nil];

如果我想同时观察 age 和 address 属性呢,那么我就这样

[_p addObserver:self forKeyPath:@"man.age" options:NSKeyValueObservingOptionNew context:nil];
[_p addObserver:self forKeyPath:@"man.address" options:NSKeyValueObservingOptionNew context:nil];

那么有的童鞋就有疑问了,如果同时观察多了属性,这样写是不是就很不优雅,有没有一种简洁优雅的写法可以同时观察多个属性,答案是有的,如下

+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
    NSSet *keyPath = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"man"]) {
        keyPath = [NSSet setWithObjects:@"_man.age", @"_man.address", nil];
    }
    return keyPath;
}

+ (NSSet<NSString *> *)keyPathsForValuesAffectingMan {
    return [NSSet setWithObjects:@"_man.age", @"_man.address", nil];
}

只监听 man 属性就可以收到 age 和 address 的改变值,结果如下

上面这两种方式都可以实现只观察 man 属性,就可以监听到 age 和 address 的变化,这就是属性依赖,如果 Person 还有有 name 和 firstName、lastName 三个属性,想 name 改变就监听到 firstName、lastName 改变,可以如下

+ (NSSet<NSString *> *)keyPathsForValuesAffectingName {
    return [NSSet setWithObjects:@"firstName", @"lastName", nil];
}

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

三、KVO 的原理

为了探究 KVO 的原理,我们来做一个实验,我们在添加监听的时候打个断点,如下

kvo-break-point.png

此时我们去打印一下 _p 的 isa 指针,然后进行下一步,在打印 isa,会发现如下

我们发现在给 p 对象添加监听以后,其 isa 指针发生了变化,由原来指向的 Person 变成了 NSKVONotifying_Person,那么这个 NSKVONotifying_Person 又是个东西呢?为什么会发生这种变化?

这是因为在给 p 对象添加监听以后,runtime 会动态的创建一个叫 NSKVONotifying_Person 的类,该类继承于 Person,此时将 _p 的 isa 指针改变指向 NSKVONotifying_Person,然后调用 NSKVONotifying_Person 中重写的 setName: 方法,setName: 方法调用 Foundation 框架的 _NSSetObjectValueAndNotify 方法,然后 _NSSetObjectValueAndNotify 方法内部的实现是依次调用 willChangeValueForKey、父类的 setName: 方法、didChangeValueForKey 方法,最后调用 observeValueForKeyPath:ofObject:change:context: 方法完成通知流程,这就是 KVO 的原理,流程大致如下

#import "NSKVONotifying_Person.h"

...

//isa 指向 NSKVONotifying_Person,调用子类 NSKVONotifying_Person 的 setter 方法
- (void)setName:(NSString *)name {
    // setter 方法调用 Foundation 的 c 函数,设置的值不同调用的函数不同,比如还有 _NSSetBoolValueAndNotify、_NSSetFloatValueAndNotify 等(可以找到 Foundation 用 nm Foundation | grep ValueAndNotify 命令查看)
    _NSSetObjectValueAndNotify();
}

void _NSSetObjectValueAndNotify() {
    //依次调用
    [self willChangeValueForKey:@"name"];
    //这儿调用父类的 setter 方法
    [super setName:name];
    [self didChangeValueForKey:@"name"];
}

- (void)didChangeValueForKey:(NSString *)key {
    //通知观察者属性改变
    [observer observeValueForKeyPath:key ofObject:self change:nil context:nil];
}

通过打印消息,我们可以简单验证一下


kvo-fundation-imp.png

需要注意的是如果我们创建了 NSKVONotifying_Person 这个子类,然后再去添加监听,会出现以下错误

2020-03-02 16:15:50.328074+0800 OC_test[16550:163923] [general] KVO failed to allocate class pair for name NSKVONotifying_Person, automatic key-value observing will not work for this class

说是 KVO 创建 NSKVONotifying_Person 失败,KVO 不会生效(记得之前都是crash,然后说已存在 NSKVONotifying_Person 这个类,估计现在改进了)。

四、如何手动实现 KVO

上面我们知道了 KVO 的实现原理,下面我来来模拟实现一个 KVO,我新建一个 NSObject+MMKVO 的 category,代码如下

#import "NSObject+MMKVO.h"
#import <objc/message.h>

@implementation NSObject (MMKVO)

- (void)mm_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context {
    
    NSString *oldClassName = NSStringFromClass(self.class);
    NSString *newClassName = [NSString stringWithFormat:@"MMKVONotifying_%@", oldClassName];
    //1、创建一个类名为 MMKVONotifying_ 前缀的子类
    Class newClass = objc_allocateClassPair(self.class, newClassName.UTF8String, 0);
    //2、注册新类
    objc_registerClassPair(newClass);
    //3、重写子类的 setName: 方法,其实也就是给子类添加一个 setName: 方法(新的类继承于父类,但是其实子类中并没有父类的方法,我们平时能在子类中重写父类的方法其实也就是在子类中没查找到,最后查找到父类的方法)
    class_addMethod(newClass, @selector(setName:), (IMP)mm_setName, "v@:@");
    //4、修改 isa 指针
    object_setClass(self, newClass);
    //5、绑定 observer 到当前对象,以便后面通知给观察者
    objc_setAssociatedObject(self, @selector(setName:), observer, OBJC_ASSOCIATION_ASSIGN);
}

void mm_setName(id self, SEL _cmd, NSString *name) {
    
    //1、拿到当前类,也就是子类,因为前面修改了 isa 指针指向子类
    Class class = [self class];
    //2、修改 isa 指向父类
    object_setClass(self, class_getSuperclass(class));
    //3、父类调用 setName: (这里需要做个类型强转, 否则会报too many argument的错误)
    ((void (*)(id, SEL, id))objc_msgSend)(self, @selector(setName:), name);
    //4、拿到观察者,发送通知
    id observer = objc_getAssociatedObject(self, @selector(setName:));
    if (observer) {
        ((void (*)(id, SEL, id, id, id, id))objc_msgSend)(observer,
                                                          @selector(observeValueForKeyPath:ofObject:change:context:),
                                                          @"name", name,
                                                          @{@"new": name, @"kind": @1},
                                                          nil);
    }
    //5、把 isa 改回来
    object_setClass(self, class);
    
    /**
     上面的2、3、5 步也可以直接用
     ((void (*)(id, SEL, id))objc_msgSendSuper)(class, @selector(setName:), name);
     方法,这样就不用把 isa 改来改去
     */
}

@end

里面的的过程我都有注释,当然只是模拟实现,有很多细节性问题这里不多赘述。如果需要验证整个过程,你可在 iOS-Knowledge-Example-Code 中找到 KVO-Manual 查看源码。里面具体的 runtime 相关内容我们会放在 runtime 章节具体讲解。

五、KVO 监听容器类的变化

现在我们在 Person 类中添加一个 array 属性,并重写 init 初始化 array

@interface Person : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, strong) NSMutableArray *array;
@end

...

@implementation Person

- (instancetype)init {
    if (self == [super init]) {
        _array = @[].mutableCopy;
    }
    return self;
}
@end

现在我们给 p 的 array 属性添加监听,并且点击屏幕改变 array

[_p addObserver:self forKeyPath:@"array" options:NSKeyValueObservingOptionNew context:nil];

...

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {

    [_p.array addObject:@"1"];
}

我们发现并没有收到监听,我们修改一下代码,如下

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {

    NSMutableArray *tempArray = [_p mutableArrayValueForKey:@"array"];
    [tempArray addObject:@"1"];
}
kvo-observe-array-1.png

此时发现监听到了 array 的变化,那么原因是什么呢?我们打断点调试发现

kvo-observe-array-2.png

往下走一步

kvo-observe-array-3.png

tempArray 变成了 NSKeyValueNotifyingMutableArray 这个类型,同理上面的对象类型,我们可以猜测应该也是重写了子类的方法然后调用 willChangeValueForKeydidChangeValueForKey

最后,关于 KVO 的内容基本就到这里了,你有可能会感觉 KVO 这么繁琐,我得 add、remove 一对操作就为了监听一个属性,有没有更加简便的方式呢?答案是有的,有情趣的童鞋可以去了解下 ReactiveCocoa,看看它是如何优雅的实现监听的。

更多文章请查看 iOS-Knowledge ,欢迎 star !
)

Reference:

Key-Value Observing Implementation Details

class_addMethod

Objective-C Runtime Programming Guide:Type Encodings

iOS底层原理总结 - 探寻KVO本质

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