Crash拦截器 - KVO崩溃破除(再也不用担心KVO让你崩溃)

在本文中,我们将了解到如下内容:

  1. 明晰KVO中的观察者和被观察者
  2. KVO导致崩溃的情况一览
  3. 破除KVO崩溃的方案

前言

KVO(Key Value Observing) 也就是键值对观察,它是iOS中观察者模式的一种实现。KVO方便了我们做很多事情,但是在提供方便的时候,同样给我们带来了麻烦-最最最烦人的崩溃问题。
本文,我们将讨论KVO导致崩溃的各种情况,以及给出解决这些崩溃问题的方案。

观察者和被观察者

在进行具体内容的讨论之前,我们先对观察者被观察者这两个在KVO中的角色进行明晰。
我们看如下代码:

 1. [self addObserver:self.myView forKeyPath:@"myLabel.text" options:NSKeyValueObservingOptionNew context:nil];
 2. [self.myView.myLabel addObserver:self forKeyPath:@"text" options:NSKeyValueObservingOptionNew context:nil];

对于代码 1 而言,其中观察者self.myView被观察者self
对于代码 2 而言,其中观察者self被观察者self.myView.myLabel
简单来讲,在addObserver左边的是被观察者,在右边的是观察者
keyPath必须是在被观察者中的有效路径。
被观察者的被观察的属性发生变化时,将会由观察者中的observeValueForKeyPath:ofObject:change:context:方法进行响应。

KVO导致崩溃的情况一览

我们现在先来把KVO导致崩溃的原因挨个撸出来,然后再想办法解决掉所有的这些问题(思路清晰,没毛病。我们解决所有问题的思路都应该是这样的)。
下面我们先列举出我们了解到的所有引起崩溃的原因:

  1. 添加或移除观察时,keypath长度为0。
  2. 观察者忘记写监听回调方法observeValueForKeyPath:ofObject:change:context:
  3. 添加和移除观察的次数不匹配
    • 观察者dealloc后没有移除监听。
    • 移除未添加监听的观察者。
    • 多次添加和移除观察者,但添加和移除的次数不相同。
  4. 观察者和被观察者生命周期不一致,其中一个被释放,而另一个未被释放(比如两个局部变量之间添加观察)
    • 被观察者被提前释放,iOS10及以前会崩溃(笔者未能复现)。
    • 观察者提前被释放,如果未移除观察,则会崩溃。

PS:对于上面列举到的各种情况,笔者在这里说明一下。观察者dealloc后没有移除监听* 这一情况应该是在iOS9中就被修复了,但是我找不到书面证据(略显尴尬)。被观察者被提前释放,iOS10及以前会崩溃 这一情况我没有弄出来,所以不是很确定其导致崩溃的原因,本文中将不会对其进行讨论。*

破除KVO崩溃的思路

我们的目标是解决掉上述所有问题,并且要保证无侵入性
基于这样的目的,我们有如下的思路:

  1. NSObject的分类中,使用Method Swizzling拦截addObserver:forKeyPath:options:context:removeObserver:forKeyPath:方法。removeObserver:forKeyPath:context:会在判断context是否一致之后,再调用removeObserver:forKeyPath:移除监听。所以我们不置换removeObserver:forKeyPath:context:方法。
  2. 我们创建一个KVOProxy作为中间者,目的是使用KVOProxy代替对象完成所有的观察和分发通知的功能。
  3. 观察者添加观察时,我们使KVOProxy作为真正的观察者去添加对被观察者的观察,当被观察者的属性值有变化时,KVOProxy接收observeValueForKeyPath:ofObject:change:context:,然后再根据keypathofObject两个参数去找到并通知观察者
  4. 观察者移除观察时,我们在KVOProxy找到需要移除的观察,再对观察进行移除。

整体思路如上,这样述说给我们的感觉很模糊,我们还是回我们最熟悉的方式:看代码。

破除KVO崩溃的实现

首先我们先定义我们会使用到的类,中间者KVOProxy

@interface KVOProxy : NSObject

- (void)proxy_addObserverWithProxyItem:(KVOProxyItem *)proxyItem didAddBlock:(dispatch_block_t)didAddBlock;
- (void)proxy_removeObserved:(NSObject *)observed keyPath:(NSString *)keyPath didRemoveBlock:(dispatch_block_t)didRemoveBlock;
- (void)proxy_removeAllObserver;

