阅读 140

iOS KVO 崩溃防护笔记

前言

最近项目中在做 KVO 防护,因此做了一番研究,本文进行一番简单的记录。

调查

网上现有的 KVO 崩溃防护,主要有以下 2 种类型

  1. 对系统的 KVO 接口进行相关的封装,项目中不使用系统提供的 KVO 接口,而是使用封装后的 KVO 相关接口。 如:KVOController
  2. swizzle 系统 KVO 的几个关键函数,做相关的崩溃防护。

由于是对已有项目中添加崩溃防护,所以比较倾向于对第二种方式,对业务影响较小。 相关文章有:

](juejin.im/post/684490…)

相关原理上面文章已经说的很详细了,这里就不再复述,下面主要是实现过程中遇到的几个问题点的记录。

记录

KVO 日常使用造成崩溃的原因通常有以下几个:

  1. KVO 添加次数和移除次数不匹配:

    • 移除了未注册的观察者,导致崩溃。
    • 重复移除多次,移除次数多于添加次数,导致崩溃。
    • 重复添加多次,虽然不会崩溃,但是发生改变时,也同时会被观察多次。
    • 添加和删除的顺序不一致导致崩溃
  2. 被观察者提前被释放,被观察者在 dealloc 时仍然注册着 KVO,导致崩溃。

    • 例如:被观察者是局部变量的情况(iOS 10 及之前会崩溃)。
  3. 添加了观察者,但未实现 observeValueForKeyPath:ofObject:change:context: 方法,导致崩溃。

  4. 添加或者移除时 keypath == nil,导致崩溃。

PS: 引用自 iOS 开发:『Crash 防护系统』(二)KVO 防护

其中对于第 3 点和第 4 点是比较好解决的。

  • swizzle observeValueForKeyPath:ofObject:change:context: 方法,在调用原方法外包一层 try catch 就可以解决第 3 点的崩溃。
  • swizzle addObserver:forKeyPath:options:context:removeObserver:forKeyPath:removeObserver:forKeyPath:context 方法,在里面做 keyPath 的判空或者是直接在原方法外包一层try catch,可以解决第 4 点崩溃。

KVO 添加次数和移除次数不匹配问题

针对于 KVO 添加和移除次数不匹配的问题,整体的解决思路是 swizzle KVO 的添加和删除方法,在添加 KVO 时,将 KVO 的相关信息存储到 KVO 缓存容器中,在删除时,先检查 KVO 缓存容器是否有匹配上的 KVO 信息,如果有,则调用系统的方法删除 KVO,如果没有,则不处理。

在将 KVO 添加到缓存容器中时,网上方案大部分都会通过对比 KVO 的 observerkeyPath 来避免重复添加相关的 KVO,但是由于 KVO 添加的输入参数还有 optionscontext,所以个人认为,应该是 4 个输入参数都相同,才算是同一个 KVO。如果通过 4 个参数进行判断,则判断条件会相对复杂,所以个人在这里采用了另外的一种思路,就是不对 KVO 的重复添加做限制,只防止 KVO 的多次删除导致的崩溃。这个思路有以下好处:

  1. 和系统 KVO 的添加机制一致
  2. 在实际项目中,系统内部框架,的确会重复添加很类似的 KVO, 其中observerkeyPath 是一致的但 optionscontext有所不同,采用此方案因为和系统行为一致,不会产生不可预料的影响。
  3. 重复添加本身就没有崩溃问题,理论上不应该进行防护。

由于上面不对 KVO 的重复添加做相应的限制,因此我们需要考虑以下场景


    NSObject *target = [[NSObject alloc] init];
    
    [target addObserver:self forKeyPath:@"description" options:NSKeyValueObservingOptionNew context:nil];
    [target addObserver:self forKeyPath:@"description" options:NSKeyValueObservingOptionNew context:(__bridge void * _Nullable)(@(1))];
    
    
    [target removeObserver:self forKeyPath:@"description"];
    [target removeObserver:self forKeyPath:@"description" context:(__bridge void * _Nullable)(@(1))];

复制代码

在不做任何崩溃防护的情况下,上面代码你们觉得是否会发生崩溃?实验后,发现还是会发生崩溃,结果如下:

为什么呢?上面代码中 addObserverremoveObserver 的数量是相对应的,为啥还是会发生崩溃。这个和 KVO 的删除机制有关系。

  • 调用 removeObserver:forKeyPath: 方法时,系统是通过 KVO 添加顺序的倒序进行遍历,找到 observerkeyPath 相匹配的 KVO 对象进行删除,如果找不到,则抛出 exception 异常。
  • 调用 removeObserver:forKeyPath:content: 方法时,系统也是通过 KVO 添加顺序的倒序进行遍历,找到 observerkeyPathcontext 都匹配 KVO 对象进行删除,如果找不到,则抛出 exception 异常。这里值得注意的是,如果 contextnil 时,是会查找 context = nil 的 KVO 对象,和 removeObserver:forKeyPath: 的行为并不一致,因为 removeObserver:forKeyPath: 不会对 context 进行匹配。

