關於 Runloop,這篇文章寫的很是棒,深刻理解RunLoop。我寫這篇文章在深度上是不如它的,可是爲何還想寫一下呢?git
Runloop 是一個偏門的東西,在個人工做經歷中,幾乎沒有使用到它的地方,在我當時學習它時,由於自己對 iOS 整個生態瞭解不夠,不少概念讓我很是頭疼。github
所以這篇文章我但願能夠換一下因果關係,先不要管 Runloop 是什麼,讓咱們從需求入手,看看 Runloop 能作什麼,當你實現過一次以後,回頭看這些高屋建瓴的文章,可能會更有啓發性。swift
本文涉及的代碼託管在:github.com/tianziyao/R…api
首先先記下 Runloop 負責作什麼事情:數組
保證程序不退出和監聽應該比較容易理解,用僞代碼來表示,大體是這樣:緩存
// 退出
var exit = false
// 事件
var event: UIEvent? = nil
// 事件隊列
var events: [UIEvent] = [UIEvent]()
// 事件分發/響應鏈
func handle(event: UIEvent) -> Bool {
return true
}
// 主線程 Runloop
repeat {
// 出現新的事件
if event != nil {
// 將事件加入隊列
events.append(event!)
}
// 若是隊列中有事件
if events.count > 0 {
// 處理隊列中第一個事件
let result = handle(event: events.first!)
// 處理完成移除第一個事件
if result {
events.removeFirst()
}
}
// 再次進入發現事件->添加到隊列->事件分發->處理事件->移除事件
// 直到 exit=true,主線程退出
} while exit == false複製代碼
負責渲染屏幕上全部的 UI,也就是在一次 Runloop 中,事件引發了 UI 的變化,再經過像素點的重繪表現出來。bash
上面講到的,所有是 Runloop 在系統層面的用處,那麼在應用層面,Runloop 能作什麼,以及應用在什麼地方呢?首先咱們從一個計時器開始。網絡
當咱們使用計時器的時候,應該有了解過 timer 的幾種構造方法,有的須要加入到 Runloop 中,有的不須要。app
實際上,就算咱們不須要手動將 timer 加入到 Runloop,它也是在 Runloop 中,下面的兩種初始化方式是等價的:ide
let timer = Timer(timeInterval: 1,
target: self,
selector: #selector(self.run),
userInfo: nil,
repeats: true)
RunLoop.current.add(timer, forMode: .defaultRunLoopMode)
///////////////////////////////////////////////////////////////////////////
let scheduledTimer = Timer.scheduledTimer(timeInterval: 1,
target: self,
selector: #selector(self.run),
userInfo: nil,
repeats: true)複製代碼
如今新建一個項目,添加一個 TextView
,你的 ViewController 文件應該是這樣:
class ViewController: UIViewController {
var num = 0
override func viewDidLoad() {
super.viewDidLoad()
let timer = Timer(timeInterval: 1,
target: self,
selector: #selector(self.run),
userInfo: nil,
repeats: true)
RunLoop.current.add(timer, forMode: .defaultRunLoopMode)
}
func run() {
num += 1
print(Thread.current ,num)
}
}複製代碼
按照直覺,當 App 運行後,控制檯會每秒打印一次,可是當你滾動 TextView
時,會發現打印中止了,TextView
中止滾動時,打印又繼續進行。
這是什麼緣由呢?在學習線程的時候咱們知道,主線程的優先級是最高的,主線程也叫作 UI 線程,UI 的變化不容許在子線程進行。所以在 iOS 中,UI 事件的優先級是最高的。
Runloop 也有同樣的概念,Runloop 分爲幾種模式:
// App 的默認 Mode,一般主線程是在這個 Mode 下運行
public static let defaultRunLoopMode: RunLoopMode
// 這是一個佔位用的Mode,不是一種真正的Mode,用於區分 defaultMode
public static let commonModes: RunLoopMode
// 界面跟蹤 Mode,用於 ScrollView 追蹤觸摸滑動,保證界面滑動時不受其餘 Mode 影響
public static let UITrackingRunLoopMode: RunLoopMode複製代碼
看到這裏你們應該能夠明白,咱們的 timer 是在 defaultRunLoopMode
中,而 TextView
的滾動則處於 UITrackingRunLoopMode
中,所以二者不能同時進行。
這個問題會在什麼場景下出現呢?好比你使用定時器作了輪播,當下面的列表滾動時,輪播圖停住了。
那麼如今將 timer 的 Mode
修改成 commonModes
和 UITrackingRunLoopMode
再試一下,看看會發生什麼有趣的事情?
commonModes
模式下,run
方法會持續進行,不受 TextView
滾動和靜止的影響,UITrackingRunLoopMode
模式下,當 TextView
滾動時,run
方法執行,當 TextView
靜止時,run
方法中止執行。
若是看過一些關於 Runloop 的介紹,咱們應該知道,每一個線程都有 Runloop,主線程默認開啓,子線程需手動開啓,在上面的例子中,當 Mode 是 commonModes
時,定時器和 UI 滾動同時進行,看起來像是在同時進行,但實際上不管 Runloop Mode 如何變化,它始終是在這條線程上循環往復。
你們都知道,在 iOS 開發中有一條鐵律,永遠不能阻塞主線程。所以,在主線程的任何 Mode 上,也不能進行耗時操做,如今將 run
方法改爲下面這樣試下:
func run() {
num += 1
print(Thread.current ,num)
Thread.sleep(forTimeInterval: 3)
}複製代碼
如今咱們瞭解了 Runloop 是怎樣運行的,以及運行的幾種 Mode,下面咱們嘗試解決一個實際的問題,TableCell
的內容加載。
在平常的開發中,咱們大體會將 TableView
的加載分爲兩部分處理:
TableView
,使用模型數據填充 TableCell
;爲何咱們大多會這樣處理?實際上仍是上面的原則:永遠不能阻塞主線程。所以,爲了 UI 的流暢,咱們會千方百計將耗時操做從主線程中剝離,纔有了上面的方案。
可是有一點,UI 的操做是必須在主線程中完成的,那麼,若是使用模型數據填充 TableCell
也是一個耗時操做,該怎麼辦?
好比像下面這種操做:
let path = Bundle.main.path(forResource: "rose", ofType: "jpg")
let image = UIImage(contentsOfFile: path ?? "") ?? UIImage()
cell.config(image: image)複製代碼
在這個例子中,rose.jpg
是一張很大的圖片,每一個 TableCell
上有 3 張這樣的圖片,咱們固然能夠將圖片在子線程中讀取完畢後再更新,不過咱們須要模擬一個耗時的 UI 操做,所以先這樣處理。
你們能夠下載代碼運行一下,滾動 TableView
,FPS 最低會降到 40 如下,這種現象是如何產生的呢?
上面咱們講到過,Runloop 負責渲染屏幕的 UI 和監聽觸摸事件,手指滑動時,TableView
隨之移動,觸發屏幕上的 UI 變化,UI 的變化觸發 Cell 的複用和渲染,而 Cell 的渲染是一個耗時操做,致使 Runloop 循環一次的時間變長,所以形成 UI 的卡頓。
那麼針對這個過程,咱們怎樣改善呢?既然 Cell 的渲染是耗時操做,那麼須要把 Cell 的渲染剝離出來,使其不影響 TableView
的滾動,保證 UI 的流暢後,在合適的時機再執行 Cell 的渲染,總結一下,也就是下面這樣的過程:
cellForRowAtIndexPath
代理中直接返回 Cell;數組存放代碼你們應該能夠理解,也就是一個 Block 的數組,可是 Runloop 如何監聽呢?
咱們須要知道 Runloop 循環在什麼時候開始,在什麼時候結束,Demo 以下:
fileprivate func addRunLoopObServer() {
do {
let block = { (ob: CFRunLoopObserver?, ac: CFRunLoopActivity) in
if ac == .entry {
print("進入 Runloop")
}
else if ac == .beforeTimers {
print("即將處理 Timer 事件")
}
else if ac == .beforeSources {
print("即將處理 Source 事件")
}
else if ac == .beforeWaiting {
print("Runloop 即將休眠")
}
else if ac == .afterWaiting {
print("Runloop 被喚醒")
}
else if ac == .exit {
print("退出 Runloop")
}
}
let ob = try createRunloopObserver(block: block)
/// - Parameter rl: 要監聽的 Runloop
/// - Parameter observer: Runloop 觀察者
/// - Parameter mode: 要監聽的 mode
CFRunLoopAddObserver(CFRunLoopGetCurrent(), ob, .defaultMode)
}
catch RunloopError.canNotCreate {
print("runloop 觀察者建立失敗")
}
catch {}
}
fileprivate func createRunloopObserver(block: @escaping (CFRunLoopObserver?, CFRunLoopActivity) -> Void) throws -> CFRunLoopObserver {
/* * allocator: 分配空間給新的對象。默認狀況下使用NULL或者kCFAllocatorDefault。 activities: 設置Runloop的運行階段的標誌,當運行到此階段時,CFRunLoopObserver會被調用。 public struct CFRunLoopActivity : OptionSet { public init(rawValue: CFOptionFlags) public static var entry //進入工做 public static var beforeTimers //即將處理Timers事件 public static var beforeSources //即將處理Source事件 public static var beforeWaiting //即將休眠 public static var afterWaiting //被喚醒 public static var exit //退出RunLoop public static var allActivities //監聽全部事件 } repeats: CFRunLoopObserver是否循環調用 order: CFRunLoopObserver的優先級,正常狀況下使用0。 block: 這個block有兩個參數:observer:正在運行的run loop observe。activity:runloop當前的運行階段。返回值:新的CFRunLoopObserver對象。 */
let ob = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, CFRunLoopActivity.allActivities.rawValue, true, 0, block)
guard let observer = ob else {
throw RunloopError.canNotCreate
}
return observer
}複製代碼
根據上面的 Demo,咱們能夠監聽到 Runloop 的開始和結束了,如今在控制器中加入一個 TableView
,和一個 Runloop 的觀察者,你的控制器如今應該是這樣的:
class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
addRunloopObserver()
view.addSubview(tableView)
}
fileprivate func addRunloopObserver() {
// 獲取當前的 Runloop
let runloop = CFRunLoopGetCurrent()
// 須要監聽 Runloop 的哪一個狀態
let activities = CFRunLoopActivity.beforeWaiting.rawValue
// 建立 Runloop 觀察者
let observer = CFRunLoopObserverCreateWithHandler(nil, activities, true, Int.max - 999, runLoopBeforeWaitingCallBack)
// 註冊 Runloop 觀察者
CFRunLoopAddObserver(runloop, observer, .defaultMode)
}
fileprivate let runLoopBeforeWaitingCallBack = { (ob: CFRunLoopObserver?, ac: CFRunLoopActivity) in
print("runloop 循環完畢")
}
fileprivate lazy var tableView: UITableView = {
let table = UITableView(frame: self.view.frame)
table.delegate = self
table.dataSource = self
table.register(TableViewCell.self, forCellReuseIdentifier: "tableViewCell")
return table
}()
}複製代碼
如今運行起來,打印信息以下:
runloop 循環完畢
runloop 循環完畢
runloop 循環完畢
runloop 循環完畢複製代碼
從這裏咱們看到,從控制器的 viewDidLoad
開始,通過幾回 Runloop,TableView
成功在屏幕出現,而後進入休眠,當咱們滑動屏幕或者觸發陀螺儀、耳機等事件發生時,Runloop 進入工做,處理完畢後再次進入休眠。
而咱們的目的是利用 Runloop 的休眠時間,在用戶沒有產生事件的時候,能夠處理 Cell 的渲染任務。本文的開頭咱們提到 Runloop 負責的事情,觸摸和網絡等事件通常是由用戶觸發,且執行完 Runloop 會再次進入休眠,那麼合適的的事件,也就是時鐘了。
所以咱們監聽了 defaultMode
,並須要在觀察者的回調中啓動一個時鐘事件,讓 Runloop 始終保持在活動狀態,可是這個時鐘也不須要它執行什麼事情,因此我開啓了一個 CADisplayLink
,用來顯示 FPS。不瞭解 CADisplayLink
的同窗,將它想象爲一個大約 1/60 秒執行一次的定時器就能夠了,執行的動做是輸出一個數字。
首先咱們聲明幾個變量:
/// 是否使用 Runloop 優化
fileprivate let useRunloop: Bool = false
/// cell 的高度
fileprivate let rowHeight: CGFloat = 120
/// runloop 空閒時執行的代碼
fileprivate var runloopBlockArr: [RunloopBlock] = [RunloopBlock]()
/// runloopBlockArr 中的最大任務數
fileprivate var maxQueueLength: Int {
return (Int(UIScreen.main.bounds.height / rowHeight) + 2)
}複製代碼
修改 addRunloopObserver
方法:
/// 註冊 Runloop 觀察者
fileprivate func addRunloopObserver() {
// 獲取當前的 Runloop
let runloop = CFRunLoopGetCurrent()
// 須要監聽 Runloop 的哪一個狀態
let activities = CFRunLoopActivity.beforeWaiting.rawValue
// 建立 Runloop 觀察者
let observer = CFRunLoopObserverCreateWithHandler(nil, activities, true, 0) { [weak self] (ob, ac) in
guard let `self` = self else { return }
guard self.runloopBlockArr.count != 0 else { return }
// 是否退出任務組
var quit = false
// 若是不退出且任務組中有任務存在
while quit == false && self.runloopBlockArr.count > 0 {
// 執行任務
guard let block = self.runloopBlockArr.first else { return }
// 是否退出任務組
quit = block()
// 刪除已完成的任務
let _ = self.runloopBlockArr.removeFirst()
}
}
// 註冊 Runloop 觀察者
CFRunLoopAddObserver(runloop, observer, .defaultMode)
}複製代碼
建立 addRunloopBlock
方法:
/// 添加代碼塊到數組,在 Runloop BeforeWaiting 時執行
///
/// - Parameter block: <#block description#>
fileprivate func addRunloopBlock(block: @escaping RunloopBlock) {
runloopBlockArr.append(block)
// 快速滾動時,沒有來得及顯示的 cell 不會進行渲染,只渲染屏幕中出現的 cell
if runloopBlockArr.count > maxQueueLength {
let _ = runloopBlockArr.removeFirst()
}
}複製代碼
最後將渲染 cell 的 Block 丟進 runloopBlockArr
:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
if useRunloop {
return loadCellWithRunloop()
}
else {
return loadCell()
}
}
func loadCellWithRunloop() -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "tableViewCell") as? TableViewCell else {
return UITableViewCell()
}
addRunloopBlock { () -> (Bool) in
let path = Bundle.main.path(forResource: "rose", ofType: "jpg")
let image = UIImage(contentsOfFile: path ?? "") ?? UIImage()
cell.config(image: image)
return false
}
return cell
}複製代碼