我认为的 Runloop 最佳实践

关于 Runloop,这篇文章写的很是棒,深刻理解RunLoop。我写这篇文章在深度上是不如它的,可是为何还想写一下呢?git

Runloop 是一个偏门的东西,在个人工做经历中,几乎没有使用到它的地方,在我当时学习它时,由于自己对 iOS 整个生态了解不够,不少概念让我很是头疼。github

所以这篇文章我但愿能够换一下因果关系,先不要管 Runloop 是什么,让咱们从需求入手,看看 Runloop 能作什么,当你实现过一次以后,回头看这些高屋建瓴的文章,可能会更有启发性。swift

本文涉及的代码托管在:github.com/tianziyao/R…api

首先先记下 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 的变化,再经过像素点的重绘表现出来。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 修改成 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…

相关文章
相关标签/搜索