Objective-C中安全、高效地使用和及时、智能的移除KVO

张维娜

key-value observing (KVO)是NSObject一个非正式协议。他可以使得一个对象可以让任意观察者来监听该对象的特定keypath。我们可以使用KVO来对被观察者对象属性发生变化时做出快速及时的响应。例如我们可以使用KVO对model与viewcontroller实现双向绑定。

但是KVO也有着许多问题去解决,比如观察对象的keyPath发生变化无法被编译器检查,在运行时才能被发现。误操作移除两次相同的KVO,或在对象被销毁时,没有及时移除KVO,都会造成程序的crash。那么如何安全、高效的使用KVO,以及如何及时、智能的移除KVO是我们需要探讨的问题。

一、更好的使用KeyPath

首先我们创建一个model和一个viewController

@interface ViewModel : NSObject

@property (nonatomic, strong) NSString *observeString;

@end

当model里的observeString发生变化时,viewController希望得到通知。 平常我们使用KVO时,一般都是这么写:

[viewModel addObserver:self
            forKeyPath:@"observeString"
               options:NSKeyValueObservingOptionNew
               context:nil];

这时,我们可以发现由于keypath使用的是字符串编码,所以拼写错误或是observeString属性名称发生变化等问题都无法被编译器检查出来,最终导致了许多问题延后到了运行时才能被发现。

那如何解决这个问题呢?

我们可以使用宏来解决这个问题:

 #define KvoKeyPath(PATH)  @(((void)(NO && ((void)PATH, NO)), strchr(# PATH, '.') + 1))

对于编译语言来说,所有的宏都是在预编译的时候被展开的,所以我们可以通过Xcode直接查看预处理或者预编译阶段的宏展开。

我们可以看到 KvoKeyPath(self.class) 被展开成了 @(((void)(__objc_no && ((void)self.class, __objc_no)), strchr("self.class", '.') + 1))

(void)是为了防止逗号表达式的warning。加NO是为了C短路判断条件表达式。编译器看见了NO && 以后,就会很快的跳过之后的判断条件。

在宏中,#代表把宏的参数名转化为一个字符串。而strchr函数使用来查找字符串s中首次出现字符c的位置。返回首次出现字符c的位置的指针,返回的地址是被查找字符串指针开始的第一个与字符c相同字符的指针,如果字符串中不存在字符c则返回NULL。

最后我们通过strchr函数得到了一个C的字符串,通过@( )包起来,就变成了一个OC的字符串了。

使用宏之后,keyPath的就可以这样写:

 [viewModel addObserver:self
            forKeyPath:KvoKeyPath(viewModel.observeString)
               options:NSKeyValueObservingOptionNew
               context:nil];

由于我们在判断式中加入了self.class,编译器会对你的属性名称进行拼写校验。

二、KVO消息转发

KVO所有的改变通知回调都被到了都被集中到了一个单独的方法 -observeValueForKeyPath:ofObject:change:context: 中,这就导致了当我们在一个类中观察了多个对象时需要使用if else来做区分:

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
  if ([keyPath isEqualToString:@"observeString"]) {
    // TODO
  }
}

这容易导致 -observeValueForKeyPath:ofObject:change:context: 中代码的臃肿,可读性的下降。

那我们是否可以在建立观察者联系时,就可以通过block或者指定@selector的形式来接收消息的回调呢?

实现步骤

1、创建NSObject的分类,利用runtime来对本身关联一个KvoPoint对象,在对象中,保存观察对象的KVO配置,保存自己的持有者,并实现对观察对象的监听

2、封装 -observeValueForKeyPath:ofObject:change:context: 方法

3、在KvoPoint接收到消息回调时,把KVO消息回调转发给A

下面是核心代码:

创建NSObject分类

@interface NSObject (SafeKvo)

- (void)safe_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options block:(KVOSafeBlock)block;

@end

