OC底层原理二十三:KVO原理

OC底层原理 学习大纲

上一节,我们介绍了KVC原理,而KVC工作,绝大部分通过继承NSObject自动处理好了。实际应用中,我们关注相对较少。而基于KVCKVO,在应用中却是非常的广泛。

现在我们使用的响应式框架(RACRxSwifCombine等),实际都是KVO机制的应用

本节,我们详细讲解KVO:

  1. KVO介绍
  2. KVO应用
  3. KVO原理

引入:

  • 我们上一节分析KVC时,官方KVC的应用中,第一个介绍的就是KVO
    👉 KVC文档链接
    image.png

我们点击进入Key-Value Observing Programming Guide (KVO指引)


1. KVO介绍

KVO,全称为Key-value observing键值观察。

  • 键值观察是一种机制,允许对象其他对象指定属性发生更改得到通知

2 KVO应用:

  • 测试代码:(监听person对象的name属性的新值
// HTPerson
@interface HTPerson : NSObject
@property (nonatomic, copy) NSString *name;
@end

@implementation HTPerson
@end

// ViewController
@interface ViewController ()
@property (nonatomic, strong) HTPerson *person;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    self.person = [HTPerson new];
    self.person.name = @"ht";
    
    // 1. 添加
    [self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context: NULL];
}

// 2. 监听
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString: @"name"]) {
        NSLog(@"新值:%@", change[NSKeyValueChangeNewKey]);
    }
}

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
   self.person.name = [NSString stringWithFormat:@"%@ +",self.person.name];
}

-(void)dealloc {
    // 3. 移除
    [self.person removeObserver:self forKeyPath:@"name" context: NULL];
}

@end
  • 这里为了测试,在touchesBegan点击事件中添加了name的变更,多次点击,打印结果如下:
    image.png

主要步骤: 1. 添加 -> 2. 监听 ->3. 移除

2.1 添加

  • addObserver 添加操作中,addObserver是监听对象KeyPath是监听路径option是监听类型,是一个枚举,包含:
typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
    NSKeyValueObservingOptionNew     // 新值
    NSKeyValueObservingOptionOld     // 旧值
    NSKeyValueObservingOptionInitial // 初始值 
    NSKeyValueObservingOptionPrior   // 变化前
};

面试官:添加通知时,context写什么内容?
答:填nil
面试官:回去等通知 😂

  • 关于context的介绍:
image.png
  • 照顾英语不好的同学,我们放上谷歌翻译

    image.png

  • context的类型为(void *),所以不能写nil。但可以写成Null
    如果写Null,会默认通过KeyPath路径去确定需要监听的对象。但是这种方法可能导致父类由于不同原因观察相同的路径,而产生问题。而且查询父类消耗的计算资源更多

所以苹果建议我们可以static void*创建静态的context,这样的好处是:

    1. 仅从本类中查找当前context节省计算资源,更安全
    1. observeValueForKeyPath监听对象时,我们可以不再通过name去区分当前响应对象。而是使用context精准区分当前响应对象:
      image.png
    1. removeObserver移除对象时,可以通过context精准移除观察对象:
      image.png

2.2 监听

  • observeValueForKeyPath 监听当前控制器的所有变化,change有以下4种情况:
typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
    NSKeyValueChangeSetting = 1,
    NSKeyValueChangeInsertion = 2,
    NSKeyValueChangeRemoval = 3,
    NSKeyValueChangeReplacement = 4,
};
  • keyPathobjectcontext和上述一样。

2.3 移除

一定要移除! 一定要移除! 一定要移除!

网上有很多说Xcode升级后,不再需要手动移除监听者。仅仅在当前页面操作时,确实不用处理。

  • 但如果业务变得复杂,对于同一对象属性,如果当前页面进行了添加、监听和移除,而其他页面只进行添加和监听,再触发监听时,就会产生KVO Crash。所以我们要养成谁使用谁销毁的习惯。

2.4 开关

  • automaticallyNotifiesObserversForKey控制自动手动发送通知默认值为自动 👉 官方介绍
