Objective-C 之 KVO 原理

键值观察提供了一种机制,该机制允许将其他对象的特定属性的更改通知给对象。对于应用程序中模型层和控制器层之间的通信特别有用。 (在OS X中,控制器层绑定技术在很大程度上依赖于键值观察。)控制器对象通常观察模型对象的属性,而视图对象通过控制器观察模型对象的属性。但是,此外,模型对象可能会观察其他模型对象(通常是确定从属值何时更改),甚至是自身(再次确定从属值何时更改)。
您可以观察到一些属性,包括简单属性,一对一关系和一对多关系。一对多关系的观察者被告知所做更改的类型,以及更改涉及哪些对象。

一、基本使用

1.1 注册观察者

/// 注册观察者
/// @param observer 观察者
/// @param keyPath 要观察的属性keyPath
/// @param options 观察者选项。影响通知的生成方式及回调时字典中携带的信息
/// @param context 上下文
- (void)addObserver:(NSObject *)observer
         forKeyPath:(NSString *)keyPath
            options:(NSKeyValueObservingOptions)options
            context:(nullable void *)context;

context接收一个void *类型的参数,基本可以传任何类型。假如子类和他的父类由于不同的原因都注册了对同一个属性的观察,在回调中这两种的处理是不同的,那么回调中的keyPath和被观察者对象是无法区分的,此时就可以通过context这个参数来区分。

1.2 实现回调

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary<NSKeyValueChangeKey,id> *)change
                       context:(void *)context

1.3 移除观察者

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(nullable void *)context;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

1.4观察集合类型属性

@interface Animal : NSObject


@property (nonatomic,strong) NSMutableArray *friends;

@end

//viewContoller代码
- (void)viewDidLoad {
    [super viewDidLoad];
    
    
    Animal *animal = [Animal alloc];
    animal.friends = @[].mutableCopy;
    [animal addObserver:self
             forKeyPath:@"friends"
                options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
                context:NULL];
    
    //1、被动触发错误方式:这里无法触发`kvo`回调
    [animal.friends addObject:@"dog"];
    //2、被动触发正确方法
    [[animal mutableArrayValueForKey:@"friends"] addObject:@"dog"];
    //3、手动触发
    [animal willChangeValueForKey:@"friends"];
    [animal.friends addObject:@"dog"];
    [animal didChangeValueForKey:@"friends"];

}

23行代码相当于

NSMutableArray *tmp = [NSMutableArray arrayWithArray:animal.friends];
[tmp addObject:@"dog"];
animal.friends = tmp;

因此触发了kvo21行,因为KVO是给予set方法的,这样不会触发set方法,所以就不会触发KVO通知。

1.5多属性的关联

我们需要在被观察者类重写两个方法:

  1. 一个系统方法+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key或者+ (NSSet *)keyPathsForValuesAffecting<xxx>
  2. 一个是被观察属性的getter方法。

例如:有一个Downloader.h类,有三个属性totalBytescompletedBytes,和百分比进度progress

// Downloader.h
@interface Downloader : NSObject

@property (nonatomic) unsigned long long totalBytes;

@property (nonatomic) unsigned long long completedBytes;

@property (nonatomic, copy) NSString *progress;

@end

在UI层我们只关注progress,但进度是受其他两个属性共同影响的,此时需要在Downloader.m实现中重写两个方法:

@implementation Downloader

+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
    
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"progress"]) {
        NSArray *dependKeys = @[@"totalBytes", @"completedBytes"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:dependKeys];
    }
    return keyPaths;
}

- (NSString *)progress {
    if (0 == self.totalBytes || 0 == self.completedBytes) {
        return @"0";
    }
    
    double progress = (double)self.completedBytes / (double)self.totalBytes * 100;
    
    if (progress > 100) {
        progress = 100;
    }
    
    return [NSString stringWithFormat:@"%d%%", (int)ceil(progress)];
}

@end

二、KVO实现原理

Automatic key-value observing is implemented using a technique called isa-swizzling. 具体参考苹果文档