@end

KVOProxy有一些方法,我们先不做解释,后续使用到时,我们再进行详细讨论。

NSObject分类会添加类型为KVOProxy的成员变量kvoProxy,代码如下:

@interface NSObject (KVOProxy)

@property (nonatomic, readonly, strong) KVOProxy *kvoProxy;

@end

我们再定义NSObject使用的用于保存通知相关信息的对象KVOProxyItem,代码如下:

@interface KVOProxyItem : NSObject

@property (nonatomic, weak) id observed; // 弱引用被观察者,防止循环引用
@property (nonatomic, weak) id observer; // 弱引用观察者,如果观察者被释放,这里将会变为nil
@property (nonatomic, copy) NSString *keyPath;
@property (nonatomic, assign) NSKeyValueObservingOptions options;
@property (nonatomic, assign) void *context;

- (instancetype)initWithObserver:(id)observer observed:(id)observed keyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context;

@end

我们在KVOProxyItem保存了通知相关的所有属性,其中观察者(observer)和被观察者(observed)都是使用的弱引用。当观察者被观察者被释放后,observeValueForKeyPath:ofObject:change:context:如果被调起,我们可以判断是否需要发送通知到观察者

KVOProxy的私有属性定义如下:

@interface KVOProxy ()

@property (nonatomic, assign) pthread_mutex_t mutex;
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSMutableSet<KVOProxyItem *> *> *proxyItemMap;

@end

互斥锁mutex,考虑到可能会在多线程中添加或移除通知,所以我们需要做一些同步操作。
数据结构proxyItemMap,该字典中的keyKVOkeyPath。字典中的valueKVOProxyItem组成的集合,该集合保存了观察者kvoProxy是当前KVOProxyItem对象的所有KVO对应的KVOProxyItem对象。
上面这段话读起来可能比较绕口(表达能力就在这里,大家担待点~~),我再对这段话做进一步的解释:
我们会在addObserver:forKeyPath:options:context:时,创建一个KVOProxyItem对象:

 // 创建KVOProxyItem
 KVOProxyItem *item = [[KVOProxyItem alloc] initWithObserver:observer observed:self keyPath:keyPath options:options context:context];

那么这个item就是这次KVO对应的KVOProxyItem对象。

我们再以下面这行代码为例:

[self addObserver:self.myView forKeyPath:@"myLabel.text" options:NSKeyValueObservingOptionNew context:nil];

这行代码执行之后,会在self.myViewkvoProxyproxyItemMap中以@"myLabel.text"key的集合中,添加一个这次KVO对应的KVOProxyItem对象。

上面说了这么多,我们还是先把相关的代码贴出来,大家一起过过眼吧。

NSObject的代码如下:

@implementation NSObject (KVOProxy)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [self kvo_swizzleSelector:@selector(addObserver:forKeyPath:options:context:)
                       toSelector:@selector(kvo_addObserver:forKeyPath:options:context:)];
        [self kvo_swizzleSelector:@selector(removeObserver:forKeyPath:)
                       toSelector:@selector(kvo_removeObserver:forKeyPath:)];
    });
}

/*
 removeObserver:forKeyPath:context: 会在判断context是否一致之后,再调用removeObserver:forKeyPath:移除监听
 */
- (void)kvo_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context {
    if (keyPath.length <= 0) {
        NSLog(@"keyPath is empty for observer:%@...", observer);
        return;
    }
    
    if ([observer isKindOfClass:NSClassFromString(@"NSKeyValueObservance")]) {
        [self kvo_addObserver:observer forKeyPath:keyPath options:options context:context];
        return;
    }
    
    // 创建KVOProxyItem
    KVOProxyItem *item = [[KVOProxyItem alloc] initWithObserver:observer observed:self keyPath:keyPath options:options context:context];
    
    // 向观察者的kvoProxy添加KVOProxyItem,如果成功则在self作为被观察者添加观察者observer.kvoProxy
    [observer.kvoProxy proxy_addObserverWithProxyItem:item didAddBlock:^{
        [self kvo_addObserver:observer.kvoProxy forKeyPath:keyPath options:options context:context];
    }];
}

- (void)kvo_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath {
    [observer.kvoProxy proxy_removeObserved:self keyPath:keyPath didRemoveBlock:^{
        [self kvo_removeObserver:self.kvoProxy forKeyPath:keyPath];
    }];
}

