底层原理-19-KVO原理|8月更文挑战

330 阅读10分钟

1.KVO简介

KVO全称:Key—Value-Observing键值观察是一种允许通知对象其他对象指定属性更改的机制。
键值观测提供了一种机制,允许将其他对象的特定属性更改通知对象。它对于应用程序中模型层和控制器层之间的通信特别有用。控制器对象通常观察模型对象的属性,视图对象通过控制器观察模型对象的属性。然而,此外,模型对象可能会观察其他模型对象(通常用于确定依赖值何时更改)甚至本身(再次用于确定依赖值何时更改)。
您可以观察属性,包括简单属性、一对一关系多类型关系。接触许多关系的观察者被告知所做的更改类型,以及哪些对象参与了更改。因为KVO不是开源的具体可以参照官方文档

2.常用API用法

举一个简单的例子,我们有一个Person类,它有一个账户Account类,账户中有一些balance等属性。我们让账户添加一个观察者Person,当账户中的钱发生变化了,就告诉Person,不需要的时候就移除观察。

Art/kvo_objects_properties.png

2.1 注册

[self.account addObserver:self forKeyPath:@"balance" options:NSKeyValueObservingOptionNew context:NULL];

Art/kvo_objects_add.png

person观察accountbalance,其中options

typedef NS_OPTIONS(NSUInteger, NSKeyValueObservingOptions) {
NSKeyValueObservingOptionNew = 0x01,//当观察的属性发送改变我们返回它的最新的变化
NSKeyValueObservingOptionOld = 0x02,//当观察的属性发生变化我们返回它变化前的值

NSKeyValueObservingOptionInitial API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) = 0x04,//一对一情况下使用它会立即发送通知给观察者;当此选项与-addObserver:toObjectsAtIndexes:forKeyPath:options:context一起使用时:将为添加观察者的每个索引对象发送通知。
NSKeyValueObservingOptionPrior API_AVAILABLE(macos(10.5), ios(2.0), watchos(2.0), tvos(9.0)) = 0x08 //每次更改之前和之后向观察者发送单独的通知

}

消息中的上下文指针包含任意数据,这些数据将在相应的更改通知中返回给观察者。您可能会指定 NULL,并完全依赖密钥路径字符串来确定更改通知的来源,但这种方法可能会给超类出于不同原因也在观察同一密钥路径的对象带来问题。
通常来说就是上下文contextnullable void *类型,我们可以传NULL,它代表唯一标识符,方便我们直接查找,比如观察者观察了多个对象但是他们回调都是同一个方法,使用context就可以快速区分。

static void *PersonAccountBalanceContext = &PersonAccountBalanceContext;

static void *PersonAccountInterestRateContext = &PersonAccountInterestRateContext;

2.2 通知

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

    NSLog(@"%@",change);

}

Art/kvo_objects_observe.png

为了从Account接收变更通知,Person实现了observeValueForKeyPath:ofObject:change:context:方法,这是所有观察者都需要的。当注册的密钥路径之一发生更改时,Account将发送此消息给Person。然后,Person可以根据变更通知采取适当的行动。

2.3 移除

[self.account removeObserver:self forKeyPath:@"balance"];

Art/kvo_objects_remove.png

最后,当它不再需要通知时,至少在它被解除分配之前,Person实例必须通过向Account发送消息removeObserver:forKeyPath解除注册。

2.4 手动更改通知

NSObject提供自动键值更改通知的基本实现。自动键值更改通知通知观察者使用符合键值的访问器以及键值编码方法所做的更改。例如:setter方法,kvc
在某些情况下,您可能希望控制通知过程,例如,尽量减少因特定应用程序原因而不必要的通知的触发,或将一些更改分组到单个通知中。手动更改通知提供了这样做的手段

// 自动开关

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)theKey {

    BOOL automatic = NO;

    if ([theKey isEqualToString:@"balance"]) {

        automatic = NO;//指定的keypath

    }

    else {

        automatic = [super automaticallyNotifiesObserversForKey:theKey];

    }

    return automatic;

}
//实现手动通知的实例访问器方法
- (void)setBalance:(double)theBalance {

    [self willChangeValueForKey:@"balance"];//将要改变

    _balance = theBalance;

    [self didChangeValueForKey:@"balance"];

}

2.5 一对一关系

在许多情况下,一个属性的值取决于另一个对象中一个或多个其他属性的值。如果一个属性的值发生变化,则派生属性的值也应标记为更改。如何确保为这些依赖属性发布键值观察通知取决于关系的基数。
例如,一个人的全名取决于名字和姓氏。返回全名的方法可以写成如下:

- (NSString *)fullName {

    return [NSString stringWithFormat:@"%@ %@",firstName, lastName];

}

