iOS Target-Action模式下内存泄露问题深入探究

2,015 阅读8分钟

在我们日常开发中,我们或多或少的都会遇到循环引用的问题。其实问题的实质就是造成了互相持有的关系,在对象释放的时候,就好像产生了一个死锁一样,系统没有办法释放其中的任何一个对象,就造成了内存泄露的问题。我们都知道NSTimer是其中的典型。可是为什么继承自UIControl类的对象同样调用addtarget的方法就不会造成内存泄露的问题呢?现在就开启本文的探索。

1.Target-Action模式

这是苹果做的一种设计模式,在设置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一下有很多,本文的核心在于去深入的研究一小下。

2.UIControl和NSTimer下调用addTarget方法到底为什么不同


Target-Action模式.

上面是我们调用的时候会调用的方法,但是UIButton不会造成循环引用,但是NSTimer为什么会造成循环引用的问题呢?从这个问题出发,我查看了UIControl和NSTimer的官方文档,对于这里的解释真的是聊聊无几,我没有找到强有力的证据能够说明其中的原因,但是我们思考下猜想应该是UIControl机制下一定是底层将self弱引用了,解开了循环的链,所以UIControl下没有这样的操作。从这个角度出发,我去Google了一下,看了一些相关的文章,发现可以在堆栈信息中看出一些猫腻。那么现在看一下我们堆栈信息中我们能够发现什么.

首先我们看一下使用LLDB方案我们获取到的信息是不是可以为我们所用呢?我分别在两个addTarget方法出下了断点。然后在控制台输入dis,打印当前堆栈的调用信息,结果如下。



在看到这个堆栈信息的时候我发现对于同一块内存的引用方式竟然完全是一样的,这就更加增加了我的好奇,这里的堆栈信息完全不能解答现有的疑问,还有其他的方式么?后来想到调用方法的堆栈,去看方法到底做了什么也许更清晰,我们能够清晰地知道方法中用到了什么,于是在项目中添加了如下两个symbolic breakpoint断点践行进行测试。


symbolic breakpoints

此时重新跑程序,在每个断点执行的时候,我们可以看到对应的堆栈信息如下。


UIControl 下的target

NSTimer下的target

通过上图的两张堆栈信息,我们可以看到在UIControl下的target的持有方式确实是weakRetained弱持有的方式解开了引用循环,所以我们在使用时不会出现引用循环的问题。但是在NSTimer下,我看到的堆栈信息中看到这行代码的时候,开始明白机制的原理了,在NSTimer机制下对Target持有的方式使用的是autorelease的方式,也就是说target会在runloop下一次执行的时候查看这块区域是否进行释放,这也就能解释为什么我们如果将repeats属性设置成NO内存可以释放的原因,以及为什么将self设置成nil后内存依然不释放的原因。接下来我对invalidate方法打印堆栈信息,但是我发现没有对应方法的堆栈信息,反而会再次调用addtarget方法,这是我联想到NSTimer的官方文档中有说明,一旦调用了invalidate方法之后,这个timer就不能再使用,我认为底层这个时候就是个当前的timer进行了一个target的重定向,正好执行一次runloop的timerobserver监听,将之前的内存释放掉了,然后解开了引用的循环,现在我们已经明白了原理,那么我们就从原理出发,看看现有的解决方案是否合理。

3.从根源出发,看看现有解决方案

我百度了一下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方法,毕竟不是能看到的问题解决了,我们的程序就没有问题了。

希望本文能给大家在开发中带来帮助,最近一直都在做一些项目优化上的事,最近有时间会分享关于如何让程序变得更省电上的思考和一些优化上的小经验。如果文章中的观点有任何问题,烦请留言区指出,我会立即进行更正,谢谢。