当一个类的实例第一次注册观察者时,系统会做以下事情:

  • 动态生成一个继承自该类的中间类:NSKVONotifying_xxx
  • 将对象的isa指向这个中间类(isa-swizzling
  • 观察的是setter
  • 子类中重写set<xxx>-class-dealloc方法,添加一个-_isKVOA方法,依然返回原类,而非子类
  • 移除所有的观察后,isa会指回来,但是动态子类不会销毁

2.1 原理验证

被观察类Animal添加代码:

@interface Animal : NSObject{
    @public
    NSString *nickName;
}

@property (nonatomic, copy) NSString *name;
@property (nonatomic,strong) NSMutableArray *friends;

@end

viewController添加代码:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    
    Animal *animal = [Animal alloc];
    [self printClasses:[animal class]];
    [self printMethods:[animal class]];
    
    [animal  addObserver:self
                forKeyPath:@"nickName"
                   options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
                   context:NULL];
    [animal addObserver:self
             forKeyPath:@"name"
                options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
                context:NULL];
    [animal addObserver:self
             forKeyPath:@"friends"
                options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
                context:NULL];
    
    printf("\n********************************************************\n\n");
    [self printClasses:[animal class]];
    [self printMethods:NSClassFromString(@"NSKVONotifying_Animal")];
    
    
    
    
    animal.name = @"dog";
    animal->nickName = @"cat";
}

/// 打印出指定类及其子类列表
- (void)printClasses:(Class)cls {
    int count = objc_getClassList(NULL, 0);
    NSMutableArray *results = [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])) {
            [results addObject:classes[i]];
        }
    }
    NSLog(@"\nClasses: %@", results);
    free(classes);
}

/// 打印出指定类所有的方法
- (void)printMethods:(Class)cls {
    unsigned int count = 0;
    Method *methodList = class_copyMethodList(cls, &count);
    printf("Methods of class: %s (\n", NSStringFromClass(cls).UTF8String);
    for (int i = 0; i < count; i++) {
        Method method = methodList[i];
        SEL sel = method_getName(method);
        IMP imp = method_getImplementation(method);
        printf("    %s-%p\n", NSStringFromSelector(sel).UTF8String, imp);
    }
    printf(")\n");
    free(methodList);
}

控制台打印结果 :

2020-04-13 15:08:51.803441+0800 kvo[23854:22631006] 
Classes: (
    Animal
)
Methods of class: Animal (
    .cxx_destruct-0x10f868e50
    name-0x10f868d80
    setName:-0x10f868db0
    friends-0x10f868df0
    setFriends:-0x10f868e10
)

********************************************************

2020-04-13 15:08:51.809383+0800 kvo[23854:22631006] 
Classes: (
    Animal,
    "NSKVONotifying_Animal"
)
Methods of class: NSKVONotifying_Animal (
    setFriends:-0x7fff25701c8a
    setName:-0x7fff25701c8a
    class-0x7fff2570074d
    dealloc-0x7fff257004b2
    _isKVOA-0x7fff257004aa
)
2020-04-13 15:08:51.809932+0800 kvo[23854:22631006] -------------------{
    kind = 1;
    new = dog;
    old = "<null>";
}


通过上面打印结果发现:只有属性发生了回调,实例变量并没有。它们的区别就是有没有setter方法,所以我们得出结果:KVO是通过setter方法进行处理回调的。

苹果官方推荐尽量使用属性点语法的形式为属性赋值和访问属性,这样其实是在调用setter和getter,如果重写了setter和getter在期中增加了额外代码,可以保证代码执行的正确性。

viewController中继续添加代码,移除所有的观察者。

[self performSelector:@selector(removeAllObserver) withObject:nil afterDelay:2];

- (void)removeAllObserver{
    [_animal removeObserver:self forKeyPath:@"nickName"];
    [_animal removeObserver:self forKeyPath:@"name"];
    [_animal removeObserver:self forKeyPath:@"friends"];
    
    printf("\n********************************************************\n\n");
    [self printClasses:[_animal class]];
}

打印结果:

2020-04-13 15:20:17.770486+0800 kvo[24356:22641029] 
Classes: (
    Animal,
    "NSKVONotifying_Animal"
)

你也可以通过lldb,来探索一下,整个过程中isa指针的指向,object_getClassName(animal)

