Objective-C KVO总结

看本章之前建议先打好OC对象的基础:深入理解OC中的对象

大纲

  • 什么是KVO
  • 场景代码示例 (后面的分析都是基于示例代码展开)
  • NSKVONotifying_XXX 类对象
  • _NSSetXXXValueAndNotify 方法
  • 自定义手动触发KVO
  • KVO总结
  • 拓展补充

1. 什么是KVO

KVO的全称是Key-Value Observing,俗称“键值监听”。可以用于监听某个对象属性值的改变。

  • KVO使用:
    1. 确定需要监听的对象的属性。
    2. 确定监听者,并注册监听回调。
    3. 在监听回调中处理业务逻辑。
KVO流程图

2. 场景代码示例

定义一个类,申明一个count属性

@interface KVOTest : NSObject
@property (nonatomic, assign) NSInteger count;
@end

@implementation KVOTest
@end

viewcontroller中创建对象,注册属性监听。点击屏幕的时候改变count值

static void *countContext = &countContext;

@interface ViewController ()
@property (nonatomic, strong) KVOTest   *kvo;
@end

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    self.kvo = [[KVOTest alloc] init];
    self.kvo.count = 1;
    // 注册监听
    [self.kvo addObserver:self
               forKeyPath:@"count"
                  options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
                  context:countContext];
}

// 监听回调
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if (context == countContext)
    {
        NSInteger newCount = [[change valueForKey:NSKeyValueChangeNewKey] integerValue];
        NSInteger oldCount = [[change valueForKey:NSKeyValueChangeOldKey] integerValue];
        NSLog(@"new: %ld, old: %ld", newCount, oldCount);
    }
}

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    // 点击屏幕,改变count的值
    self.kvo.count += 1;
}
@end

点击屏幕的时候修改count的值将会触发监听回调,控制台打印如下:



上面就是个最简单的KVO使用的场景示例了。

3. NSKVONotifying

我们回到上面的示例代码中添加如下代码:输出注册监听前后类对象类对象对应的地址

我们可以先猜测一下控制台的log信息,如果你只是停留在单纯使用KVO的阶段,应该会和我当初猜想的一致。注册监听前后log的“类对象”和“地址”应该都是一样的。而实际的结果是这样的:
是不是很惊讶,我们创建的KVOTest实例对象的属性被注册了KVO监听后,苹果底层其实偷偷的做了修改,使用runtime运行时动态的创建了一个新的类NSKVONotifying_KVOTest.通过对比类对象的地址0x102ac2a980x600001f8cb40也可以确认的确不是同一个类。大致流程如下:

  1. 我们先验证属性监听isa的变化:
  • 监听后isa的确发生了变化,所以类对象的确发生了改变。
  1. 验证NSKVONotifying_KVOTest的父类是否是KVOTest
struct cw_objc_class {
    Class _Nonnull isa;
    Class _Nullable super_class;
};

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.kvo = [[KVOTest alloc] init];
    self.kvo.count = 1;
    // 监听之前的类对象
    Class classObj = object_getClass(self.kvo); 
    [self.kvo addObserver:self
               forKeyPath:@"count"
                  options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
                  context:countContext];
     // 监听之后的
    Class kvo_notify_class = object_getClass(self.kvo);
    // 强转一下类型
    struct cw_objc_class *newClass = (__bridge struct cw_objc_class *)kvo_notify_class;
    NSLog(@"KVOTest类对象: %@ - %p", classObj, classObj);
    NSLog(@"NSKVONotifying_KVOTest类对象: %@ - %p", kvo_notify_class, kvo_notify_class);
    NSLog(@"NSKVONotifying_KVOTest类对象的父类: %@ - %p", newClass->super_class, newClass->super_class);
}

控制台log如下:

KVOTest类对象: KVOTest - 0x109ca8ab8
NSKVONotifying_KVOTest类对象: NSKVONotifying_KVOTest - 0x600001edc090
NSKVONotifying_KVOTest类对象的父类: KVOTest - 0x109ca8ab8

NSKVONotifying_KVOTest类对象的父类地址和KVOTest类对象地址一致。

补充说明:

struct cw_objc_class {
   Class _Nonnull isa;
  Class _Nullable super_class;
};

struct cw_objc_class *newClass = (__bridge struct cw_objc_class *)kvo_notify_class

Class类型的superclass指针是隐藏属性在外部不能访问,因为我们知道了Class其实也是结构体,并且前两个指针的类型:一个是isa,一个是superclass,所以这里我们可以自定义一个类似结构体,强转一下类型。这样我们就能拿到superclass了。

4. _NSSetXXXValueAndNotify

当属性发生改变时,我们需要通知到observer触发回调。之前的setter方法是满足不了需求的。所以底层继承原来的类,动态的创建了一个子类NSKVONotifying_XXX来重写setter方法,做了一些额外的操作。我们可以通过一段代码来验证:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.kvo = [[KVOTest alloc] init];
    self.kvo.count = 1;
    NSLog(@"监听之前的setter方法: %p", [self.kvo methodForSelector:@selector(setCount:)]);
    [self.kvo addObserver:self
               forKeyPath:@"count"
                  options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
                  context:countContext];
    NSLog(@"监听之后的setter方法: %p", [self.kvo methodForSelector:@selector(setCount:)]);
}

控制台log如下:

OCTest[8254:266577] 监听之前的setter方法: 0x105e94f70
OCTest[8254:266577] 监听之后的setter方法: 0x7fff2591f5cd