@implementation NSObject (SafeKvo)
- (void)safe_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options block:(KVOSafeBlock)block
{
    //关联一个KvoPoint对象
    KvoPoint *sPoint = [self point];
    KvoPoint *oPoint = [(NSObject *)observer point];

    [oPoint addPoint:sPoint keyPath:keyPath options:options block:block];
}

@end

利用runtime来关联一个对象

- (KvoPoint *)point {
    KvoPoint *point = objc_getAssociatedObject(self, @selector(point));
    if (point == nil) {
        point = [KvoPoint alloc] initWithObject:self];
        objc_setAssociatedObject(self, @selector(point), point, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    return point;
}

KvoPoint配置

@interface KvoPoint ()
{
    NSMapTable<ZWNKvoPoint *, NSMutableSet<_ZWNKvoInfo *> *> *_toPoints;//保存所有观察对象的配置
}
@property (nonatomic, unsafe_unretained) id theObject;//指向持有者

@end

PointA将PointB和KvoInfo添加到自己的_toPoints中

- (void)addPoint:(ZYKvoPoint *)point withInfo:(ZYKvoInfo *)info
{
    NSMutableSet *infos = [_toPoints objectForKey:point];
    ZYKvoInfo *existingInfo = [infos member:info];
    if (nil != existingInfo) {
        return;
    }

    if (nil == infos) {
        infos = [NSMutableSet set];
        [_toPoints setObject:infos forKey:point];
    }
    [infos addObject:info];

    [point.theObject addObserver:self forKeyPath:info->_keyPath options:info->_options context:(void *)info];
}

调用block,把PointA的KVO消息回调转发给A

#pragma mark - observe
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
{
    NSAssert(context, @"missing context keyPath:%@ object:%@ change:%@", keyPath, object, change);
    ZWNKvoInfo *info = (__bridge id)context;

    if (info->_block) {
        info->_block(_theObject, object, change);
    }
    else {
        [_theObject observeValueForKeyPath:keyPath ofObject:object change:change context:context];
    }
}

kvoInfo配置

@interface KvoInfo : NSObject

@end

@implementation KvoInfo
{
@public
    NSString *_keyPath;
    NSKeyValueObservingOptions _options;
    KVOSafeBlock _block;
    void *_context;
    SEL sel;
}

三、如何安全的取消注册

在使用者KVO最大的痛苦就是在什么时候及时的移除观察者,而且移除观察者的时机必须合适。没有及时的移除或者多次重复的移除都会造成crash。

我们可以使用 @try / @catch 的方式来安全的取消注册。例如:

   @try {
      [object removeObserver:self forKeyPath:@"keyPath"];
   }
   @catch (NSException * __unused exception) {}

但是@try / @catch的方案会导致代码的臃肿,可读性的下降。我们可以采用更加优雅的方案来解决这个问题:

移除KVO

当观察者和被观察者建立连接时,我们可以把这一连接描述成一幅有向图。

如上图所示,对象A监听B某一个属性,但同时又是C的监听对象,如果A把监听自己的对象C信息也保存下来,是不是就能够在自己被销毁前,先手动移除所有的KVO。

1、我们对KVOPoint类进行改造,添加一个NSHashTable类型的fromPoints表,用来给kvoPointA存储监听自己的kvoPointC信息。

@interface ZWNKvoPoint ()
{
    NSMapTable<ZWNKvoPoint *, NSMutableSet<_ZWNKvoInfo *> *> *_toPoints;//保存所有观察对象的配置
    NSHashTable<ZWNKvoPoint *> *_fromPoints; // 保存所有观察自己的持有者的KVOPoint
}

2、添加KVO,当我们要增加A对B的某个属性进行观察时,我们更新对应的A,B的KvoPoint中toPoints,fromPoints中对应的表信息,当A要移除对B的观察时,我们检查PointA中对应的toPoints表内信息,如果toPoints中有要移除的PointB以及对应的B的KvoInfo,就移除toPoints中对应内容和PointA对B的观察,同时更新PointB中的formPoints表信息。由于我们使用了KvoPoint来记录每次添加KVO时的信息,并在移除时进行了校验这样就可以防止多次添加同样的KVO或多次移除的发生。

PointB将PointA添加到_formPoints中

- (void)addFromPoint:(ZYKvoPoint *)point
{
    if (nil == point) {
        return;
    }

    [_formPoints addObject:point];
}

PointA 移除PointB对应的Kvoinfo

- (void)removePoint:(ZYKvoPoint *)point withInfo:(ZYKvoInfo *)info
{
    NSMutableSet *infos = [_toPoints objectForKey:point];
    ZYKvoInfo *existingInfo = [infos member:info];
    if (nil == existingInfo) {
        return;
    }

    [infos removeObject:existingInfo];
    [point.theObject removeObserver:self forKeyPath:existingInfo->_keyPath context:(void *)existingInfo];

    if (0 == infos.count) {
        [_toPoints removeObjectForKey:point];
        [point removeFromPoint:self];
    }
}

四、更加智能的自动移除KVO

当一个对象被释放时我们必须需要手动移除观察者和被观察者,一旦其中一方在被释放时没有及时的移除KVO关系就会导致Crash,这迫使我们需要在对象 -dealloc 时,添加手动移除的代码。

为了能够在对象A释放时自动移除KVO关系,需要我们在接收到A将要销毁时,同步销毁PointA,并移除PointA表中对应的所有KVO关系就可以实现自动移除KVO,那么问题的关键便成为了如何获取一个对象的销毁时机?

Hook dealloc

借助Objective-C中的runtime的特性,我们可以实现很多常规方法下几乎不可能完成的事情。例如我们可以使用RunTime运行时的这个黑科技很容易的替换NSObject的 -dealloc 方法并在替换后盾方法中自动注销KVO关系的移除。

// 替换dealloc方法,自动注销observer
+ (void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Method originalDealloc = class_getInstanceMethod(self, NSSelectorFromString(@"dealloc"));
        Method newDealloc = class_getInstanceMethod(self, @selector(autoRemoveObserverDealloc));
        method_exchangeImplementations(originalDealloc, newDealloc);
    });
}