2.2 kvc 和 kvo

苹果文档有介绍,在理解KVO之前,必须先理解KVC。上篇文章我们也讨论了KVC的实现原理,KVC会先查找settergetter进行调用,如果没有查找到,则调用类方法+accessInstanceVariablesDirectly,如果返回YES,再去查找成员变量。
KVO也有类似的机制,在KVO接口中有这三个接口:

- (void)willChangeValueForKey:(NSString *)key;
- (void)didChangeValueForKey:(NSString *)key;
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key;

+automaticallyNotifiesObserversForKey:默认返回YES,动态创建的中间类重写了setter,虽然无法看到实现源码,但可以猜测在修改属性前后分别调用了-willChangeValueForKey:-didChangeValueForKey:类似方法,达到通知观察者的目的。
如果子类中重载了+automaticallyNotifiesObserversForKey:并返回NO,则无法触发自动KVO通知机制,但我们可以通过手动调用-willChangeValueForKey:-didChangeValueForKey:来触发KVO回调。

三、自定义KVO

系统kvo使用时存在不方便的地方,根据kvo的原理和基本使用,我们可以简单自定义kvo实现。

  1. 入参检查
  2. 检查是否有属性的setter
  3. 动态创建对象子类BLKVOClass_xxx
  4. isa-swizzling
  5. 重写-class、-dealloc方法
  6. 重写setter
  7. 保存观察者信息,在属性发生变化时回调

3.1 动态创建对象子类

    Class newClass = NSClassFromString(newClassName);
    
    if (newClass) {
        return newClass;
    }
    
    /**
    * 如果内存不存在,创建生成
    * 参数一: 父类
    * 参数二: 新类的名字
    * 参数三: 新类的开辟的额外空间
    */
   
    // 2.1 : 申请类
    newClass = objc_allocateClassPair([self class], newClassName.UTF8String, 0);
    // 2.2 : 注册类
    objc_registerClassPair(newClass);

3.2 isa-swizzling

重写class方法

Class mm_class(id self,SEL _cmd){
    return class_getSuperclass(object_getClass(self));
}

动态子类添加class实现,完成isa-swizzling

// 2.3.1 : 添加class : class的指向是父类
    SEL classSEL = NSSelectorFromString(@"class");
    Method classMethod = class_getInstanceMethod(newClass, classSEL);
    const char *classTypes = method_getTypeEncoding(classMethod);
    class_addMethod(newClass, classSEL, (IMP)mm_class, classTypes);

3.3 dealloc

重写delloc,1、安全移除所有observe,2、销毁关联对象,3、isa指回父类,4、调用系统dealloc。

- (void)mm_dealloc {
//    Class superClass = [self class];
    Class superCls = class_getSuperclass(object_getClass(self));
    object_setClass(self, superCls);
    
    // Call system -dealloc
    [self mm_dealloc];
}

四、FBKVOController

下面简单聊一下FBKVOController,它里面有几个关键类:

  1. _FBKVOSharedController,单利对象,处理、转发KVOViewController传过来的所有观察者事件。
  2. _FBKVOInfo,数据模型,保存一个完整的KVO数据。
  3. KVOViewController,每个观察者都有一个该类的实例对象,这个类用于处理观察者传过来的所有数据,下图是他的主要属性构成。
    KVOViewController.png

下面是一个简单的调用实现代码:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    Student *student = [[Student alloc] init];
    
    FBKVOController *kvoCtrl = [FBKVOController controllerWithObserver:self];
    
    [kvoCtrl observe:student keyPath:@"nickName" options:NSKeyValueObservingOptionNew block:^(id  _Nullable observer, id  _Nonnull object, NSDictionary<NSString *,id> * _Nonnull change) {
        NSLog(@"****%@****",change);
    }];
    
    student.nickName = @"kkk";
    
}

observe对应viewControllerstudent对应object。当viewController被释放的时候,会先调用FBKVOControllerdealloc方法,在这里会将_objectInfosMap里所有的被观察者安全得 remove

拓展:抖音技术团队iOS大解密:玄之又玄的KVOObjective-C & Swift 最轻量级 Hook 方案

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

推荐阅读更多精彩内容