image.png
  • 当我们给HTPerson添加automaticallyNotifiesObserversForKey方法,返回值为NO后,所有监听消息都不再发送。
  • 我们重写属性setter方法,手动调用API进行消息发送:
@implementation HTPerson
+(BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    return NO;
}

-(void)setName:(NSString *)name {
    
    if ([self.name isEqualToString: name]) return; // 值没变化,不操作
    
    [self willChangeValueForKey:@"name"]; // 即将改变
    _name = name; // 赋值
    [self didChangeValueForKey:@"name"]; // 已改变
}
@end

2.5 路径处理

  • 我们已downloadProgress下载进度为例,下载进度等于writtenData已下载数据量 / totalData总数据量

  • 我们可以聚合writtenDatatotalData两个属性,变成监听downloadProgress一个属性。

// HTPerson
@interface HTPerson : NSObject
@property (nonatomic, copy) NSString *downloadProgress;
@property (nonatomic, assign) double writtenData;
@property (nonatomic, assign) double totalData;
@end

@implementation HTPerson

- (NSString *)downloadProgress {
    if (self.writtenData == 0) {
        self.writtenData = 10;
    }
    if (self.totalData == 0) {
        self.totalData = 100;
    }
    return [NSString stringWithFormat:@"%.2f", 1.0f * self.writtenData / self.totalData];
}

// 下载进入 writtenData / totalData
+(NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
    NSSet * keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    
    if ([key isEqualToString:@"downloadProgress"]) {
        NSArray * affectingKeys = @[@"totalData", @"writtenData"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    }
    return keyPaths;
}
@end

// ViewController
@interface ViewController ()
@property (nonatomic, strong) HTPerson *person;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person = [HTPerson new];

    // 添加监听
    [self.person addObserver:self forKeyPath:@"downloadProgress" options:NSKeyValueObservingOptionNew context: NULL];
    
}

// 处理监听
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString: @"downloadProgress"]) {
        NSLog(@"当前进度:%@", change[NSKeyValueChangeNewKey]);
    }
}

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    self.person.writtenData += 20;
    self.person.totalData +=10;
}

-(void)dealloc {
    // 移除监听
    [self.person removeObserver:self forKeyPath:@"downloadProgress" context: NULL];
}

@end
  • 打印结果:


    image.png
  • 这里将writtenDatatotalData都变化了,可以看到每次打印的是2条记录。说明聚合的downloadProgress中,只要writtenDatatotalData值变化,都会触发一次。
    (如果你用过RACRxSwift,此处一定非常熟悉,这就是RxSwift的merge的原理。)

  • 相关原理: 👉 官方链接

2.6 数组的观察

  • 集合等类型的监听,与属性的监听不同。我们可以查阅KVC的官方文档
image.png
// HTPerson
@interface HTPerson : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, strong) NSMutableArray *dateArray;
@end

@implementation HTPerson

@end

// ViewController
@interface ViewController ()
@property (nonatomic, strong) HTPerson *person;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person = [HTPerson new];

    self.person.name = @"ht ";
    self.person.dateArray = [NSMutableArray new];
    
    // 添加监听
    [self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context: NULL];
    [self.person addObserver:self forKeyPath:@"dateArray" options:NSKeyValueObservingOptionNew context: NULL];
}

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

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    self.person.name = [NSString stringWithFormat:@"%@+", self.person.name];
//    [self.person.dateArray addObject:@"6"]; //此赋值仅改变数组内部元素,不会引起数组地址的变化
    [[self.person mutableArrayValueForKeyPath:@"dateArray"] insertObject:@"6" atIndex:0];
    [[self.person mutableArrayValueForKeyPath:@"dateArray"] setObject:@"8" atIndexedSubscript:0];
    [[self.person mutableArrayValueForKeyPath:@"dateArray"] removeObjectAtIndex:0];
}

-(void)dealloc {
    // 移除监听
    [self.person removeObserver:self forKeyPath:@"name" context: NULL];
    [self.person removeObserver:self forKeyPath:@"dateArray" context: NULL];
}

@end
  • 打印结果:
