OC底层原理17-KVO底层原理

iOS--OC底层原理文章汇总

KVO(Key-Value Observing)——键值观察,它是一种机制,它允许将其他对象的指定属性的更改,通知给另一个对象。KVO苹果文档

关于KVO如何创建使用,大致分为三个步骤:

使用步骤

注册观察者

// 定义两个上下文
static void *PersonAccountBalanceContext = &PersonAccountBalanceContext;
static void *PersonAccountInterestRateContext = &PersonAccountInterestRateContext;

- (void)registerAsObserverForAccount:(Account*)account {
 
    [account addObserver:self
              forKeyPath:@"balance"
                 options:(NSKeyValueObservingOptionNew |
                          NSKeyValueObservingOptionOld)
                 context:PersonAccountBalanceContext];
 
    [account addObserver:self
              forKeyPath:@"interestRate"
                 options:(NSKeyValueObservingOptionNew |
                          NSKeyValueObservingOptionOld)
                  context:PersonAccountInterestRateContext];
}

options 是一个枚举,包含了以下四个值:
NSKeyValueObservingOptionNew:观察更改后的值;
NSKeyValueObservingOptionOld:观察更改前的值;
NSKeyValueObservingOptionInitial:观察最初的值(在注册观察服务时会调用一次触发方法);
NSKeyValueObservingOptionPrior:分别在值修改前后触发方法(即一次修改有两次触发)

context:上下文,包含任意数据,这些数据将在相应的更改通知中传递回观察者。可以指定NULL并完全依赖KeyPath字符串来确定更改通知的来源,但是这种方法可能会导致对象的父类由于不同的原因而观察到相同的键路径,从而导致问题。

一种更安全,更可扩展的方法是使用上下文确保您收到的通知是发给观察者的,而不是超类的。在类中定义唯一命名的静态变量的地址,就满足了良好的上下文条件。在父类或子类中以类似方式选择的上下文不太可能重叠。可以为整个类选择一个上下文,然后依靠通知消息中的KeyPath字符串来确定更改的内容。另外,可以为每个观察到的键路径创建一个不同的上下文,从而完全不需要进行字符串比较,从而可以更有效地进行通知解析。上面示例中显示了以这种方式选择的balanceinterestRate属性的示例上下文。

接受变更通知

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context {
 
    if (context == PersonAccountBalanceContext) {
        // Do something with the balance…
 
    } else if (context == PersonAccountInterestRateContext) {
        // Do something with the interest rate…
 
    } else {
        // Any unrecognized context must belong to super
        [super observeValueForKeyPath:keyPath
                             ofObject:object
                               change:change
                               context:context];
    }
}

当对象的观察属性的值更改时,观察者会收到一条observeValueForKeyPath:ofObject:change:context: 消息。所有观察者都必须实现此方法。

移除观察者

  • 当观察者不再应接收消息时,使用该方法removeObserver:forKeyPath:注销观察者。至少在观察者从内存释放之前调用注销方法,否则会导致奔溃。
- (void)unregisterAsObserverForAccount:(Account*)account {
    [account removeObserver:self
                 forKeyPath:@"balance"
                    context:PersonAccountBalanceContext];
 
    [account removeObserver:self
                 forKeyPath:@"interestRate"
                    context:PersonAccountInterestRateContext];
}
注销观察者

典型的使用场景是在观察者初始化期间(例如,在initviewDidLoad)注册为观察者,在释放过程中(通常在中dealloc)解除注册,以确保成对和有序地添加和删除消息,并确保观察者在从内存中释放之前被取消注册。
如果注册了观察者未注销,当再次进入观察者界面时,会再次注册KVO观察者,导致KVO观察的重复注册,而第一次的通知对象还在内存中,没有进行释放。如果此时接收到了属性值变化的通知,会出现找不到原有的通知对象,只能找到现有的通知对象,即第二次KVO注册的观察者,将会导致类似野指针的崩溃,可理解为一直保持着一个野通知,且一直在监听。

问:多次添加注册未注销会不会造成循环引用?不会,因为observer在底层的字符串是weak修饰,所以不会导致循环引用。

自动 & 手动变更通知

自动变更通知

NSObject提供自动的键值更改通知的基本实现。自动键值更改通知将使用键值兼容访问器(setName)以及键值编码方法(setValue:forKey:)进行的更改通知给观察者。由mutableArrayValueForKey:返回的收集代理对象也支持自动通知。
以下显示的示例使该属性的所有观察者都name收到有关更改的通知。

// 使用setter方法直接设置
[account setName:@"Savings"];
// 使用kvc设置name
[account setValue:@"Savings" forKey:@"name"];
 
