[iOS] 一次 Block 引起的循环引用的探索

在测试 NSNotificationCenter 使用 Block 添加通知的过程中,发现一个有趣的现象,特此记录一下。

1. Block 与循环引用

不合理地使用 Block ,可能就会造成循环引用,导致内存泄露。

NSNotificationCenter 的添加通知的方法有两种,一种是使用 SEL 添加,另一种则是使用 Block 添加。使用 Block 添加的通知需要手动移除,而使用 SEL 添加的通知在 iOS 9.0 +的版本中会自动移除。

当使用 Block 添加通知的接口时,很容易认为该 Block 只是作为一个参数,并不会引起循环引用的情况。

但在测试的时候发现,如果不使用 weak 进行修饰,就会导致通知接收对象无法释放。

简单通过程序验证一下。

页面逻辑为:ViewController 页面通过点击按钮跳转至 BlockViewController 页面。当 BlockViewController 返回时,页面应该被释放。但是实际上,BlockViewController 没有进入 dealloc 函数,即没有释放。

先放错误的写法。

@interface BlockViewController ()

@property(nonatomic, strong) NSString *name;

@property(nonatomic, strong) id observer;

@end

@implementation BlockViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    
    self.name = @"BlockViewController";
}

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    
    NSLog(@"%s", __FUNCTION__);
    
    self.observer = [[NSNotificationCenter defaultCenter] addObserverForName:@"Test" object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
        NSLog(@"%@", self.name);
    }];
}

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    
    NSLog(@"%s", __FUNCTION__);
    
    [[NSNotificationCenter defaultCenter] removeObserver:self.observer name:@"Test" object:nil];
}

- (void)dealloc {
    NSLog(@"%s", __FUNCTION__);
}

@end

再放正确的写法。使用 weak 修饰 BlockViewController,可以解决上面代码中 BlockViewController 与 Block 循环引用的情况。

@interface BlockViewController ()

@property(nonatomic, strong) NSString *name;

// Block 方式添加的通知,需要对添加时返回的 observer 强引用,用来手动移除通知
@property(nonatomic, strong) id observer;

@end

@implementation BlockViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view.
    
    self.name = @"BlockViewController";
}

- (void)viewWillAppear:(BOOL)animated {
    [super viewWillAppear:animated];
    
    NSLog(@"%s", __FUNCTION__);
    
    // 使用 weak 解决循环引用的问题
    __weak typeof(self) weakSelf = self;
    self.observer = [[NSNotificationCenter defaultCenter] addObserverForName:@"Test" object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) {
        NSLog(@"%@", weakSelf.name);
    }];
}

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    
    NSLog(@"%s", __FUNCTION__);
    
    // Block 方式添加的通知,必须手动移除
    [[NSNotificationCenter defaultCenter] removeObserver:self.observer name:@"Test" object:nil];
}

- (void)dealloc {
    NSLog(@"%s", __FUNCTION__);
}

@end


// -------- 运行结果 --------
// 2020-03-31 11:05:59.275320+0800 TestBlock[13172:7871566] -[ViewController buttonEvent:]
// 2020-03-31 11:05:59.286398+0800 TestBlock[13172:7871566] -[BlockViewController viewWillAppear:]
// 2020-03-31 11:06:01.526375+0800 TestBlock[13172:7871566] -[BlockViewController viewWillDisappear:]
// 2020-03-31 11:06:02.039568+0800 TestBlock[13172:7871566] -[BlockViewController dealloc]

 对比两种写法的运行结果,可以猜测,当 NSNotificationCenter 中使用 Block 添加通知时,使 BlockViewController 对 Block 建立了直接或间接的强引用管理。同时,由于 Block 本身存在对 BlockViewController 的强引用,导致 BlockViewController 和 Block 形成了引用环,BlockViewController 无法释放。

2. Leak 与循环引用

Leak,Xcode 自带的 Instruments 中的工具,用来检测和定位内存泄漏的问题。

对于以上的猜测是否成立,可以使用 Leaks 来进行验证。然而在验证的过程中,却发现了意料之外的现象。

这是错误写法的 Leak 检测。1 代表第一次进入 BlockViewController 页面,2 代表第一次退出 BlockViewController 页面,3 代表第二次进入 BlockViewController 页面,4 代表第二次退出 BlockViewController 页面。

这是正确写法的 Leak 检测。1、2、3、4的意义与上相同。

抛弃前面的小绿点不谈,先来看看错误写法中小红点的详情。

Leaks 很形象地绘制出了导致循环引用的变量关系图。这图也验证了前面的猜想。

使用 Block 添加通知时的返回结果,正是这个通知的 observer。打印这个 observer 的类型,就是 __NSObserver。

现在,引用关系就很清晰了。错误的写法中,BlockViewController 强引用 __NSObserver,__NSObserver 强引用 Block, Block 强引用 BlockViewController,一个引用环形成了。而正确的写法中,使用了 weak 打破了 Block 对 BlockViewController 的强引用,因此能够正常释放。