image.png
  • 数组设值,必须使用专属API才可以触发。直接赋值仅改变数组内部元素不会引起数组地址变化

  • 从打印的结果上,change4种情况:

typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
    NSKeyValueChangeSetting = 1,
    NSKeyValueChangeInsertion = 2,
    NSKeyValueChangeRemoval = 3,
    NSKeyValueChangeReplacement = 4,
};
image.png

3. KVO底层原理

3.1 KVO只观察Setter方法

  • 我们先观察一个案例,此案例有public声明的nickName成员变量和@property定义的属性name,分别监听这2个属性值:
  • 测试代码:
// HTPerson
@interface HTPerson : NSObject
{
@public NSString * nickName;
}
@property (nonatomic, copy) NSString *name;
@end

@implementation HTPerson

@end

// ViewController
@interface ViewController ()
@property (nonatomic, strong) HTPerson *person;
@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person = [HTPerson new];

    self.person.name = @"ht ";
    
    // 添加监听
    [self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context: NULL];
    [self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context: NULL];
}

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

-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    self.person.name = [NSString stringWithFormat:@"%@+", self.person.name];
    self.person->nickName = [NSString stringWithFormat:@"%@+", self.person->nickName];
}

-(void)dealloc {
    // 移除监听
    [self.person removeObserver:self forKeyPath:@"name" context: NULL];
    [self.person removeObserver:self forKeyPath:@"nickName" context: NULL];
}

@end
  • 打印结果:


    image.png
  • 发现只监听属性的变化,而监听不到成员变量的变化。而属性成员变量区别,核心在于是否实现setter方法。

3.2 KVO派生类

  • addObserver处打上断点运行到断点处后,打印当前person类:

    image.png

  • 惊奇的发现,使用运行时object_getClassName读取的self.person类,是NSKVONotifying_HTPerson类。而使用self.person.class直接打印的类,确是HTPerson类

  • 好像发现了一些不可告人的密码 😃 苹果金屋藏娇生成了个NSKVONotifying_HTPerson,但又故意的不让外部知道,所以调用class方法,打印的还是HTPerson类

  • 当然,从开发的层面,可以理解,对外事务越简单越好,高内聚减轻开发人员学习使用成本。不过对于我们现在探究底层原理而言,就想知道这个NSKVONotifying_HTPerson是什么。

3.2.1 NSKVONotifying_HTPersonHTPerson什么关系?

  • 导入#import <objc/runtime.h>,添加打印本类所有子类的方法:
/// 遍历本类及子类
-(void) printClasses: (Class)cls {
    
    // 注册类的总数
    int count = objc_getClassList(NULL, 0);
    // 创建1个数组
    NSMutableArray * mArray = [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])) {
            [mArray addObject:classes[i]];
        }
    }
    free(classes);
    NSLog(@"classes: %@",mArray);
}
  • addObserver前后前后打印self.person类
image.png
  • 观察到添加观察者后,HTPerson多了一个NSKVONotifying_HTPerson子类。

我们添加遍历IvarsPropertyMethod的函数:

/// 遍历Ivars
-(void) printIvars: (Class)cls {
    
    // 仿写Ivar结构
    typedef struct HT_ivar_t {
        int32_t *offset;
        const char *name;
        const char *type;
        uint32_t alignment_raw;
        uint32_t size;
    }HT_ivar_t;

    // 记录函数个数
    unsigned int count = 0;
    // 读取函数列表
    Ivar * ivars = class_copyIvarList(cls, &count);
    for (int i = 0; i < count; i++) {
        HT_ivar_t * ivar = (HT_ivar_t *) ivars[i];
        NSLog(@"ivar: %@", [NSString stringWithUTF8String: ivar->name]);
    }
    free(ivars);
    
}

/// 遍历属性
-(void) printProperties: (Class)cls {
    
    // 仿写objc_property_t结构
    typedef struct Ht_property_t{
        const char *name;
        const char *attributes;
    }Ht_property_t;

    // 记录函数个数
    unsigned int count = 0;
    // 读取函数列表
    objc_property_t * props = class_copyPropertyList(cls, &count);
    for (int i = 0; i < count; i++) {
        Ht_property_t * prop = (Ht_property_t *)props[i];
        NSLog(@"property: %@", [NSString stringWithUTF8String:prop->name]);
    }
    free(props);
    
}

