iOS底层原理-KVO(上)

1,603 阅读7分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

KVO的定义

iOS开发中,KVO模式会经常被用到,通过监听对象的属性值变化进而处理一些业务逻辑,那KVO是如何去监听的?属性值的变化是如何通知监听者的?首先通过苹果的官方文档Key-Value Observing Programming Guide对KVO的定义。

Key-value observing提供了一种机制,允许在其他对象的特定属性发生变化时通知对象。

KVO流程图

根据上面的定义,可以简单的画个KVO的流程图 001.png

KVO的使用

首先我们看一下KVO的代码使用,直接调用相关API,代码如下:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person = [[ATPerson alloc] init];
    [self.person addObserver:self
                  forKeyPath:@"name"
                     options:NSKeyValueObservingOptionNew
                     context:NULL];
}

代码很简单,直接在viewDidLoad方法里添加addObserver方法,对person对象的name属性进行监听,我们平时Context都是传的NULL,这里Context有什么作用?

KVO中的Context

首先看一下官方文档对Context的定义。

addObserver:forKeyPath:options:context:回调函数返回数据中,观察者将接收到包含任意的数据。你可以指定NULL并完全依赖keyPath来确定更改通知的来源,但是这种方法可能会导致对象的问题,因为该对象的超类也因为不同的原因观察相同的keyPath。 更安全、更可扩展的方法是使用上下文来确保接收到的通知是发给观察者的,而不是超类。 当在一个类中添加多个KVO监听时,因此我们可以指定Context就可以确定监听属性的来源,避免在同一个回调方法里做过多的判断逻辑。

示例代码
static void *PersonHobbyContext = &PersonHobbyContext;
static void *PersonNameContext = &PersonNameContext;
- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person = [[ATPerson alloc] init];
    [self.person addObserver:self
                  forKeyPath:@"hobby"
                     options:NSKeyValueObservingOptionNew
                     context:PersonHobbyContext];
    [self.person addObserver:self
                  forKeyPath:@"name"
                     options:NSKeyValueObservingOptionNew
                     context:PersonNameContext];
}
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary<NSKeyValueChangeKey,id> *)change
                       context:(void *)context {
    if (context == PersonHobbyContext) {
        // hobby
    } else if (context == PersonNameContext) {
        // name
    } else {
        [super observeValueForKeyPath:keyPath
                             ofObject:object
                               change:change
                              context:context];
    }
}

通过上面指定Context,我们可以在同一个对象监听不同的属性值变化,而当属性值变化时就不需要通过多层判断来确定,这样可以提高代码的可读性,减少冗余代码。

移除观察者

在类的销毁过程中是否需要移除观察者,还是看一下官方文档关于移除观察者的解释。

  • 如果没有注册观察者,移除观察者会导致NSRangeException。当你addObserver:forKeyPath:options:context:就要调用removeObserver:forKeyPath:context:一次,或者如果这在你的应用程序中不可用,把removeObserver:forKeyPath:context:调用放在try/catch块中来处理潜在的异常。
  • 当被释放时,观察者不会自动移除自己。被观察的对象继续发送通知,无视观察者的状态。然而,与任何其他消息一样,发送到已释放对象的更改通知会触发内存访问异常。因此,你可以确保观察者在从内存释放之前将自己移除 下面通过一个示例来验证一下不移除监听会有什么问题。

移除监听案例分析

代码部分

首页HomeVC

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.title = @"首页";
    self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"下一页"
                                                                              style:UIBarButtonItemStylePlain
                                                                             target:self
                                                                             action:@selector(nextClick:)];
}

- (void)nextClick:(UIBarButtonItem *)item {
    [self.navigationController pushViewController:[[SecondVC alloc] init] animated:YES];
}

SecondVC

- (void)viewDidLoad {
    [super viewDidLoad];
    self.title = @"KVO";
    
    // 这里通过单例的方式来验证
    self.person = [ATPerson sharedInstane];
    [self.person addObserver:self
                  forKeyPath:@"name"
                     options:NSKeyValueObservingOptionNew
                     context:NULL];
}

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

- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
    self.person.name = @"hello world";
}
操作步骤
  1. 在首页点击下一页push到SecondVC
  2. 点击屏幕触发监听回调
  3. 返回首页,再次进入
  4. 点击屏幕再次触发监听回调 运行,第一次进到SecondVC页面点击屏幕正常,当退回到HomeVC再次点击进入SecondVC点击屏幕程序Crash002.png
crash原因分析

造成第二次Crash的原因,是由于我们对person设置了单例,第一次进来正常监听回调,当pop出来时,原来的SecondVC内存已释放,第二次再次进入开辟了新的内存空间给SecondVC,但由于我们没有在dealloc时移除监听,person还是会给原来的SecondVC发送监听消息,访问原来已释放的地址造成Crash。虽然造成这种Crash并一定发生,但为了代码的强壮型,注意加上移除监听操作。