firstNamelastName属性发生变化时,必须通知观察fullName属性的应用程序,因为它们会影响属性的值。 一种解决方案是覆盖keyPathsForValuesAffectingValueForKey:指定一个人的fullName属性依赖于lastNamefirstName属性

+ (NSSet *)keyPathsForValuesAffectingValueForKey:(NSString *)key {

    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
 

    if ([key isEqualToString:@"fullName"]) {

        NSArray *affectingKeys = @[@"lastName", @"firstName"];

        keyPaths = [keyPaths setByAddingObjectsFromArray:affectingKeys];

    }

    return keyPaths;

}

通常应调用super,并返回一个集合,该集合中包含因这样做而产生的任何成员(以免干扰超类中此方法的重写)。
您还可以通过实现遵循命名约定keyPathsForValuesAffecting<Key>的类方法来实现相同的结果,其中<Key>是依赖于值的属性(首字母大写)的名称,例如fullName

+ (NSSet *)keyPathsForValuesAffectingFullName {

    return [NSSet setWithObjects:@"lastName", @"firstName", nil];

}

2.6 一对多关系

上面的方法并不支持多种关系,比如我们个人Person,有多个账户account,我们希望每次每个账户上发了salary薪水就能立马告诉Person我的总totalSalary多少。

@interface KBPerson : NSObject
@property (nonatomic, assign) NSNumber *totalSalary;

@property (nonatomic, strong) NSArray *accounts;
@end
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {

 

    if ([keyPath isEqualToString:@"salary"]) {

        [self updateTotalSalary];

    }

    else{

        

    }

}

- (void)updateTotalSalary {

    [self setTotalSalary:[self valueForKeyPath:@"accounts.@sum.salary"]];

}
//具体实现
    KBAccount *account1 = [[KBAccount alloc]init];

    account1.salary = 12;//账户1

    KBAccount *account2 = [[KBAccount alloc]init];

    account2.salary = 1;//账户2

    self.person.accounts = @[account1,account2];

    [account1 addObserver:self forKeyPath:@"fullName" options:NSKeyValueObservingOptionNew context:fullNameContext];

    [account1 addObserver:self.person forKeyPath:@"salary" options:NSKeyValueObservingOptionNew context:NULL];//告诉person 发工资了

    [account2 addObserver:self.person forKeyPath:@"salary" options:NSKeyValueObservingOptionNew context:NULL];

    [self.person addObserver:self forKeyPath:@"totalSalary" options:NSKeyValueObservingOptionNew context:totalSalaryContext];//告诉当前self,total Salary改变
//模拟改变
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event

{

    KBAccount *account1 = self.person.accounts[0];

     NSInteger salary = arc4random()%100;
     
    KBAccount *account2 = self.person.accounts[1];

     NSInteger salary2 = arc4random()%100;

    NSLog(@"账户1发工资了:%ld",salary);

    account1.salary = salary;

    NSLog(@"账户2发工资了:%ld",salary2 );

    account2.salary = salary2;

}
//监听改变
//改变

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

    

    if (context == totalSalaryContext) {

        NSLog(@"个人账户总资产:%@",[change objectForKey:NSKeyValueChangeNewKey]);

    }else if(context == fullNameContext){

        NSLog(@"改变了:%@",[change objectForKey:NSKeyValueChangeNewKey]);

    }
 

}

image.png

2.6 可变数组观察

当我们的观察者的属性是可变数组的时候,直接添加是不会调用setter方法,是不会触发kvo通知回调的

self.person.dateArray = [NSMutableArray arrayWithCapacity:1];

[self.person addObserver:self forKeyPath:@"dateArray" options:(NSKeyValueObservingOptionNew) context:NULL];

[self.person.dateArray addObject:@"1"];//不生效

官方推荐我们使用mutableArrayValueForKey,通过kvc触发,kvo的回调。

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

    self.person.nick = [NSString stringWithFormat:@"%@+",self.person.nick];

//    self.person.writtenData += 10;

//    self.person.totalData  += 1;

    // KVC 集合 array


    [[self.person mutableArrayValueForKey:@"dateArray"] addObject:@"1"];

}

image.png 其中的kind表示键值变化的类型,是一个枚举,主要有以下4种

typedef NS_ENUM(NSUInteger, NSKeyValueChange) {
    NSKeyValueChangeSetting = 1,//设值
    NSKeyValueChangeInsertion = 2,//插入
    NSKeyValueChangeRemoval = 3,//移除
    NSKeyValueChangeReplacement = 4,//替换
};

3. 底层原理

官方文档查看

image.png kvo使用一种称为“isa-swizzing”的技术来实现。

  • isa指针,顾名思义,指向维护分派表的对象的类。这个分派表本质上包含指向类实现的方法的指针,以及其他数据。
  • 当观察者注册对象的属性时,被观察对象的 isa 指针被修改,指向中间类,而不是真实类。因此,isa指针的值不一定反映实例的实际类。
  • 你不应该依赖isa指针来决定类的成员。相反,您应该使用类方法来确定对象实例的类

