iOS Target-Action模式下記憶體洩露問題深入探究

Edgars發表於2017-12-13

在我們日常開發中,我們或多或少的都會遇到迴圈引用的問題。其實問題的實質就是造成了互相持有的關係,在物件釋放的時候,就好像產生了一個死鎖一樣,系統沒有辦法釋放其中的任何一個物件,就造成了記憶體洩露的問題。我們都知道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方法到底為什麼不同

iOS Target-Action模式下記憶體洩露問題深入探究
Target-Action模式.

上面是我們呼叫的時候會呼叫的方法,但是UIButton不會造成迴圈引用,但是NSTimer為什麼會造成迴圈引用的問題呢?從這個問題出發,我檢視了UIControl和NSTimer的官方文件,對於這裡的解釋真的是聊聊無幾,我沒有找到強有力的證據能夠說明其中的原因,但是我們思考下猜想應該是UIControl機制下一定是底層將self弱引用了,解開了迴圈的鏈,所以UIControl下沒有這樣的操作。從這個角度出發,我去Google了一下,看了一些相關的文章,發現可以在堆疊資訊中看出一些貓膩。那麼現在看一下我們堆疊資訊中我們能夠發現什麼.

首先我們看一下使用LLDB方案我們獲取到的資訊是不是可以為我們所用呢?我分別在兩個addTarget方法出下了斷點。然後在控制檯輸入dis,列印當前堆疊的呼叫資訊,結果如下。

iOS Target-Action模式下記憶體洩露問題深入探究
iOS Target-Action模式下記憶體洩露問題深入探究

在看到這個堆疊資訊的時候我發現對於同一塊記憶體的引用方式竟然完全是一樣的,這就更加增加了我的好奇,這裡的堆疊資訊完全不能解答現有的疑問,還有其他的方式麼?後來想到呼叫方法的堆疊,去看方法到底做了什麼也許更清晰,我們能夠清晰地知道方法中用到了什麼,於是在專案中新增了如下兩個symbolic breakpoint斷點踐行進行測試。

iOS Target-Action模式下記憶體洩露問題深入探究
symbolic breakpoints

此時重新跑程式,在每個斷點執行的時候,我們可以看到對應的堆疊資訊如下。

iOS Target-Action模式下記憶體洩露問題深入探究
UIControl 下的target
iOS Target-Action模式下記憶體洩露問題深入探究
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是不是能夠解決問題。

首先方法二實現的核心程式碼大致如下

iOS Target-Action模式下記憶體洩露問題深入探究

看完上邊的程式碼,我們發現此時的target為NSTimer類物件,其實本身就是一個單例,所以會伴隨程式的整個生命週期,所以程式是不是保留對他的迴圈引用都已經無所謂,所以不會造成記憶體洩露的問題,但是我們需要思考的一件事,我們的程式還是依然會在我們看不到的地方不停地去執行repeats事件,如果我們程式中有很多的NSTimer這樣的事件用這樣的方法,因為不太瞭解底層的具體實現,但是我認為這樣的方案對於程式的效能上會有一定的影響。但是對於記憶體釋放上的考量我認為問題已經得到了解決。所以我的建議是即便用這樣的方案也要及時的呼叫invalidate方法,否則程式的效能會受到影響,當然我們的專案也用到了很多這樣的方法,因為我認為在程式碼可讀性的角度出發,所以這樣使用時不要覺得記憶體問題解決了就完事了。

看完了方法2中的問題,我們現在再來看方法3是如何解開迴圈引用的。我在github上下載了一個相關demo,核心原始碼大致如下。

iOS Target-Action模式下記憶體洩露問題深入探究

我們看到作者重新寫了一個類,使用這個類老作為target,解開了迴圈引用,這個時候測試delloc方法就不會出現迴圈引用,看似建立timer類的解決了迴圈引用的問題。但是我測試驗證了我的想法,作者建立的weakTimer物件就會常駐記憶體一直都無法釋放掉的。其實如果作者在中間層將target指向一個類物件,我認為這樣的方法還是能夠解決很多問題的,但是關鍵還是在於上邊所說,還是可能會引發效能問題,而且還需要在寫對應的invalidate方法等,我覺得這個時候其實這樣的方法本身意義就已經不大了。所以對於中間代理的方式,個人認為真的可用性不大,增加了程式的複雜度,還不能本質上的解決問題。

所以最後對NSTimer的使用個人建議就是建立擴充套件,我認為這樣的方式程式碼的可讀性是最強的。但是注意和平時使用時一樣及時的呼叫invalidate方法,畢竟不是能看到的問題解決了,我們的程式就沒有問題了。

希望本文能給大家在開發中帶來幫助,最近一直都在做一些專案優化上的事,最近有時間會分享關於如何讓程式變得更省電上的思考和一些優化上的小經驗。如果文章中的觀點有任何問題,煩請留言區指出,我會立即進行更正,謝謝。

相關文章