我認為的 Runloop 最佳實踐

薛定諤發表於2019-03-04

關於 Runloop,這篇文章寫的非常棒,深入理解RunLoop。我寫這篇文章在深度上是不如它的,但是為什麼還想寫一下呢?

Runloop 是一個偏門的東西,在我的工作經歷中,幾乎沒有使用到它的地方,在我當時學習它時,因為本身對 iOS 整個生態瞭解不夠,很多概念讓我非常頭疼。

因此這篇文章我希望可以換一下因果關係,先不要管 Runloop 是什麼,讓我們從需求入手,看看 Runloop 能做什麼,當你實現過一次之後,回頭看這些高屋建瓴的文章,可能會更有啟發性。

本文涉及的程式碼託管在:github.com/tianziyao/R…

首先先記下 Runloop 負責做什麼事情:

  • 保證程式不退出;
  • 負責監聽事件,如觸控事件,計時器事件,網路事件等;
  • 負責渲染螢幕上所有的 UI,一次 Runloop 迴圈,需要渲染螢幕上所有變化的畫素點;
  • 節省 CPU 的開銷,讓程式該工作時工作,改休息時休息;

保證程式不退出和監聽應該比較容易理解,用虛擬碼來表示,大致是這樣:

// 退出
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 的變化,再通過畫素點的重繪表現出來。

上面講到的,全部是 Runloop 在系統層面的用處,那麼在應用層面,Runloop 能做什麼,以及應用在什麼地方呢?首先我們從一個計時器開始。

基本概念

當我們使用計時器的時候,應該有了解過 timer 的幾種構造方法,有的需要加入到 Runloop 中,有的不需要。

實際上,就算我們不需要手動將 timer 加入到 Runloop,它也是在 Runloop 中,下面的兩種初始化方式是等價的:

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 修改為 commonModesUITrackingRunLoopMode 再試一下,看看會發生什麼有趣的事情?

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 的思路

現在我們瞭解了 Runloop 是怎樣執行的,以及執行的幾種 Mode,下面我們嘗試解決一個實際的問題,TableCell 的內容載入。

在日常的開發中,我們大致會將 TableView 的載入分為兩部分處理:

  1. 將網路請求、快取讀寫、資料解析、構造模型等耗時操作放在子執行緒處理;
  2. 模型陣列準備完畢,回撥主執行緒重新整理 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 的渲染,總結一下,也就是下面這樣的過程:

  1. 宣告一個陣列,用來存放渲染 Cell 的程式碼;
  2. cellForRowAtIndexPath 代理中直接返回 Cell;
  3. 監聽 Runloop 的迴圈,迴圈完成,進入休眠後取出陣列中的程式碼執行;

陣列存放程式碼大家應該可以理解,也就是一個 Block 的陣列,但是 Runloop 如何監聽呢?

監聽 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
}複製程式碼

利用 Runloop 休眠

根據上面的 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 應用

首先我們宣告幾個變數:

/// 是否使用 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
}複製程式碼

Demo 地址

github.com/tianziyao/R…

相關文章