监听前后的方法地址0x105e94f700x7fff2591f5cd不同,证实了setter方法的确被修改了。进一步断点调试:

  • 注册监听前的setter方法调用的是setCount毫无悬念。
  • 注册监听后的setter方法如上图,内部其实调用的是Foundation框架下的_NSSetLongLongValueAndNotify函数。

关于_NSSetLongLongValueAndNotify函数,看字面意思就不难理解:setValue即设置新值,notify即通知触发observer回调。

至于LongLong这是和属性的类型有关的。因为文中示例的countNSInteger类型,这里我们可以修改成NSString类型来验证下是否会发生变化.

@interface KVOTest : NSObject
@property (nonatomic, copy) NSString *count;
@end

断点调试:

(lldb) po (IMP)0x7fff2591e98b
(Foundation`_NSSetObjectValueAndNotify)

改成NSString类型后,函数变成了_NSSetObjectValueAndNotify。有兴趣的话可以多改改试试,这里不再赘述。如果你懂逆向的话是可以找到Foundation框架下所有的这种方法。
_NSSetValueAndNotify内部实现在接下来一节手动触发KVO会提到。

5. 手动触发KVO

我们给类KVOTest定义一个成员变量age

@interface KVOTest : NSObject {
    @public NSInteger _age;
}
@end

外部在ViewController中监听age, 点击屏幕的时候修改age的值

- (void)viewDidLoad {
    [super viewDidLoad];
    self.kvo = [[KVOTest alloc] init];
    self.kvo->_age = 1;
    [self.kvo addObserver:self
               forKeyPath:@"age"
                  options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
                  context:countContext];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if (context == countContext)
    {
        NSInteger newCount = [[change valueForKey:NSKeyValueChangeNewKey] integerValue];
        NSInteger oldCount = [[change valueForKey:NSKeyValueChangeOldKey] integerValue];
        NSLog(@"new: %ld, old: %ld", newCount, oldCount);
    }
}

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    self.kvo->_age += 1;
}

定义成员变量是不会像@property关键字那样自动合成setter,getter方法的。这里的结果就是直接修改变量,并不能触发KVO机制。所以点击屏幕,回调是不会被触发的。直接修改变量值,不会触发KVO监听回调

接下来我们手动创建一个setter方法试试:

// .h文件
- (void)setAge:(NSInteger)age;

// .m文件
- (void)setAge:(NSInteger)age {
    _age = age;
}

viewController中调用setter方法

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.kvo setAge:18];
}

点击屏幕,监听回调触发


手动实现setter方法就触发了回调(即使我们将setter方法里面的赋值代码注释掉),进一步验证了上文中提到的子类NSKVONotifying重写setter方法的验证。

如果我们不按Apple的命名规范,自己定义一个setter方法会如何呢?

// .h文件
- (void)setNewAge:(NSInteger)age;

// .m文件
- (void)setNewAge:(NSInteger)age {
    _age = age;
}

viewController中改成自定义的方法:

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    [self.kvo setNewAge:18];
}

结果发现这样并不能触发KVO监听回调。

接下来我们改造一下自定义setter方法,添加一对方法willChangeValueForKeydidChangeValueForKey

- (void)setNewAge:(NSInteger)age {
    [self willChangeValueForKey:@"age"];
    _age = age;
    [self didChangeValueForKey:@"age"];
}

此时运行项目,再次点击屏幕修改值,KVO回调会被触发。而这也就是我们手动触发KVO的方式。

6. 到这里我们可以总结一下KVO的流程:

  1. KVOTest类的instance对象注册属性监听后
  2. 系统通过runtime动态的创建了一个KVOTest的子类NSKVONotifying_KVOTest类,并将instance对象的isa指向它。
  3. 当属性发生改变时,调用NSKVONotifying_KVOTest的setter方法_NSSetXXXValueAndNotify
    内部实现:
    1. willChangeValueForKey
    2. 调用父类的setter赋值
    3. didChangeValueForKey
    4. 触发监听器observer的回调方法。

7. 拓展补充

我们可以写一个方法来获取NSKVONotifying_XXX中除了setter还有哪些方法

- (void)printMethodnamesOfClass:(Class)cls {
    unsigned int count;
    Method *methodList = class_copyMethodList(cls, &count);
    NSMutableString *methodNames = [NSMutableString string];
    for (int i = 0; i < count; i++) {
        Method method = methodList[i];
        NSString *methodName = NSStringFromSelector(method_getName(method));
        [methodNames appendString:methodName];
        [methodNames appendString:@", "];
    }
    free(methodList);
    NSLog(@"%@: %@", cls, methodNames);
}

调用方法

- (void)viewDidLoad {
    [super viewDidLoad];
    self.kvo = [[KVOTest alloc] init];
    [self.kvo addObserver:self
               forKeyPath:@"age"
                  options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
                  context:countContext];
    [self printMethodnamesOfClass:object_getClass(self.kvo)];
}

控制台log

NSKVONotifying_KVOTest: setAge:, class, dealloc, _isKVOA,
  1. 重写了class方法(这里返回的是父类KVOTest,这也是为什么做了监听操作之后调用[self.kvo class]和调用object_getClass(self.kvo)结果不同的原因),
  2. 重写了delloc方法(额外的做了一些操作,所以也需要一些额外释放销毁操作)
  3. 新增了一个_isKVOA (标识)
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,734评论 6 505
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,931评论 3 394
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,133评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,532评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,585评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,462评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,262评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,153评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,587评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,792评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,919评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,635评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,237评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,855评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,983评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,048评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,864评论 2 354