一、Outline
本文將嘗試從以下3個(gè)方面向你介紹CADisplayLink
- 從文檔開始,了解
CADisplayLink相關(guān)屬性和方法 - 開始上手,使用
CADisplayLink開發(fā)一個(gè)FPS監(jiān)測(cè)工具 - 總結(jié),遇到的問題
二、CADisplayLink官方文檔
A timer object that allows your application to synchronize its drawing to the refresh rate of the display.
按蘋果的文檔,CADisplayLink 一個(gè)計(jì)時(shí)器對(duì)象,它允許你的應(yīng)用將其圖形繪制與顯示的刷新率同步。(真拗口。。。)換句話講,這個(gè)定時(shí)器對(duì)象,在每次屏幕刷新時(shí)都會(huì)回調(diào)一次。
三、Functions
init(target: Any, selector sel: Selector)
構(gòu)造一個(gè)CADisplayLink實(shí)例,傳入 target 和 selector, 注意CADisplayLink對(duì)target是強(qiáng)引用。
func add(to runloop: RunLoop, forMode mode: RunLoop.Mode)
在Runloop上, 以指定的 RunLoop.Mode 注冊(cè) CADisplayLink。
func remove(from runloop: RunLoop, forMode mode: RunLoop.Mode)
從Runloop上, 以指定的 RunLoop.Mode 移出 CADisplayLink。
func invalidate()
作廢當(dāng)前CADisplayLink, 調(diào)用此方法會(huì)移出Runloop 上所有已注冊(cè)的Mode的 CADisplayLink,CADisplayLink會(huì)釋放強(qiáng)引用的target. 此方法線程安全,意味著你直接可以在子線程調(diào)用這個(gè)方法。
四、Properties
var duration: CFTimeInterval { get }
每幀之間的時(shí)間間隔。 只讀。例如iPhone現(xiàn)在每秒刷新60次,那么這個(gè)值就是 1000ms/60 = 16.7ms
var preferredFramesPerSecond: Int { get set }
設(shè)置幀率/s, 如 15幀/s , 30幀/s
默認(rèn)值為0, 當(dāng)默認(rèn)值時(shí),幀率為屏幕的最大刷新率, 當(dāng)前的iOS設(shè)備為60,以后iPhone有高刷版本時(shí)候,默認(rèn)就是90,120.
var isPaused: Bool { get set }
是否暫停。 true時(shí)表示 暫停調(diào)用 target 的 selector。默認(rèn) false。線程安全。
var timestamp: CFTimeInterval { get }
上一幀渲染完成時(shí)間
var targetTimestamp: CFTimeInterval { get }
下一幀渲染完成時(shí)間,正常情況下,應(yīng)該比 timestamp大 16.7ms
我可以使用這個(gè)值,取消或暫停一些耗時(shí)的操作。下面是一個(gè)例子,對(duì)一組數(shù)字求平方根,再對(duì)結(jié)果求和, 如果計(jì)算需要的時(shí)間過大,那么取消操作。
func createDisplayLink() {
let displayLink = CADisplayLink(target: self, selector: #selector(step))
displayLink.add(to: .main, forMode: .default)
}
func step(displayLink: CADisplayLink) {
var sqrtSum = 0.0
for i in 0 ..< Int.max {
sqrtSum += sqrt(Double(i))
//完成計(jì)算后,比較時(shí)間是否超過targetTimestamp,
if (CACurrentMediaTime() >= displayLink.targetTimestamp) {
// 如果特別耗時(shí),則退出
print("break at i =", i)
break
}
}
}
五、應(yīng)用場(chǎng)景
-
適合做UI的不停重繪,過渡相對(duì)流暢,無卡頓感
CADisplayLink得益于和顯示器刷新率同頻的特性,我們?cè)谒幕卣{(diào)內(nèi)做繪制,動(dòng)畫將是相當(dāng)順滑,用戶不會(huì)感知到任何卡頓。 彈幕效果,和水波紋動(dòng)畫都可以使用CADisplayLink成本較低得實(shí)現(xiàn)。
水波紋 非UI更新的場(chǎng)景,比如實(shí)現(xiàn)音量EasyIn、EasyOut效果
音樂播放類App在音樂切換時(shí),平滑降低上一首音量,再平滑提高下一首音量,利用CADisplayLink的特性可以平滑實(shí)現(xiàn)相應(yīng)的曲線效果。
六、FPSMonitor - FPS監(jiān)測(cè)工具
FPS是應(yīng)用程序用戶體驗(yàn)考察的一個(gè)重要指標(biāo)。FPS低于50,頁面會(huì)出現(xiàn)卡頓,45以下會(huì)出現(xiàn)明顯的卡頓,影響用戶體驗(yàn)。日常開發(fā)工作中,在復(fù)雜Tableview視圖等場(chǎng)景下,時(shí)常會(huì)出現(xiàn)頁面卡頓,因此,有必要開發(fā)一個(gè)小工具,在產(chǎn)品回歸測(cè)試階段,做一次FPS檢查。當(dāng)然日后我們也會(huì)將這個(gè) FPSMonitor 作為App debug工具一個(gè)常用子模塊。
需求:
求一秒內(nèi)頁面幀數(shù)
已知:
-
CADisplayLink每秒默認(rèn)刷新60次 - 如果出現(xiàn)掉幀,那么一秒內(nèi)刷新將少于60次
思路:
- 在
CADisplayLink刷新時(shí),記錄一次時(shí)間timestamp; - 計(jì)數(shù):統(tǒng)計(jì)
CADisplayLink刷新次數(shù); - 每次刷新時(shí),用當(dāng)前硬件時(shí)間, 和之前記錄的timestamp想比較,用以計(jì)算時(shí)差;
- 當(dāng) 時(shí)差大于1時(shí),計(jì)算一次FPS,那么 FPS = 刷新次數(shù) / 時(shí)差
Code
見文末
使用
fpsMonitor.delegate = self
fpsMonitor.startMonitoring(inRunLoop: .main, mode: .default)
七、遇到哪些問題
問題1:頁面滑動(dòng)時(shí),selector不再被調(diào)用
原因:iOS處理滑動(dòng)時(shí),Runloop 中 UIScrollView 的 mode 是 .eventTracking,會(huì)優(yōu)先保證界面流暢,而 displaylink & timer 默認(rèn)的 model是 .default,所以會(huì)出現(xiàn)被暫停。
解決辦法:將 timer | displaylink 加到 .commen mode 中
回答為什么加到.commen之后就可以了,涉及到Runloop相關(guān)知識(shí),這里就不展開了。大家可以參考耀總博文:深入理解RunLoop
問題2:循環(huán)引用問題
在OC中我們可以使用NSProxy轉(zhuǎn)發(fā)消息,但是由于NSProxy是抽象類,在Swift中只能被繼承而無法被實(shí)例化,我在FPSMonitor申明了一個(gè)內(nèi)部類MonitorWeakProxy用來轉(zhuǎn)發(fā) CADisplayLink 中到 target的消息。
fileprivate class MonitorWeakProxy: NSObject {
weak var parentMonitor: FPSMonitor?
@objc func updateFromDisplayLink(_ displayLink: CADisplayLink) {
parentMonitor?.updateFromDisplayLink(displayLink)
}
}
如圖:
FPSMonitor --strong--> CADisplayLink --strong--> Proxy --weak--> FPSMonitor
MonitorWeakProxy始終weak持有FPSMonitor的實(shí)例,從而打破引用鏈,避免循環(huán)引用。

代碼:
//
// FPSMonitor.swift
// FPSMonitor
//
// Created by Halley on 3/28/21.
//
import Foundation
import UIKit
open class FPSMonitor: NSObject {
private var displayLink: CADisplayLink
private var runloop: RunLoop?
private var mode: RunLoop.Mode?
private var lastUpdateTime: CFTimeInterval = 0.0
private var numberOfFrames = 0
public var updateDelay: TimeInterval = 1.0
public weak var delegate: FPSMonitorDelegate?
override init() {
let monitorWeakProxy = MonitorWeakProxy()
displayLink = CADisplayLink(target: monitorWeakProxy, selector: #selector(MonitorWeakProxy.updateFromDisplayLink(_:)))
super.init()
monitorWeakProxy.parentMonitor = self
}
public func startMonitoring(inRunLoop runloop: RunLoop = .main, mode: RunLoop.Mode = .common) {
stopMonitoring()
self.runloop = runloop
self.mode = mode
displayLink.add(to: runloop, forMode: mode)
}
public func stopMonitoring() {
guard let runloop = self.runloop, let mode = self.mode else { return }
displayLink.remove(from: runloop, forMode: mode)
self.runloop = nil
self.mode = nil
}
private func updateFromDisplayLink(_ displayLink: CADisplayLink) {
if lastUpdateTime == 0.0 {
lastUpdateTime = CACurrentMediaTime()
return
}
numberOfFrames += 1
let currentTime = CACurrentMediaTime()
let timeInterval = currentTime - lastUpdateTime
if timeInterval >= self.updateDelay {
notifyUpdateForTimeInterval(timeInterval)
lastUpdateTime = 0.0
numberOfFrames = 0
}
}
private func notifyUpdateForTimeInterval(_ timeInterval: CFAbsoluteTime) {
let fps = round(Double(self.numberOfFrames) / timeInterval)
self.delegate?.fpsMonitor(self, didUpdateFramesPerSecond: Int(fps))
}
fileprivate class MonitorWeakProxy: NSObject {
weak var parentMonitor: FPSMonitor?
@objc func updateFromDisplayLink(_ displayLink: CADisplayLink) {
parentMonitor?.updateFromDisplayLink(displayLink)
}
}
}
public protocol FPSMonitorDelegate: NSObjectProtocol {
func fpsMonitor(_ counter: FPSMonitor, didUpdateFramesPerSecond fps: Int)
}