但是回过头查看错误写法的 Leaks 运行结果,却出现了另外一个问题。

在错误的写法中,在第一次退出 BlockViewController 页面后,由于 BlockViewController 和 Block 之前形成引用环而没有释放。但是,为什么第一次退出 BlockViewController 页面后,没有出现 Leak 中标志性的小红叉,而是在第二次进入后才出现呢?

3. Leaks 文档

遇事不决,先查官方文档。

Find memory leaks 文档第一段,就写清楚了 Leaks 判断内存泄漏的标准。

The Leaks profiling template uses the Allocations and Leaks instruments to measure general memory usage in your app and check for leaks—memory that has been allocated to objects that are no longer referenced and reachable.

简单翻译一下,泄漏指的是,不再被引用且不可被访问到的已分配内存的对象。

按照这个定义,难道在第一次页面退出后,BlockViewController 或者 Block 除了相互引用之外,还被其它的变量引用?

只借助 Leaks,很难看清这个问题的答案。

4. Debug Memory Graph

为了看清第一次退出 BlockViewController 页面时的内存详细情况,可以使用 Xcode 自带的 Debug Memory Graph 进行调试。

在 Edit Scheme-Run-Diagnostics 中勾选“Malloc Scribble”和“Malloc Stack(Live Allocations Only)”,重新调试运行错误写法的项目,第一次退出 BlockViewController 页面后,点击下图中红框按钮,查看内存情况。

 在左侧的列表中,发现退出 BlockViewController 页面后,这个页面依然存在,但是并没有报内存泄漏。如果 Debug Memory Graph 检测到内存泄漏,则会在泄漏的对象右侧显示一个紫色的感叹号。

 选择这个对象,可以看到这个对象的引用关系。发现,在退出这个页面后,UINavigationController 居然还有着对 BlockViewController 的引用,导致检测系统认为 BlockViewController 对象仍然可达,因此没有报出内存泄漏的警告

当第二次进入  BlockViewController 页面后,Debug Memory Graph 中就会出现 BlockViewController 对象泄漏的警告。

 借助 Debug Memory Graph 工具,解释了为什么在第一次退出时,系统没有检测出内存泄漏,而在第二次进入时才检测到的问题。

对比一下正确写法在第一次退出时的 Debug Memory Graph 调试结果。

正确的写法中,从左侧的列表中已经找不到 BlockViewController。那么,在错误写法中,为什么在未释放的 BlockViewController 页面 pop 之后,UINavigationController 仍然保留着对 BlockViewController 的引用呢?

仔细观察错误写法的内存关系引用链,可以看出,实际上是 childViewControllers 保持着对 BlockViewController 的引用,而 childViewControllers 的本质是 NSMutableArray。这就联想到,childViewControllers 和 BlockViewController 引用的保持是否与 NSMutableArray 的实现原理有关呢?

PS:如果直接打印 childViewControllers 的 count,页面退出后,数量的确减少了,这只有 ViewController 一个 对象。而其通过 NSLog 和 po 方式打印 [childViewControllers class] 的结果,都是 __NSSingleObjectArrayI 或者 __NSArrayI,而不是 __NSArrayM。 

5. NSMutableArray 实现

完整的 NSMutableArray 实现原理分析,请参考下面这篇文章:

Exposing NSMutableArray (英文原版)

NSMutableArray原理揭露 (中文译版)

Ciechan/NSMutableArrayExplorer (github)

根据 NSMutableArray 删除对象时的打印日志,发现 NSMutableArray 只将数量和偏移量减少,并没有将该移除的对象从 NSMutableArray 清除,而是将其指针保留在原位。

这也就解释了,为什么 BlockViewController 已经被 pop,从 childViewControllers 的动态数组中移除,但是 childViewControllers 依然留着 BlockViewController 引用的问题。

直到第二次进入 BlockViewController 页面时,新加入的 BlockViewController 对象会替换 childViewControllers 的动态数组中原来位置上的 BlockViewController 对象,导致 childViewControllers 失去了对旧的 BlockViewController 对象的引用。

因此在第二次进入 BlockViewController 页面后,Leaks 和 Debug Memory Graph 才能检测到了内存泄漏。

6. 文末总结

本文主要涉及到了,使用 Block 添加通知时可能造成的循环引用问题,借助 dealloc 函数、 Leaks 工具和 Debug Memory Graph 工具分析循环引用的原因,页面 pop 后 UINavigationController 仍然保留页面引用的问题。

总而言之,Block 使用时必须要注意循环引用的问题。在使用某些系统函数时,如 UIView 的 Animation、GCD 的各种接口中,Block 会作为接口的一个参数,因为没有形成引用环,可以不使用 weak 来避免循环引用的情况。但是当使用含有 Block 参数的接口,且对这个接口的实现不太了解时,谨慎起见,加上 weak!

如果文中尚有不足之处,还烦请路过的大佬指正,谢谢!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值