CADisplayLink - FPSMonitor

一、Outline

本文將嘗試從以下3個(gè)方面向你介紹CADisplayLink

  1. 從文檔開始,了解CADisplayLink相關(guān)屬性和方法
  2. 開始上手,使用CADisplayLink開發(fā)一個(gè)FPS監(jiān)測(cè)工具
  3. 總結(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í)例,傳入 targetselector, 注意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的 CADisplayLinkCADisplayLink會(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)用 targetselector。默認(rèn) false。線程安全。

var timestamp: CFTimeInterval { get }

上一幀渲染完成時(shí)間

var targetTimestamp: CFTimeInterval { get }

下一幀渲染完成時(shí)間,正常情況下,應(yīng)該比 timestamp16.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)景

  1. 適合做UI的不停重繪,過渡相對(duì)流暢,無卡頓感
    CADisplayLink得益于和顯示器刷新率同頻的特性,我們?cè)谒幕卣{(diào)內(nèi)做繪制,動(dòng)畫將是相當(dāng)順滑,用戶不會(huì)感知到任何卡頓。 彈幕效果,和水波紋動(dòng)畫都可以使用CADisplayLink成本較低得實(shí)現(xiàn)。

    水波紋

  2. 非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ù)

已知:

  1. CADisplayLink 每秒默認(rèn)刷新60次
  2. 如果出現(xiàn)掉幀,那么一秒內(nèi)刷新將少于60次

思路:

  1. CADisplayLink 刷新時(shí),記錄一次時(shí)間timestamp;
  2. 計(jì)數(shù):統(tǒng)計(jì)CADisplayLink 刷新次數(shù);
  3. 每次刷新時(shí),用當(dāng)前硬件時(shí)間, 和之前記錄的timestamp想比較,用以計(jì)算時(shí)差;
  4. 當(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í),RunloopUIScrollViewmode.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)
}

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容