安全、高效地使用和及时、智能的移除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

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功能模块化的应用场景希望能够提供帮助。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 222,252评论 6 516
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,886评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 168,814评论 0 361
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,869评论 1 299
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,888评论 6 398
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,475评论 1 312
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 41,010评论 3 422
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,924评论 0 277
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,469评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,552评论 3 342
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,680评论 1 353
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,362评论 5 351
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 42,037评论 3 335
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,519评论 0 25
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,621评论 1 274
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 49,099评论 3 378
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,691评论 2 361

推荐阅读更多精彩内容

  • KVO 作为 iOS 中一种强大并且有效的机制,为 iOS 开发者们提供了很多的便利;我们可以使用 KVO 来检测...
    JzRo阅读 943评论 0 2
  • 上半年有段时间做了一个项目,项目中聊天界面用到了音频播放,涉及到进度条,当时做android时候处理的不太好,由于...
    DaZenD阅读 3,021评论 0 26
  • 转至元数据结尾创建: 董潇伟,最新修改于: 十二月 23, 2016 转至元数据起始第一章:isa和Class一....
    40c0490e5268阅读 1,726评论 0 9
  • KVO 作为 iOS 中一种强大并且有效的机制,为 iOS 开发者们提供了很多的便利;我们可以使用 KVO 来检测...
    Draveness阅读 6,900评论 11 59
  • 压力好大,不知后面怎么弄啊,压力山大啊
    烟雨48阅读 161评论 0 0