一、Outline
本文将尝试从以下3个方面向你介绍CADisplayLink
- 从文档开始,了解
CADisplayLink
相关属性和方法 - 开始上手,使用
CADisplayLink
开发一个FPS监测工具 - 总结,遇到的问题
二、CADisplayLink官方文档
A timer object that allows your application to synchronize its drawing to the refresh rate of the display.
按苹果的文档,CADisplayLink
一个计时器对象,它允许你的应用将其图形绘制与显示的刷新率同步。(真拗口。。。)换句话讲,这个定时器对象,在每次屏幕刷新时都会回调一次。
三、Functions
init(target: Any, selector sel: Selector)
构造一个CADisplayLink
实例,传入 target
和 selector
, 注意CADisplayLink
对target
是强引用。
func add(to runloop: RunLoop, forMode mode: RunLoop.Mode)
在Runloop上, 以指定的 RunLoop.Mode
注册 CADisplayLink
。
func remove(from runloop: RunLoop, forMode mode: RunLoop.Mode)
从Runloop上, 以指定的 RunLoop.Mode
移出 CADisplayLink
。
func invalidate()
作废当前CADisplayLink
, 调用此方法会移出Runloop 上所有已注册的Mode的 CADisplayLink
,CADisplayLink
会释放强引用的target. 此方法线程安全,意味着你直接可以在子线程调用这个方法。
四、Properties
var duration: CFTimeInterval { get }
每帧之间的时间间隔。 只读。例如iPhone现在每秒刷新60次,那么这个值就是 1000ms/60 = 16.7ms
var preferredFramesPerSecond: Int { get set }
设置帧率/s, 如 15帧/s , 30帧/s
默认值为0, 当默认值时,帧率为屏幕的最大刷新率, 当前的iOS设备为60,以后iPhone有高刷版本时候,默认就是90,120.
var isPaused: Bool { get set }
是否暂停。 true
时表示 暂停调用 target
的 selector
。默认 false
。线程安全。
var timestamp: CFTimeInterval { get }
上一帧渲染完成时间
var targetTimestamp: CFTimeInterval { get }
下一帧渲染完成时间,正常情况下,应该比 timestamp
大 16.7ms
我可以使用这个值,取消或暂停一些耗时的操作。下面是一个例子,对一组数字求平方根,再对结果求和, 如果计算需要的时间过大,那么取消操作。
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))
//完成计算后,比较时间是否超过targetTimestamp,
if (CACurrentMediaTime() >= displayLink.targetTimestamp) {
// 如果特别耗时,则退出
print("break at i =", i)
break
}
}
}
五、应用场景
-
适合做UI的不停重绘,过渡相对流畅,无卡顿感
CADisplayLink
得益于和显示器刷新率同频的特性,我们在它的回调内做绘制,动画将是相当顺滑,用户不会感知到任何卡顿。 弹幕效果,和水波纹动画都可以使用CADisplayLink
成本较低得实现。
非UI更新的场景,比如实现音量EasyIn、EasyOut效果
音乐播放类App在音乐切换时,平滑降低上一首音量,再平滑提高下一首音量,利用CADisplayLink
的特性可以平滑实现相应的曲线效果。
六、FPSMonitor - FPS监测工具
FPS是应用程序用户体验考察的一个重要指标。FPS低于50,页面会出现卡顿,45以下会出现明显的卡顿,影响用户体验。日常开发工作中,在复杂Tableview
视图等场景下,时常会出现页面卡顿,因此,有必要开发一个小工具,在产品回归测试阶段,做一次FPS检查。当然日后我们也会将这个 FPSMonitor
作为App debug工具一个常用子模块。
需求:
求一秒内页面帧数
已知:
-
CADisplayLink
每秒默认刷新60次 - 如果出现掉帧,那么一秒内刷新将少于60次
思路:
- 在
CADisplayLink
刷新时,记录一次时间timestamp; - 计数:统计
CADisplayLink
刷新次数; - 每次刷新时,用当前硬件时间, 和之前记录的timestamp想比较,用以计算时差;
- 当 时差大于1时,计算一次FPS,那么 FPS = 刷新次数 / 时差
Code
见文末
使用
fpsMonitor.delegate = self
fpsMonitor.startMonitoring(inRunLoop: .main, mode: .default)
七、遇到哪些问题
问题1:页面滑动时,selector不再被调用
原因:iOS处理滑动时,Runloop
中 UIScrollView
的 mode
是 .eventTracking
,会优先保证界面流畅,而 displaylink & timer
默认的 model
是 .default
,所以会出现被暂停。
解决办法:将 timer | displaylink
加到 .commen mode
中
回答为什么加到.commen
之后就可以了,涉及到Runloop
相关知识,这里就不展开了。大家可以参考耀总博文:深入理解RunLoop
问题2:循环引用问题
在OC中我们可以使用NSProxy转发消息,但是由于NSProxy是抽象类,在Swift中只能被继承而无法被实例化,我在FPSMonitor申明了一个内部类MonitorWeakProxy
用来转发 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
的实例,从而打破引用链,避免循环引用。
代码:
//
// 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)
}