注意:不管什么情况,如果给对象添加了KVO监听,在对象销毁时一定要记得移除监听。

手动观察

KVO也可以通过手动控制监听对应的属性,需要关闭自动监听函数,默认是开启的。

手动控制

自动监听函数,需要设置返回NO

+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key {
    return NO;
}

当需要监听指定属性时,需要在监听的对象中重新setter方法,如下:

代码示例

- (void)setName:(NSString *)name {
    [self willChangeValueForKey:@"name"];
    _name = name;
    [self didChangeValueForKey:@"name"];
}

在函数automaticallyNotifiesObserversForKey返回NO时,KVO只会监听自定义实现的setter方法,其他的属性值会忽略。

KVO的一对多观察

当观察对象的属性值之间存在相关联时,对于这种情况需要实现KVOkeyPathsForValuesAffectingValueForKey函数,重写KeyPaths关联的属性值。

当一个属性的getter方法使用其他属性(包括由键路径定位的属性)的值计算要返回的值时,可以重写此方法。您的覆盖通常应该调用super并返回一个集合,该集合中包含这样做的结果中的任何成员(以便不干扰超类中此方法的覆盖)。 下面通过一个简单的下载示例来演示。

示例代码

ATPerson类里添加3个属性,当前下载进度 = 已下载 / 总数据,在点击屏幕时改变对应的属性值,KVO监听回调里打印相关信息。

ATPerson.h

@interface ATPerson : NSObject

@property (nonatomic, copy) NSString *downloadProgress; // 当前下载进度
@property (nonatomic, assign) double writtenData; // 已下载
@property (nonatomic, assign) double totalData; // 总数据

@end

ATPerson.m

// 重写KVO监听的属性值
+ (NSSet<NSString *> *)keyPathsForValuesAffectingValueForKey:(NSString *)key {
    NSSet *keyPaths = [super keyPathsForValuesAffectingValueForKey:key];
    if ([key isEqualToString:@"downloadProgress"]) {
        NSArray *affectingKeys = @[@"totalData", @"writtenData"];
        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];
}

HomeVCtouchBegin事件中改变属性值的变化。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    
    self.person = [[ATPerson alloc] init];
    [self.person addObserver:self
                  forKeyPath:@"downloadProgress"
                     options:NSKeyValueObservingOptionNew
                     context:NULL];
}

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

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

- (void)dealloc {
    [self.person removeObserver:self forKeyPath:@"downloadProgress"];
}

运行代码,在HomeVC点击屏幕,查看日志,可以看到没点击一次都可以监听到下载进度的变化。 01.png

对可变数组的观察

接下来我们对ATPerson添加一个可变数组属性dataArray,然后在HomeVC中对这个属性进行观察。

示例代码

HomeVC.m

- (void)viewDidLoad {
    [super viewDidLoad];
    
    self.person = [[ATPerson alloc] init];
    [self.person addObserver:self
                  forKeyPath:@"dateArray"
                     options:NSKeyValueObservingOptionNew
                     context:NULL];
    self.person.dateArray = [[NSMutableArray alloc] init];
    [self.person.dateArray addObject:@"hello"];
}

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

运行,看打印信息可变数组dataArray没有添加任何信息。 02.png

分析

根据之前的KVO属性监听方式,都是采用一样的方式,但实际监听可变数组时发现结果却没有改变新值,带着疑问查看苹果关于KVO的官方文档,在文档介绍中首先就有下面这个提示。

重要:要理解KVO,必须首先KVC. 所以再去找到KVC的文档,对于集合类型的键值观察提供是下面几个方法。

1. mutableArrayValueForKey:  mutableArrayValueForKeyPath:
2. mutableSetValueForKey:  mutableSetValueForKeyPath:
3. mutableOrderedSetValueForKey:  mutableOrderedSetValueForKeyPath:

接下来对可变数组采用这个方式来进行监听,代码如下,再次运行: 03.png 可以看到监听到了可变数组的属性值变化。

总结

从官方文档对KVO的定义,采用实际案例代码对KVO使用的验证,KVO键值观察是结合KVC的使用方式,对KVO属性观察需要参照KVC的实现原理,通过上面的分析,做以下几点总结:

  • 我们可以对一个对象的属性进行监听,常规的属性直接调用addObserver,特殊的属性需要参照KVC中的定义,如集合类。
  • 在对象销毁时注意要移除监听
  • 可以设置手动的监听方式
  • 如果一个属性依赖多个属性值,需要重写keyPathsForValuesAffectingValueForKey方法。 以上就是本篇对KVO的使用的基本介绍,关于KVO原理分析将通过下一篇进行探索。