- (void)autoRemoveObserverDealloc
{
    [self.point removeAll];
    [self autoRemoveObserverDealloc];
}

但是直接替换基础类的-dealloc 对于其他的代码入侵性太强,容易产生一些可不遇见性的问题,所以不推荐使用这个方式。

使用关联对象

我们也可以借助runtime的另一个特性关联对象(Associated Objects)来完成获取任意对象的释放时机.

首先我们对 NSObject 添加一个 DeallocHook 的关联对象。

NSObject+DeallocHook

@interface NSObject (DeallocHook)

- (void) addDeallocMethod;

@end

@implementation NSObject (DeallocHook)

- (void)addDeallocMethod {
    DeallocHook *hook = objc_getAssociatedObject(self, @selector(addDeallocMethod));
    if (hook == nil) {
        DeallocHook *hook = [DeallocHook new];
        hook.thePoint = self.point;
        objc_setAssociatedObject(self, @selector(addDeallocMethod),hook, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
}

@end

DeallocHook

@interface DeallocHook : NSObject

@property (strong) ZWNKvoPoint *thePoint;

@end

@implementation DeallocHook

- (void)dealloc {
    [self.thePoint removeAll];
    self.thePoint = nil;
}
@end

由于关联对象hook只被主对象所持用,所以当关联对象在主对象调用-dealloc中的object_dispose()中被进行释放后便会被一起释放掉。通过这个办法我们可以获取特定对象的释放时机,对其他没用添加关联对象的对象也不会产生任何影响。

总结

以上这些经过理论和demo初步实践,基本上实现了如何安全、高效的添加KVO,以及智能的自动移除KVO,对未来进行app功能模块化的应用场景希望能够提供帮助。