在我们日常开发中,我们或多或少的都会遇到循环引用的问题。其实问题的实质就是造成了互相持有的关系,在对象释放的时候,就好像产生了一个死锁一样,系统没有办法释放其中的任何一个对象,就造成了内存泄露的问题。我们都知道 NSTimer 是其中的典型。可是为什么继承自 UIControl 类的对象同样调用 addtarget 的方法就不会造成内存泄露的问题呢?现在就开启本文的探索。
这是苹果做的一种设计模式,在设置 target 对象之后,该对象可以执行对应的 Selector。我们可以看到在我们的项目中,经常在使用 UIButton,UISegmentedControl 等继承自 UIControl 的类时调用
- (void)addTarget:(nullableid)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents; 这个方法,但是从代码可读性的角度考虑,这样的并不是特别的好,我们也经常为这些类写扩展,完成 block 的调用。可这种方式为什么会存在,不是设计成 block 回调。其实这个原因个人认为有两个。
1. 在 storyboard 下,将 selector 连接出来就是使用的这一模式,这样的模式个人认为在这种情况下还是很强大的。
2. 其实这个模式是伴随整个 OC 的版本的,而 block 是在 iOS4 的时候才推出的。所以在开始的时候 Target-Action 的模式看起来真的很强大。而且我发现在 iOS10 中,苹果已经在 NSTimer 类中添加了 block 的方式,其实这时候我们循环引用的问题可以用 block 的方式,但也只能在 iOS10 的时候使用。
其它关于此模式的思考不再扩展,网上相关的文章很多,Google 一下有很多,本文的核心在于去深入的研究一小下。
上面是我们调用的时候会调用的方法,但是 UIButton 不会造成循环引用,但是 NSTimer 为什么会造成循环引用的问题呢?从这个问题出发,我查看了 UIControl 和 NSTimer 的官方文档,对于这里的解释真的是聊聊无几,我没有找到强有力的证据能够说明其中的原因,但是我们思考下猜想应该是 UIControl 机制下一定是底层将 self 弱引用了,解开了循环的链,所以 UIControl 下没有这样的操作。从这个角度出发,我去 Google 了一下,看了一些相关的文章,发现可以在堆栈信息中看出一些猫腻。那么现在看一下我们堆栈信息中我们能够发现什么.
首先我们看一下使用 LLDB 方案我们获取到的信息是不是可以为我们所用呢?我分别在两个 addTarget 方法出下了断点。然后在控制台输入 dis,打印当前堆栈的调用信息,结果如下。
在看到这个堆栈信息的时候我发现对于同一块内存的引用方式竟然完全是一样的,这就更加增加了我的好奇,这里的堆栈信息完全不能解答现有的疑问,还有其他的方式么?后来想到调用方法的堆栈,去看方法到底做了什么也许更清晰,我们能够清晰地知道方法中用到了什么,于是在项目中添加了如下两个 symbolic breakpoint 断点践行进行测试。
此时重新跑程序,在每个断点执行的时候,我们可以看到对应的堆栈信息如下。
通过上图的两张堆栈信息,我们可以看到在 UIControl 下的 target 的持有方式确实是 weakRetained 弱持有的方式解开了引用循环,所以我们在使用时不会出现引用循环的问题。但是在 NSTimer 下,我看到的堆栈信息中看到这行代码的时候,开始明白机制的原理了,在 NSTimer 机制下对 Target 持有的方式使用的是 autorelease 的方式,也就是说 target 会在 runloop 下一次执行的时候查看这块区域是否进行释放,这也就能解释为什么我们如果将 repeats 属性设置成 NO 内存可以释放的原因,以及为什么将 self 设置成 nil 后内存依然不释放的原因。接下来我对 invalidate 方法打印堆栈信息,但是我发现没有对应方法的堆栈信息,反而会再次调用 addtarget 方法,这是我联想到 NSTimer 的官方文档中有说明,一旦调用了 invalidate 方法之后,这个 timer 就不能再使用,我认为底层这个时候就是个当前的 timer 进行了一个 target 的重定向,正好执行一次 runloop 的 timerobserver 监听,将之前的内存释放掉了,然后解开了引用的循环,现在我们已经明白了原理,那么我们就从原理出发,看看现有的解决方案是否合理。
我百度了一下 NSTimer 循环引用的问题,归纳总结一下,大概的解决方案是
1)及时的调用 invalidate 方法
2)给 NSTimer 写一个扩展类,然后使用 block 回调的方式
3)在给 self 增加代理的时候创建中间层代理。
那么我们现在看到三个方法的时候,首先知道方法一重定向的方式在上边已经知晓了能够解决问题的原因,那么我们看下方法 2 和方法 3 是不是能够解决问题。
首先方法二实现的核心代码大致如下
看完上边的代码,我们发现此时的 target 为 NSTimer 类对象,其实本身就是一个单例,所以会伴随程序的整个生命周期,所以程序是不是保留对他的循环引用都已经无所谓,所以不会造成内存泄露的问题,但是我们需要思考的一件事,我们的程序还是依然会在我们看不到的地方不停地去执行 repeats 事件,如果我们程序中有很多的 NSTimer 这样的事件用这样的方法,因为不太了解底层的具体实现,但是我认为这样的方案对于程序的性能上会有一定的影响。但是对于内存释放上的考量我认为问题已经得到了解决。所以我的建议是即便用这样的方案也要及时的调用 invalidate 方法,否则程序的性能会受到影响,当然我们的项目也用到了很多这样的方法,因为我认为在代码可读性的角度出发,所以这样使用时不要觉得内存问题解决了就完事了。
看完了方法 2 中的问题,我们现在再来看方法 3 是如何解开循环引用的。我在 github 上下载了一个相关 demo,核心源码大致如下。
我们看到作者重新写了一个类,使用这个类老作为 target,解开了循环引用,这个时候测试 delloc 方法就不会出现循环引用,看似创建 timer 类的解决了循环引用的问题。但是我测试验证了我的想法,作者创建的 weakTimer 对象就会常驻内存一直都无法释放掉的。其实如果作者在中间层将 target 指向一个类对象,我认为这样的方法还是能够解决很多问题的,但是关键还是在于上边所说,还是可能会引发性能问题,而且还需要在写对应的 invalidate 方法等,我觉得这个时候其实这样的方法本身意义就已经不大了。所以对于中间代理的方式,个人认为真的可用性不大,增加了程序的复杂度,还不能本质上的解决问题。
所以最后对 NSTimer 的使用个人建议就是创建扩展,我认为这样的方式代码的可读性是最强的。但是注意和平时使用时一样及时的调用 invalidate 方法,毕竟不是能看到的问题解决了,我们的程序就没有问题了。
希望本文能给大家在开发中带来帮助,最近一直都在做一些项目优化上的事,最近有时间会分享关于如何让程序变得更省电上的思考和一些优化上的小经验。如果文章中的观点有任何问题,烦请留言区指出,我会立即进行更正,谢谢。
来源: https://juejin.im/post/5a31406f6fb9a0450c496906