iOS10已經發布了一段時間,iOS10的各種適配相信大家已經完成。本文將講述的是關於iOS10內核的一個小改動,慣例,本文屬於進階性技術文,不會講解API的使用,要求讀者對RunLoop有一定的認知,感謝網友@送你的獨白麼 提供的SDK。
當我們的程序需要定時處理一些事件時,我們就會用到定時器,常用的定時器有NSTimer,CADisplayLink,GCD Timer,本文主要針對NSTimer和CADisplayLink進行講述,因為這兩者跟你的Application更為密切。
NSTimer和CADisplayLink都是建立在CFRunLoopTimer之上的抽象物,但有趣的是,蘋果只提供了NSTimer和CFRunLoopTimer互轉的Toll-Free Bridge,並沒有提供CADisplayLink和CFRunLoopTimer互轉的接口,因此一些開發者對此產生了一些猜想,有的人認為,CADisplayLink是用GCD Dispatch Source來實現的,有的人認為,CADisplayLink是用RunLoopSource來實現的,但這些猜想的依據都太容易被推翻了。如果CADisplayLink是用GCD Dispatch Source來實現的,那麼CADisplayLink是怎麼在你所創建的子線程中工作的呢?如果CADisplayLink是用RunLoopSource來實現的,會不會多此一舉?
CFRunLoopTimer是RunLoop的定時源,與Source1(Port)一樣,都屬於端口事件源,但不同的是,每一個Source1都有與之對應的端口,而一個RunLoopMode中的所有CFRunLoopTimer共用一個端口(Mode Timer Port),CFRunLoopTimer在RunLoop中的工作原理如下圖。
定時源工作從定時源在RunLoop中的工作原理我們得知,只要符合條件的定時器都會被觸發,也就是說,在同一次Loop中,可能會執行幾個定時器的回調。
很多講述定時器的技術文中都有這麼一個觀點,如果一個定時器錯過了本次可以觸發的時間點,那麼定時器將跳過這個時間點,等待下一個時間點的到來,這個觀點似乎是從官方文檔中得來的,但這個觀點跟定時器在RunLoop中的工作原理並不符。定時消息從內核發出,消息在消息中心等待被處理,RunLoop每次Loop都會去消息中心查找相應的端口消息,若找到相應的端口消息就會進行處理,所以,即使當前RunLoop正在執行一個耗時很長的任務,當任務執行完進入下一次Loop時,那些未被處理的消息仍然會被處理。經過大量測試表明,定時消息並不會因延遲而掉失。
關於RunLoop,官方文檔在這一部份的勘誤比較多,經常會出現文檔的介紹跟源碼不同的情況,所以想學習RunLoop的同學,建議看源碼和自己做測試,特別是自己做測試。
NSTimer和CADisplayLink最大的區別在於信號的發射頻率不同,CADisplayLink的發射頻率固定在16.67ms一次,而NSTimer則可以自由定義。我在頁面間跳轉的性能優化(一)中曾經提到過,不是必要的情況下,都不要選擇使用CADisplayLink作為定時器,因為它會使目標RunLoop一直處理活躍狀態。下面通過一個例子來看看實際的效果,創建一個CADisplayLink定時器,設置為100秒後觸發,然後觀察目標RunLoop的狀態。
CADisplayLink從實際效果我們可以看到,目標RunLoop一直處於活躍狀態,不斷地處理內核發出的信號,直到RunLoop Stop或CADisplayLink定時器被移除。同樣的條件,我們把定時器換成NSTimer來觀察實際情況。
NSTimer與CADisplayLink的固定信號不同,NSTimer的信號間隔完全是由使用者來定義。所以,除非你需要實現定時動畫,不然都不要選擇使用CADisplayLink作為定時器,它不僅會損耗大量的CPU資源,還會響應目標RunLoop處理其它事件源。
前面介紹了定時器的工作原理,現在來看看實際的改動,從一個例子入手進行講述。現在有頁面A,B,頁面A,B各有一個按鈕,頁面A的按鈕用來進入頁面B,進入頁面B後創建一個子線程,然後向子線程添加一個定時器並啟動RunLoop,頁面B的按鈕用於停止定時器,並返回頁面A,頁面B被釋放時會在dealloc方法裡輸出dealloc,編譯環境是ARC,下圖為頁面B的代碼,Gif圖分別是iOS10與iOS9的實際運行效果。
頁面B代碼iOS10iOS9一般情況下,從頁面B返回到頁面A後,頁面B會被釋放,頁面B的dealloc方法會輸出dealloc,但從實際的運行效果可以看到,在iOS10環境下頁面B並沒有被釋放,WTF,為什麼iOS10環境下會這樣?要回答這個問題,我們需要先知道iOS10的改動是什麼。
若目標RunLoop當前沒有定時源需要處理(像上面的例子那樣,子線程RunLoop只有一個定時器,該定時器移除後,則子線程RunLoop沒有定時源需要處理),則通知內核不需要再向當前Timer Port發送定時消息並移除該Timer Port。在iOS10環境下,當移除Timer Port後,內核會把消息列表中與該Timer Port相應的定時消息移除,而iOS10以前的環境下,當移除Timer Port後,內核不會把消息列表中與該Timer Port相應的定時消息移除。iOS10的處理是更為合理的,iOS10以前的處理可能是歷史遺留問題吧。
看回上面的例子,例子中遇到的問題是頁面B返回後並沒有被釋放,即頁面B的內存被強制保留了,所以我們現在需要知道的是頁面B為什麼被強制保留了。在頁面B中我們創建了一個子線程,子線程的主函數是頁面B的對象函數,這可能是導致頁面B被強制保留的原因,所以,我們需要知道子線程開啟前後,頁面B對象的引用計數是否有增加。
創建並開啟子線程頁面B的引用計數從輸出的信息我們得知,創建子線程後,Target會被強制保留,直到子線程的主函數返回。引用計數在很多時候可以幫助我們了解內存的使用情況,但在ARC編譯環境下,我們無法直接使用retainCount方法來獲取一個對象的引用計數,所以,我們需要做額外的處理。
獲取對象的引用計數回到例子中,我們知道了頁面B被強制保留的原因後,就知道了怎麼解決,只需要退出子線程即可,子線程之所以可以一直存活,是因為啟動了RunLoop,所以,我們只需要退出RunLoop,子線程的主函數就會返回。例子中涉及到線程異步的問題,定時器是在子線程RunLoop中注冊的,但定時器的移除操作卻是在主線程,由於子線程RunLoop處理完一次定時信號後,就會進入休眠狀態。在iOS10以前的環境下,定時器被移除後,內核仍然會向對應的Timer Port發送一次信號,所以子線程RunLoop接收到信號後會被喚醒,由於沒有定時源需要處理,所以RunLoop會直接跳轉到判斷階段,判斷階段會檢測當前RunLoopMode是否有事件源需要處理,若沒有事件源需要處理,則會退出RunLoop。由於例子中子線程RunLoop的當前RunLoopMode只有一個定時器,而定時器被移除後,RunLoopMode就沒有了需要處理的事件源,所以會退出RunLoop,子線程的主函數也因此返回,頁面B對象被釋放。
但在iOS10環境下,當定時器被移除後,內核不再向對應的Timer Port發送任何信號,所以子線程RunLoop一直處於休眠狀態並沒有退出,而我們只需要手動喚醒RunLoop即可。
更改頁面B代碼iOS10例子中所遇到的問題已經解決,但看完這個例子,可能你會有疑問,這個例子講述的情況有實戰意義?這個例子是從一個國外成熟產品所提供的配套SDK中簡化而來,配套的SDK用於與產品進行對接。額......實話說,當我看到這個處理方式的時候,我被震驚了,沒想到一個成熟產品所提供的配套SDK會出現這樣的問題,讓我更震驚的是,隨後在其它SDK中也發現了這個問題,這......
我們回頭來看看例子中的處理方式,例子中,子線程RunLoop的退出依賴於RunLoopMode的事件源為空,這種RunLoop的退出方式是極不穩定的,因為系統有很多API會向目標RunLoopMode添加額外的事件源來處理系統事件的,所以這種方式是不能確保一定可以退出RunLoop的。正確的方式應該是配對調用CFRunLoopRun( ),CFRunLoopStop( )來啟動和退出RunLoop,需要注意的是,除非你要創建一個單例線程,不然不要使用[runloop run]方法來啟動RunLoop,因為使用run方法啟動RunLoop後,唯一退出RunLoop的方式是當前RunLoopMode的事件源為空,而我們知道這種方式本身是極不穩定的。