3.1 KVO触发机制

我们之前查看文档说明kvo通过setter 方法触发。我们验证下

self.student = [[KBStudent alloc]init];

    [self.student addObserver:self forKeyPath:@"name" options:NSKeyValueObservingOptionNew context:NULL];

    [self.student addObserver:self forKeyPath:@"nickName" options:NSKeyValueObservingOptionNew context:NULL];
-(void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event

{

    self.student.name = @"jack";

    self.student->nickName = @"Smith";
    
 }

image.png 成员变量通过内存平移无法触发kvo回调,setter才有回调。我们使用kvo,并实现setter方法

-(void)setNickName:(NSString*)nickName
{
    self->nickName = nickName; 

}

image.png 结论:KVO触发是监测了setter方法,只有实现了setter方法才能触发。

3.2 中间类

官方文档说kvo是通过交换实例对象的isa来实现的,也就是生成了一个中间类。 我们打印一下添加前和添加后变化

#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);

}

image.png 打印KBStudent和它的子类, 添加后多了一个NSKVONotifying_KBStudent的类,这个就是中间类也就是KBStudent的子类。我们lldb验证下

image.png 添加前我们的实例对象的isa指向0x000000010e1551e8 KBStudent类,添加后指向了NSKVONotifying_KBStudent的类。

3.3 中间类有什么东西?

是否继续了父类的方法,属性,成员变量?

#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);

}
//打印成员变量
-(void)printClassAllIvars:(Class)cls{

    

    unsigned int count = 0;

    Ivar *ivars = class_copyIvarList(cls, &count);

    

    for (int i = 0; i<count; i++) {

        Ivar ivar = ivars[i];

        NSString *ivarName = [NSString stringWithFormat:@"%s", ivar_getName(ivar)];

        NSLog(@"%@",ivarName);

    }  

    free(ivars);

}
//打印属性
-(void)printClassAllPropertys:(Class)cls{

    unsigned int count = 0;

    objc_property_t *propertys = class_copyPropertyList(cls, &count);

    

    for (int i = 0; i<count; i++) {

        objc_property_t property = propertys[i];

        NSString *propertyName = [NSString stringWithFormat:@"%s", property_getName(property)];

        NSLog(@"%@",propertyName);

    }

    free(propertys);
    

}

image.png 打印结果,并没有完全继承父类。其实也可以理解,我们对KBStudent添加观察主要观察的keypathname,我们中间类只需关注这个setName的实现就好。class(获取当前类)dealloc(销毁)_isKVOA(判断是否kvo)是重写NSObject
为什么是重写不是继承?
1.我们可以看打印方法的地址不是一样的.
2.子类继承父类方法自己不重写的话,子类方法列表是没有的,调用的时候是去父类链查找。

3.4 中间类setter方法做了什么?

我们在外部改变改变name,也就是调用namesetter方法,因为isa的交换,实际会去调用中间类的setter方法,但是我们对于KBStudentname 属性也是改变的,不然就会出问题,中间类就是相当管家,打理后还是需要告诉主人的。

self.student.name = @"jack";
//进入
-(void)setName:(NSString *)name
{

    _name = name;

    NSLog(@"%s",__func__);

}

image.png 可以得出结论:

  1. 当外面调用setter方法时候,实际进入中间类的setter方法来实现。
  2. 中间类setter方法会先去通知外面的观察者你观察的值要改变了。
  3. 向父类发送消息调用父类的setter方法,切实的去改变。

3.5 观察者dealloc的时候,中间类是否销毁?

我们在控制器dealloc中移除,打上断点。

image.png

removeObserver前实例对象的isa还是指向中间类NSKVONotifying_KBStudent。移除后:

image.png 还原了之前的isa指向,指向本类

  • 为何我们实例对象要销毁了还需要remove还原isa,防止野指针。比如我们的student是单利,不还原isa的话下次访问就会崩溃。
    我们remove后回到上个页面打印下中间类看是否存在

image.png 没有销毁,其实也可以理解,创建这个类也是挺复杂的,销毁的话。用户不断进出一个页面,不断重复添加移除操作,会消耗大量内存,浪费内存

4.总结

  • kvo相当于我们实时监测某一个关心的值,基于这个值的setter方法监听的,当调用这个值告诉观察者,这个值要改变了,不用的时候移除观察。
  • kvo内部实现是通过一个中间类去操作,中间类的setter主要是通知观察者值的改变调用父类的setter方法,实际去改变。这样设计相当于一个中间类当成一个管家,外面只要吩咐一下,具体执行交给管家,降低了耦合性
  • kvo移除主要是还原isa,把实例对象的isa由中间类还原到本类,中间类不会销毁,方便下次关联。