// 使用keypath 设置document的name
[document setValue:@"Savings" forKeyPath:@"account.name"];
 
// 使用 mutableArrayValueForKey: to retrieve a relationship proxy object.
NSArray * arrayTrans = @{@"1001",@"1002"};
NSMutableArray *transactions = [account mutableArrayValueForKey:@"transactions"];
[transactions addObject: arrayTrans];

手动变更通知

这是切换手动or自动的方法,默认YES即为自动变更通知,这里可以判断theKey来控制是否手动变更通知。

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey {
    
    BOOL automatic = NO;
    if ([theKey isEqualToString:@"balance"]) {
        automatic = NO;
    }
    else {
        automatic = [super automaticallyNotifiesObserversForKey:theKey];
    }
    return automatic;
}

要实现手动观察者通知,在willChangeValueForKey:更改值之前和didChangeValueForKey:更改值之后调用。如下实现了该balance属性的手动通知,首先检查值是否已更改来最大程度地减少发送不必要的通知。如下balance则可以实现仅在通知已更改时才提供通知。

- (void)setBalance:(double)theBalance {
    if (theBalance != _balance) {
        [self willChangeValueForKey:@"balance"];
        _balance = theBalance;
        [self didChangeValueForKey:@"balance"];
    }
}

如果一次操作多个更改时,就需要嵌套了多个键的更改通知,如下

- (void)setBalance:(double)theBalance {
    [self willChangeValueForKey:@"balance"];
    [self willChangeValueForKey:@"itemChanged"];
    _balance = theBalance;
    _itemChanged = _itemChanged+1;
    [self didChangeValueForKey:@"itemChanged"];
    [self didChangeValueForKey:@"balance"];
}

如果是有序的一对多关系,不仅必须指定已更改的键,还必须指定更改的类型和所涉及对象的索引。NSKeyValueChange类型变化的有:NSKeyValueChangeInsertionNSKeyValueChangeRemovalNSKeyValueChangeReplacement。受影响对象的索引作为NSIndexSet对象传递。

- (void)removeTransactionsAtIndexes:(NSIndexSet *)indexes {
    [self willChange:NSKeyValueChangeRemoval
        valuesAtIndexes:indexes forKey:@"transactions"];
 
    // 删除指定索引的事务对象。
 
    [self didChange:NSKeyValueChangeRemoval
        valuesAtIndexes:indexes forKey:@"transactions"];
}

观察多属性变化

注册一个观察者,观察多个属性的变化。举例:有一个我下载进度,每次点击屏幕触发属性值增加,观察currentData,totalData的变化,利用keyPathsForValuesAffectingValueForKey通过keyPath拼接的方式观察两个属性值的变化,当观察到变化的值后,打印出变化后的值。

//1、观察一个数组 ,数组包含两个属性:currentData totalData
// ---Person.m---
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key{
    
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"downloadProgress"]) {
        NSArray *affectingKeys = @[@"totalData", @"currentData"];
        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];
    }
    return keyPaths;
}
// 更改下载进度数值
- (NSString *)downloadProgress{
    if (self.writtenData == 0) {
        self.writtenData = 10;
    }
    if (self.totalData == 0) {
        self.totalData = 100;
    }
    return [[NSString alloc] initWithFormat:@"%f",1.0f*self.writtenData/self.totalData];
}


// ----ViewController.m----

- (void)viewDidLoad {
    [super viewDidLoad];
  //2、注册KVO观察
  [self.person addObserver:self forKeyPath:@"downloadProgress" options:(NSKeyValueObservingOptionNew) context:NULL];
}
//3、触发属性值增加
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    self.person.currentData += 10;
    self.person.totalData  += 1;
}
//4、收到变更通知
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
    NSLog(@"%@",change);
}
//4、移除观察者
- (void)dealloc{
    [self.person removeObserver:self forKeyPath:@"downloadProgress"];
}

观察可变数组

观察可变数组类型,用到的是mutableArrayValueForKey ormutableArrayValueForKeyPath.

// 1、注册可变数组KVO观察者
- (void)viewDidLoad {
    [super viewDidLoad];
    self.account.dateArray = [NSMutableArray arrayWithCapacity:10];
    [self.account addObserver:self forKeyPath:@"dateArray" options:(NSKeyValueObservingOptionNew) context:NULL];
}
// 2、接收变更通知
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context{
    NSLog(@"%@",change);
}

// 3、移除观察者
- (void)dealloc{
  [self.account removeObserver:self forKeyPath:@"dateArray"];
}

// 4、给数组添加数据
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    // 
    [[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"1"];
}

KVO观察属性,不观察成员变量