所以上面的代码在删除 KVO 时,先调用了 removeObserver:forKeyPath: 导致第二个添加的 context = (__bridge void * _Nullable)(@(1)) 的 KVO 对象被删除了,所以后续通过 removeObserver:forKeyPath:context: 删除指定 context 的KVO时,会崩溃报错。

为了保证 KVO 缓存中相关的 KVO 信息和系统中的 KVO 信息是一致的,KVO 缓存的删除也是需要采用相同的规则进行删除。 在保证 KVO 缓存和系统 KVO 列表是一致的情况下,在每次删除 KVO 时,就可以先检查下 KVO 缓存中是否有相关的 KVO 信息,如果有,则进行删除,如果没有,则直接返回,从而避免多次删除导致的崩溃问题。

被观察者提前被释放,被观察者在 dealloc 时仍然注册着 KVO,导致崩溃问题

由于 KVO 主要有 2 个对象 target 和 observer,所以理论上来说,有以下 2 种可能:

  1. target 被释放时,相关的 KVO 没有被释放,会导致崩溃(iOS 11 及以上版本不会)
  2. observer 被释放时, 相关 KVO 没有被释放,不会导致崩溃,但是由于 observer 被提前释放了,导致 target 中 KVO 是没有办法被删除的,因为此时 observer 为 nil。这导致了最终 target 被释放时,会因为相关 KVO 没有被删除而崩溃。(iOS 11 及以上版本不会)

针对该问题,网上主要有 2 种解决思路

  1. swizzle NSObject 的 dealloc 方法,在 hook_dealloc 方法中清理相关的 KVO LSSafeProtector
  2. 为 NSObject 中添加一个关联对象,由于该关联对象只有宿主这个强引用,所以当宿主被释放时,该关联对象也会被释放。所以可以在关联对象的 dealloc 中做相关的释放。关于 dealloc 的详解,可以查看 ARC下,Dealloc还需要注意什么

XXShield 在该文章中有提到这种实现方式

由于个人觉得 swizzle dealloc 对整个系统的影响较大,所以选择采用了第二种思路。

方案一

在被观察者对象中添加一个关联对象 m_kvoInfoManager,在m_kvoInfoManager 中用字典kvoInfoMap存储 KVO 的信息,每次添加或删除 KVO 时,都同步到kvoInfoMap 中。当 m_kvoInfoManager 中的 dealloc 被调用时,检查 kvoInfoMap 并将未删除的 KVO 进行删除。

该方案运用时,遇到的问题是:

  1. 如果 observer 在 target 前先释放了,会导致最终在 m_kvoInfoManagerdealloc 中获取到的 observer 是 nil,所以会有一部分 KVO 无法被删除,最终导致崩溃。
  2. 如果在 observer 的 dealloc 手动调用 removeObserver:forKeyPathremoveObserver:forKeyPath:context: 进行删除操作,由于这 2 个方法已经被我们 swizzle,所以会走我们自己实现的删除方法。在我们拦截的删除方法中,我们会判断将要删除的 observer 和 keyPath 在 KVO 缓存中是否存在相匹配的 KVO Info,这会导致一个令本人比较迷惑的问题。由于是在 observer 的 dealloc 中调用,你会发现此时 KVO 缓存的 KVO Info 对象中,存储 observer 的弱引用指针被设置为 nil,这会导致

info.observer == observer 这个判断不成立,最终不会执行删除 KVO 的操作。

按照我查找到的相关资料(如下),observer 被释放时,所有指向 observer 的 weak 指针,应该是在 clearDeallocating 中才会被释放的,当 observer 的 dealloc 被调用时,应该还没有调用到 NSObject 的 dealloc 方法,所以 clearDeallocating 应该也是没有被调用的,理论上不应该为 nil 才对。

自己稍微测试一下,代码如下:

@interface TestObserableObject()

@property (nonatomic, weak) TestObserableObject *selfTest;
@property (nonatomic, unsafe_unretained) TestObserableObject *unsafeTest;
@end

@implementation TestObserableObject

- (instancetype)init {
    if (self = [super init]) {
        self.selfTest = self;
        self.unsafeTest = self;
        NSLog(@"%@", self.selfTest);
    }
    return self;
}

- (void)dealloc {
    NSLog(@"%@", self.selfTest);
    NSLog(@"%@", _selfTest);
    NSLog(@"%@", self.unsafeTest);
}

@end
复制代码

控制台打印结果如下:

说明此时 weak 指针的确被设置为了 nil。留待后续查找原因。

总结一下方案一 如果 target 先释放,那么 target 关联对象的 dealloc 会被正常调用,此时 target (关联对象会有一个 unsafe_unretained 指针指向宿主对象,也就是 target) 和 observer 都是存在的,可以正常清空未删除的 KVO。 如果 observer 先释放,无论用户是否自己手动在 dealloc 中调用删除方法,都会导致部分 KVO 没有被释放成功,最终会产生异常(iOS 11 以下)

