美文网首页
安全、高效地使用和及时、智能的移除KVO

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

作者: VilaZhang | 来源:发表于2018-01-09 11:21 被阅读0次

    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

    KVO消息转发思维导图.png

    下面是核心代码:

    创建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的有向图.jpeg

    如上图所示,对象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功能模块化的应用场景希望能够提供帮助。

    相关文章

      网友评论

          本文标题:安全、高效地使用和及时、智能的移除KVO

          本文链接:https://www.haomeiwen.com/subject/ajewnxtx.html