定义一个类,分别观察其属性name和成员变量nickeName的变化

    self.account = [[Account alloc] init];
    [self.account addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
    [self.account addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:NULL];

触发对属性和成员变量的赋值,执行结果如下:

观察属性vs成员

KVO在对属性、成员变量观察时,只观察到了属性的变化。原因是属性比成员变量多setter方法,而KVO观察的就是setter方法。

中间类

苹果关于KVO实现的的解释
KVO实现解释
  1. 自动键值观察是使用是基于isa-swizzling(指针装换)的技术实现的。
  2. isa指针,顾名思义,指向对象的类,它保持一个调度表。该调度表实质上包含指向该类实现的方法的指针以及其他数据。
  3. 在为对象的属性注册观察者时,将修改观察对象的isa指针,它指向了中间类而不是真实类。因此,isa指针的值不一定反映实例的实际类。
  4. 不要依靠isa指针来确定类的成员变量。相反,应该使用该类方法确定对象实例的类。

中间类的产生

根据这段文字描述,isa指针在为对象的属性注册观察者时,观察对象的isa指针会发生改边,指向了一个中间类。可以做一个简单的探究。

以观察刚才的account对象属性name为例,探究在添加观察者之后类是否发生了变化

产生了一个中间类

由上面的结果知道,在注册观察者之前,对象的类是Account,注册之后,实例对象的指针地址发生了变化,指向了一个中间类NSKVONotifying_Account

中间类是否为子类?

不禁好奇这个NSKVONotifying_Account中间类是怎样一个存在,是否为Account的子类呢?可以试着打印account对象的类探究下

// ---------------
    self.account = [[Account alloc] init];
    [self printClasses:self.account.class];
    [self.account addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];
    [self printClasses:self.account.class];
//  ---------------

#pragma mark - 遍历类以及子类
- (void)printClasses:(Class)cls{
    // 注册类的总数
    int count = objc_getClassList(NULL, 0);
    // 创建一个数组, 其中包含给定对象
    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);
}

打印之

中间类是Account的子类

根据结果可以知道:NSKVONotifying_Account中间类是Account的子类。

中间类的方法

接下来,顺藤摸瓜,可以探究下中间类中有什么方法?
定义一个打印方法列表的方法

#pragma mark - 遍历方法-ivar-property
- (void)printClassAllMethod:(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(@"%@-%p",NSStringFromSelector(sel),imp);
    }
    free(methodList);
}

同理再次执行,分析一下打印结果

中间类的四个方法

中间类打印出四个方法:setName、class、dealloc、_isKVOA,为了方便研究是什么,
在Acount类中添加两个实例方法:

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface Account : NSObject
{
    NSString * nickName;
}
@property (nonatomic,copy) NSString * name;

-(void)method1;
-(void)method2;

@end

NS_ASSUME_NONNULL_END

account添加一個子类SubAccount,只重写父类namesetter方法,其他只集成;

#import "SubAccount.h"

@implementation SubAccount
// 重写父类是setName方法
- (void)setName:(NSString *)name
{
    
}
@end

再执行分析结果

子类 vs 中间类 方法列表

根据以上可以知道,SubAccount集成了Account类,并且重写了setName方法,在遍历子类方法列表时,只打印出了setName方法。说明NSKVONotifying_Account是重写了父类Account的四个方法,这四个方法是否真的是Account的呢?
为了研究这个,我们可以打印出Account目前的方法列表

由上面的结果可以知道,NSKVONotifying_Account只重写了setName的方法,剩下的class、dealloc、_isKVOA则是重写了Account父类NSObject的方法。

isa指针重指向观察者类

经过探究,在移除观察者时,isa指针重指向了Account

注销前后

在调用注销方法之后,观察者对象的中间类消失,实例对象的类还原成Account,那这个中间类是否是真的消失了?

ViewController页面我们介入一个打印Account类及子类的方法,第一次进入程序时,打印它,之后进入测试KVO界面,打印出注册观察者前后Account的类变化,最后注销观察者前后的Account的类变化,再次回到ViewController页面,再次打印之。

image.png

可以知道,中间类NSKVONotifying_Account并未随着KVO界面消息、注销观察者之后就消失,它依然还是作为Account的子类,在其开辟的内存空间里面,这样的目的就是为了复用

总结

  1. 给实例对象注册KVO观察者后,实例对象的指针地址会发生变化,会生成一个实例对象父类的一个子类NSKVONotifying_xxxx;
  2. 中间类方法会重写父类的方法;其中class、dealloc、_isKVOA则是重写了基类NSObject的方法。(dealloc:重写了释放方法、_isKVOA:判断是否为KVO类)
  3. 移除观察者后,观察者类会还原为初始化类(isa指针重指向初始化类);但中间类并未从内存中移除,目的是为了方便复用。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345