- (KVOProxy *)kvoProxy {
    id proxy = objc_getAssociatedObject(self, _cmd);
    if (proxy == nil) {
        proxy = [KVOProxy new];
        objc_setAssociatedObject(self, _cmd, proxy, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    }
    return proxy;
}

@end
  1. 首先,在load中进行方法交换。
  2. kvo_addObserver:forKeyPath:options:context:中进行判断,如果keyPath.length <= 0,则直接返回,避免出现添加观察时,keypath长度为0的问题。
  3. 创建KVOProxyItem对象。
  4. 观察者kvoProxy中添加KVOProxyItem对象,在添加成功的回调中,执行真正的添加观察的操作。但是此时的观察者已经换成了kvoProxy
  5. kvo_removeObserver:forKeyPath:中根据keyPath被观察者观察者kvoProxy中移除对应的KVOProxyItem对象。在移除成功的回调中执行真正的移除操作。

在步骤4中,我们可以避免多次添加观察。

在步骤5中,我们可以避免移除不存在的观察。

细心的我们肯定看到了这样的代码:

    if ([observer isKindOfClass:NSClassFromString(@"NSKeyValueObservance")]) {
        [self kvo_addObserver:observer forKeyPath:keyPath options:options context:context];
        return;
    }

这是因为我们在添加KVO时,如果keyPath是多级,那么系统会自动拆分成多级进行监听。我们打印了整个过程,得到如下数据:

observer:MyView - keyPath:myView.myLabel.text
observer:NSKeyValueObservance - keyPath:myLabel.text
observer:NSKeyValueObservance - keyPath:text

keyPath逐级变化,而系统添加的后续步骤的观察者NSKeyValueObservance对象,所以我们需要进行一次过滤。

KVOProxy的代码如下:

@implementation KVOProxy

- (void)dealloc {
    // 被释放前移除所有观察
    [self proxy_removeAllObserver];
    pthread_mutex_destroy(&(_mutex));
}

- (instancetype)init {
    self = [super init];
    if (self) {
        pthread_mutex_init(&(_mutex), NULL);
        self.proxyItemMap = @{}.mutableCopy;
    }
    return self;
}

#pragma mark - KVO Handle
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey, id> *)change context:(void *)context {
    if (keyPath.length <= 0 || object == nil) {
        return;
    }
    
    [self lock];
    
    __block KVOProxyItem *item = nil;
    NSSet *set = self.proxyItemMap[keyPath];
    [set enumerateObjectsUsingBlock:^(KVOProxyItem *obj, BOOL * _Nonnull stop) {
        if (object == obj.observed &&
            self == [obj.observer kvoProxy]) {
            *stop = YES;
            item = obj;
        }
    }];
    
    [self unlock];
    
    if (item == nil) {
        return;
    }
    
    // 如果观察者被提前释放,则打印错误信息
    if (item.observer == nil) {
        NSLog(@"observer is nil when %@ observe keyPath:%@", [item.observed class], item.keyPath);
        return;
    }
    
    // 判断当前观察者是否实现了方法observeValueForKeyPath:ofObject:change:context:
    // 这个地方用respondsToSelector:检测,没有用(未实现,也返回YES)
    SEL selector = @selector(observeValueForKeyPath:ofObject:change:context:);
    
    BOOL exist = NO;
    unsigned int count = 0;
    Method *methoList = class_copyMethodList([item.observer class], &count);
    for (int i = 0; i < count; i++) {
        Method method = methoList[i];
        if (method_getName(method) == selector) {
            exist = YES;
            break;
        }
    }
    
    if (!exist) {
        /*
         An -observeValueForKeyPath:ofObject:change:context: message was received but not handled.
         */
        NSLog(@"observer:%@ can not respond observeValueForKeyPath:ofObject:change:context:", item.observer);
        return;
    }
    
    // 发送事件
    [item.observer observeValueForKeyPath:keyPath ofObject:object change:change context:item.context];
}