/// 遍历方法
-(void) printMethodes: (Class)cls {
    
    // 记录函数个数
    unsigned int count = 0;
    // 读取函数列表
    Method *methodList = class_copyMethodList(cls, &count);
    for (int i = 0; i < count; i++) {
        Method method = methodList[i];
        SEL sel = method_getName(method);
        IMP imp = class_getMethodImplementation(cls, sel);
        NSLog(@"method: %@-%p", NSStringFromSelector(sel), imp);
    }
    free(methodList);
}
  • addObserver处添加打印代码,分别检查HTperson本类和NSKVONotifying_HTPerson派生类的IvarsPropertyMethod
    // 添加监听
    [self.person addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context: NULL];
    [self.person addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context: NULL];
    NSLog(@"------- NSKVONotifying_HTPerson --------");
    [self printMethodes: objc_getClass("NSKVONotifying_HTPerson")];
    [self printIvars: objc_getClass("NSKVONotifying_HTPerson")];
    [self printProperties: objc_getClass("NSKVONotifying_HTPerson")];
    NSLog(@"------- HTPerson --------");
    [self printMethodes: HTPerson.class];
    [self printIvars: HTPerson.class];
    [self printProperties: HTPerson.class];
  • 打印结果:
image.png

拓展:

  • 检验同样继承HTPerosn的子类HTStudent,打印结果:
// HTStudent
@interface HTStudent : HTPerson
@end
@implementation HTStudent
@end
image.png

结论:

  • 直接继承的子类,没有任何方法属性。可以确定:
    KVO派生类继承自HTPerosn,重写了setNameclassdealloc方法,新增了_isKVOA方法

3.2.2 KVO派生类给父类属性赋值

  • addObserver处添加断点,运行代码到此处时,lldb输入:watchpoint set variable self->_person->_name
image.png
  • 设置成功后,运行代码点击屏幕触发touchesBegan事件,会进入汇编页面(观察到设置属性断点处)
image.png
image.png
  • 可以观察到,当派生类在调用willChangedidChange中间,调用了[HTPerson setName]方法,完成了给父类HTPersonname属性赋值。(此时的willChange和didChange方法是继承自NSObject的)

3.2.3 KVO派生类何时移除,是否真移除?

  • 我们在removeObserver处加入断点,分别在removeObserver前后使用object_getClassName()打印当前isa指向的

    image.png

  • 发现NSKVONotifying_HTPerson在外部removeObserver时,完成的移除操作。将isa指回了原类

  • 但是我们在removeObserver移除操作之后,打印HTPerosn类和子类的信息,发现NSKVONotifying_HTPerson派生类并没有移除

ps: 页面销毁之后再打印HTPerosn类和子类,也一样存在NSKVONotifying_HTPerson派生类。

  • KVO派生类只要生成,就会一直存在,这样可以减少频繁的添加操作

至此,我们已经知道KVO是创建派生类实现了键值观察

    1. 添加:addObserver时,创建了派生类,派生类是当前类的子类重写被监听属性setter方法,并将当前类isa指向派生类
      此时开始,所有调用本类的方法,都是调用的派生类派生类没有的方法,就会沿着继承链查询到本类
    1. 赋值: 派生类重写了被监听属性的setter方法,在派生类setter方法触发时:在willChange之后,didChange之前,调用父类属性settter方法,完成父类属性的赋值`。
    1. 移除: 在removeObserver后,isa派生类指回本类。 但创建过的派生类,不会被本类从子类列表移除,会一直存在。
    1. 假象: 之所以外部打印class永远看不到派生类,是因为派生类class方法重写了,故意不让外界看到。
      (知道越多,烦恼越多 😂 ,就让派生类做个默默付出的无名英雄吧)

下一节,我们纯代码自定义KVO。(简化版,重在理解派生类流程功能

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