方案二

从方案一看来,target 先于 observer 释放,可以正常运行。observer 先于 target 释放,会导致异常。根本原因是由于 observer 释放后,相关 weak 引用就会被设置为 nil,最终导致程序异常。

既然 observer 先被释放时会导致问题,那么,能不能在 observer 被释放时,就把和 observer 相关的 KVO 都删除掉呢。我们在 observer 中也添加一个关联对象,当我们向 target 中添加或删除 observer 进行 KVO 时,我们除了向 target 的关联对象中添加或删除一个 KVO Info,在 observer 的关联对象中,也添加相应的 KVO info。这样做的作用如下:

  1. 假设 target 先被释放了,在 target 的关联对象中的 dealloc 中,可以获取 target,并且此时 KVO Info 中存储的 observer 也没有被设置为 nil,所以此时可以正常删除相关 KVO。
  2. 假设 observer 先被释放,在 observer 的关联对象中的 dealloc 中,可以获取到 observer 对象,并且 KVO Info 中的 target 弱引用也没有被设置为 nil,所以此时也是可以把相关的 KVO 信息删除掉。

另一种类似的实现方式是用一个 KOVInfoManager 来管理所有的 KVO 信息,KOVInfoManager 中会有 2 个 Map,分别是 targetObserverMapobserverTargetMaptargetObserverMap 是的数据格式是 { targetAddress: { observerAddress: [ KVOInfo] }}observerTargetMap 的数据格式类似,只是以 observerAddress 作为第一级 Key 值。

综上所述,无论哪一个对象先被释放掉了,都可以正确的清理掉以这个对象作为 target 或 observer 的相关 KVO,从而保证所有的 KVO 都可以在 dealloc 中被删除掉。

测试数据

observer 没有实现 observeValueForKeyPath:ofObject:change:context


@interface TestObserableObject : NSObject

@property (nonatomic, strong) NSString *test;

@end

@implementation TestObserableObject
@end

@interface ViewController ()

@property (nonatomic, strong) TestObserableObject *target;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    self.target = [[TestObserableObject alloc] init];

    [self.target addObserver:self forKeyPath:@"test" options:NSKeyValueObservingOptionNew context:nil];
    
    self.target.test = @"测试1";
}

@end
复制代码

多次删除同一个 KVO,或删除没有添加的 KVO

  self.target = [[TestObserableObject alloc] init];
    
    [self.target addObserver:self forKeyPath:@"test" options:NSKeyValueObservingOptionNew context:nil];
        
    [self.target removeObserver:self forKeyPath:@"test" context:nil];
    [self.target removeObserver:self forKeyPath:@"test1" context:nil];
    [self.target removeObserver:self forKeyPath:@"test2" context:nil];
    [self.target removeObserver:self forKeyPath:@"description" context:nil];

复制代码

添加和删除顺序不匹配时

  self.target = [[TestObserableObject alloc] init];
    
    [self.target addObserver:self forKeyPath:@"test" options:NSKeyValueObservingOptionNew context:nil];
    [self.target addObserver:self forKeyPath:@"test" options:NSKeyValueObservingOptionNew context:(__bridge void * _Nullable)(@(1))];
    [self.target addObserver:self forKeyPath:@"test" options:NSKeyValueObservingOptionNew context:(__bridge void * _Nullable)(@(2))];
    [self.target addObserver:self forKeyPath:@"test" options:NSKeyValueObservingOptionNew context:(__bridge void * _Nullable)(@(3))];

       
    [self.target removeObserver:self forKeyPath:@"test"];
    [self.target removeObserver:self forKeyPath:@"test" context:(__bridge void * _Nullable)(@(3))];
    [self.target removeObserver:self forKeyPath:@"test"];
    [self.target removeObserver:self forKeyPath:@"test" context:(__bridge void * _Nullable)(@(2))];

复制代码

target 先于 observer 释放,并且在释放前没有清空 KVO

    self.target = [[TestObserableObject alloc] init];

    [self.target addObserver:self forKeyPath:@"test" options:NSKeyValueObservingOptionNew context:nil];

    self.target = nil;

复制代码

observer 先于 target 释放,并且释放前没有清空 KVO

    self.target = [[TestObserableObject alloc] init];
    
    @autoreleasepool {
        NSObject *observer = [[NSObject alloc] init];
        [self.target addObserver:observer forKeyPath:@"test" options:NSKeyValueObservingOptionNew context:nil];
    }
    self.target = nil;

复制代码

多线程添加删除测试

       
    self.target = [[TestObserableObject alloc] init];
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
    
        for (int i = 0; i < 100000; i++) {
            [self.target addObserver:self forKeyPath:@"test" options:NSKeyValueObservingOptionNew context:nil];
        }
        
    });
    
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        
            for (int i = 0; i < 100000; i++) {
                [self.target removeObserver:self forKeyPath:@"test"];
            }

    });

复制代码