#pragma mark - Public Methods
- (void)proxy_addObserverWithProxyItem:(KVOProxyItem *)proxyItem didAddBlock:(dispatch_block_t)didAddBlock {
    if (proxyItem == nil) {
        return;
    }
    
    if (proxyItem.keyPath.length <= 0) {
        NSLog(@"keyPath is empty for observer:%@...", proxyItem.observer);
        return;
    }
    
    [self lock];
    
    __block BOOL added = NO;
    NSMutableSet *set = self.proxyItemMap[proxyItem.keyPath];
    [set enumerateObjectsUsingBlock:^(KVOProxyItem *obj, BOOL * _Nonnull stop) {
        if (obj.observer == proxyItem.observer &&
            obj.observed == proxyItem.observed) {
            *stop = YES;
            added = YES;
        }
    }];
    
    if (added) {
        NSLog(@"observer:%@ for keyPath:%@ is added", [proxyItem.observer class], proxyItem.keyPath);
        [self unlock];
        return;
    }
    
    if (set == nil) {
        set = [NSMutableSet set];
        [self.proxyItemMap setObject:set forKey:proxyItem.keyPath];
    }
    
    [set addObject:proxyItem];
    
    [self unlock];
    
    // 必须解锁之后再进行回调,否则会导致启动后屏幕不显示内容
    didAddBlock();
}

- (void)proxy_removeObserved:(NSObject *)observed keyPath:(NSString *)keyPath didRemoveBlock:(dispatch_block_t)didRemoveBlock {
    if (observed == nil || keyPath.length <= 0) {
        return;
    }
    
    [self lock];
    
    NSMutableSet *set = self.proxyItemMap[keyPath];
    __block KVOProxyItem *item = nil;
    [set enumerateObjectsUsingBlock:^(KVOProxyItem *obj, BOOL * _Nonnull stop) {
        // 这里可能因为observed已经被释放掉,导致判断出错
        // 但是判断出错对逻辑不影响,因为如果要向observed发送变更通知,observed必须不为nil
        if (observed == obj.observed) {
            item = obj;
            *stop = YES;
        }
    }];
    
    if (item) {
        [set removeObject:item];
        didRemoveBlock();
    }
    
    [self unlock];
}

- (void)proxy_removeAllObserver {
    [self.proxyItemMap enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, NSMutableSet<KVOProxyItem *> * _Nonnull obj, BOOL * _Nonnull stop) {
        [obj enumerateObjectsUsingBlock:^(KVOProxyItem * _Nonnull obj, BOOL * _Nonnull stop) {
            [obj.observed removeObserver:self forKeyPath:obj.keyPath];
        }];
    }];
}

#pragma mark - Private Methods
- (void)lock {
    pthread_mutex_lock(&(_mutex));
}

- (void)unlock {
    pthread_mutex_unlock(&(_mutex));
}

/// 根据指定的keyPath和observed在proxyItemMap查找KVOProxyItem
- (KVOProxyItem *)proxyItemForKeyPath:(NSString *)keyPath observed:(id)observed {
    NSMutableSet *set = self.proxyItemMap[keyPath];
    __block KVOProxyItem *item = nil;
    [set enumerateObjectsUsingBlock:^(KVOProxyItem *obj, BOOL * _Nonnull stop) {
        if (observed == obj.observed) {
            item = obj;
            *stop = YES;
        }
    }];
    return item;
}

@end
  1. proxy_addObserverWithProxyItem:didAddBlock:中。
    • 我们对keyPath不合法的监听进行过滤。并且如果发现有keyPathobserverobserved的监听,则认为是重复添加,我们则不再添加新的监听。
  2. proxy_removeObserved:keyPath:didRemoveBlock:中。
    • 我们对keyPathobserverobserved进行匹配。如果发现有一致的,则移除监听。如果没有,则不做移除操作。从而避免过多移除监听而造成的崩溃。
  3. dealloc中,我们移除self的所有监听,防止出现对象被释放,但是未移除监听的问题。
  4. observeValueForKeyPath:ofObject:change:context:中。
    • 我们keyPathobserverobserved进行匹配,只有在匹配到之后,才会进行通知的分发。
      此时,当observerobservednil时,是无法进行分发的,从而避免了observerobserved被填释放导致崩溃的问题。
    • 在真正分发之前,我们需要判断observer是否实现了方法observeValueForKeyPath:ofObject:change:context:,如果未实现,则不进行分发。从而避免其引起的崩溃问题。

以上,就是关于KVO崩溃破除的所有解释了,文章写得可能不够清晰。但是笔者已经很努力了,如果不够好,只能请大家谅解了(手动脸红)。

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

推荐阅读更多精彩内容