關(guān)于macOS替代品之CADisplayLink

什么是CADisplayLink

CADisplayLink是一個能讓我們以和屏幕刷新率相同的頻率將內(nèi)容畫到屏幕上的定時器。

  • CADisplayLink以特定模式注冊到runloop后,每當(dāng)屏幕顯示內(nèi)容刷新結(jié)束的時候,runloop就會向CADisplayLink指定的target發(fā)送一次指定的selector消息,CADisplayLink類對應(yīng)的selector就會被調(diào)用一次。

  • 通常情況下,iOS設(shè)備的刷新頻率事60HZ也就是每秒60次,那么每一次刷新的時間就是1/60秒大概16.7毫秒。

  • iOS設(shè)備的屏幕刷新頻率是固定的,CADisplayLink 在正常情況下會在每次刷新結(jié)束都被調(diào)用,精確度相當(dāng)高。但如果調(diào)用的方法比較耗時,超過了屏幕刷新周期,就會導(dǎo)致跳過若干次回調(diào)調(diào)用機(jī)會。

  • 如果CPU過于繁忙,無法保證屏幕 60次/秒 的刷新率,就會導(dǎo)致跳過若干次調(diào)用回調(diào)方法的機(jī)會,跳過次數(shù)取決CPU的忙碌程度。

DisplayLink概覽

對外開放方法屬性,簡單模擬iOS系統(tǒng)對應(yīng)的CADisplayLink。

// See: https://developer.apple.com/documentation/quartzcore/cadisplaylink
public protocol DisplayLinkProtocol: NSObjectProtocol {
    
    /// 每幀之間的時間,60HZ的刷新率為每秒60次,每次刷新需要1/60秒,大約16.7毫秒
    var duration: CFTimeInterval { get }
    
    /// 返回每個幀之間的時間,即每個屏幕刷新之間的時間間隔
    var timestamp: CFTimeInterval { get }
    
    /// 定義每次之間必須傳遞多少個顯示幀
    var frameInterval: Int { get }
    
    /// 是否處于暫停狀態(tài)
    var isPaused: Bool { get set }
    
    /// 使用您指定的目標(biāo)和選擇器創(chuàng)建顯示鏈接
    /// 將在“target”上調(diào)用名為“sel”的方法,該方法對應(yīng)``(void)selector:(CADisplayLink *)sender``
    init(target: Any, selector sel: Selector)
    
    /// 將接收器添加到給定的運(yùn)行循環(huán)和模式中
    func add(to runloop: RunLoop, forMode mode: RunLoop.Mode)
    
    /// 從運(yùn)行循環(huán)的給定模式中移除接收器
    func remove(from runloop: RunLoop, forMode mode: RunLoop.Mode)
    
    /// 銷毀計(jì)時器,并釋放“目標(biāo)”對象
    func invalidate()
}

DisplayLink方法和屬性介紹

  • 初始化

然后把 CADisplayLink 對象添加到 runloop 中后,并給它提供一個 target 和 select 在屏幕刷新的時候調(diào)用

/// Responsible for starting and stopping the animation.
private lazy var displayLink: CADisplayLink = {
    self.displayLinkInitialized = true
    let target = DisplayLinkProxy(target: self)
    let display = CADisplayLink(target: target, selector: #selector(onScreenUpdate(_:)))
    //displayLink.add(to: .main, forMode: RunLoop.Mode.common)
    display.add(to: .current, forMode: RunLoop.Mode.default)
    display.isPaused = true
    return display
}()
  • 停止方法

執(zhí)行 invalidate 操作時,CADisplayLink 對象就會從 runloop 中移除,selector 調(diào)用也隨即停止

deinit {
    if displayLinkInitialized {
        displayLink.invalidate()
    }
}
  • 開啟or暫停

開啟計(jì)時器或者暫停計(jì)時器操作,

/// Start animating.
func startAnimating() {
    if frameStore?.isAnimatable ?? false {
        displayLink.isPaused = false
    }
}
/// Stop animating.
func stopAnimating() {
    displayLink.isPaused = true
}
  • 每幀之間的時間

60HZ的刷新率為每秒60次,每次刷新需要1/60秒,大約16.7毫秒。

/// The refresh rate of 60HZ is 60 times per second, each refresh takes 1/60 of a second about 16.7 milliseconds.
var duration: CFTimeInterval {
    guard let timer = timer else { return DisplayLink.duration }
    CVDisplayLinkGetCurrentTime(timer, &timeStampRef)
    return CFTimeInterval(timeStampRef.videoRefreshPeriod) / CFTimeInterval(timeStampRef.videoTimeScale)
}
  • 上一次屏幕刷新的時間戳

返回每個幀之間的時間,即每個屏幕刷新之間的時間間隔。

/// Returns the time between each frame, that is, the time interval between each screen refresh.
var timestamp: CFTimeInterval {
    guard let timer = timer else { return DisplayLink.timestamp }
    CVDisplayLinkGetCurrentTime(timer, &timeStampRef)
    return CFTimeInterval(timeStampRef.videoTime) / CFTimeInterval(timeStampRef.videoTimeScale)
}
  • 定義每次之間必須傳遞多少個顯示幀

用來設(shè)置間隔多少幀調(diào)用一次 selector 方法,默認(rèn)值是1,即每幀都調(diào)用一次。如果每幀都調(diào)用一次的話,對于iOS設(shè)備來說那刷新頻率就是60HZ也就是每秒60次,如果將 frameInterval 設(shè)為2那么就會兩幀調(diào)用一次,也就是變成了每秒刷新30次。

/// Sets how many frames between calls to the selector method, defult 1
var frameInterval: Int {
    guard let timer = timer else { return DisplayLink.frameInterval }
    CVDisplayLinkGetCurrentTime(timer, &timeStampRef)
    return timeStampRef.rateScalar
}

DisplayLink使用

由于跟屏幕刷新同步,非常適合UI的重復(fù)繪制,如:下載進(jìn)度條,自定義動畫設(shè)計(jì),視頻播放渲染等;

/// A proxy class to avoid a retain cycle with the display link.
final class DisplayLinkProxy: NSObject {
    
    weak var target: Animator?
    
    init(target: Animator) {
        self.target = target
    }
    
    /// Lets the target update the frame if needed.
    @objc func onScreenUpdate(_ sender: CADisplayLink) {
        guard let animator = target, let store = animator.frameStore else {
            return
        }
        if store.isFinished {
            animator.stopAnimating()
            animator.animationBlock?(store.loopDuration)
            return
        }
        store.shouldChangeFrame(with: sender.duration) {
            if $0 { animator.delegate.updateImageIfNeeded() }
        }
    }
}

DisplayLink設(shè)計(jì)實(shí)現(xiàn)

由于macOS不支持CADisplayLink,于是乎制作一款替代品,代碼如下可直接搬去使用;

//
//  CADisplayLink.swift
//  Harbeth
//
//  Created by Condy on 2023/1/6.
//

import Foundation

#if os(macOS)
import AppKit

public typealias CADisplayLink = Harbeth.DisplayLink

// See: https://developer.apple.com/documentation/quartzcore/cadisplaylink
public protocol DisplayLinkProtocol: NSObjectProtocol {
    
    /// The refresh rate of 60HZ is 60 times per second, each refresh takes 1/60 of a second about 16.7 milliseconds.
    var duration: CFTimeInterval { get }
    
    /// Returns the time between each frame, that is, the time interval between each screen refresh.
    var timestamp: CFTimeInterval { get }
    
    /// Sets how many frames between calls to the selector method, defult 1
    var frameInterval: Int { get }
    
    /// A Boolean value that indicates whether the system suspends the display link’s notifications to the target.
    var isPaused: Bool { get set }
    
    /// Creates a display link with the target and selector you specify.
    /// It will invoke the method called `sel` on `target`, the method has the signature ``(void)selector:(CADisplayLink *)sender``.
    /// - Parameters:
    ///   - target: An object the system notifies to update the screen.
    ///   - sel: The method to call on the target.
    init(target: Any, selector sel: Selector)
    
    /// Adds the receiver to the given run-loop and mode.
    /// - Parameters:
    ///   - runloop: The run loop to associate with the display link.
    ///   - mode: The mode in which to add the display link to the run loop.
    func add(to runloop: RunLoop, forMode mode: RunLoop.Mode)
    
    /// Removes the receiver from the given mode of the runloop.
    /// This will implicitly release it when removed from the last mode it has been registered for.
    /// - Parameters:
    ///   - runloop: The run loop to associate with the display link.
    ///   - mode: The mode in which to remove the display link to the run loop.
    func remove(from runloop: RunLoop, forMode mode: RunLoop.Mode)
    
    /// Removes the object from all runloop modes and releases the `target` object.
    func invalidate()
}

/// Analog to the CADisplayLink in iOS.
public final class DisplayLink: NSObject, DisplayLinkProtocol {
    
    // This is the value of CADisplayLink.
    private static let duration = 0.016666667
    private static let frameInterval = 1
    private static let timestamp = 0.0 // 該值隨時會變,就取個開始值吧!
    
    private let target: Any
    private let selector: Selector
    private let selParameterNumbers: Int
    private let timer: CVDisplayLink?
    private var source: DispatchSourceUserDataAdd?
    private var timeStampRef: CVTimeStamp = CVTimeStamp()
    
    /// Use this callback when the Selector parameter exceeds 1.
    public var callback: Optional<(_ displayLink: DisplayLink) -> ()> = nil
    
    /// The refresh rate of 60HZ is 60 times per second, each refresh takes 1/60 of a second about 16.7 milliseconds.
    public var duration: CFTimeInterval {
        guard let timer = timer else { return DisplayLink.duration }
        CVDisplayLinkGetCurrentTime(timer, &timeStampRef)
        return CFTimeInterval(timeStampRef.videoRefreshPeriod) / CFTimeInterval(timeStampRef.videoTimeScale)
    }
    
    /// Returns the time between each frame, that is, the time interval between each screen refresh.
    public var timestamp: CFTimeInterval {
        guard let timer = timer else { return DisplayLink.timestamp }
        CVDisplayLinkGetCurrentTime(timer, &timeStampRef)
        return CFTimeInterval(timeStampRef.videoTime) / CFTimeInterval(timeStampRef.videoTimeScale)
    }
    
    /// Sets how many frames between calls to the selector method, defult 1
    public var frameInterval: Int {
        guard let timer = timer else { return DisplayLink.frameInterval }
        CVDisplayLinkGetCurrentTime(timer, &timeStampRef)
        return Int(timeStampRef.rateScalar)
    }
    
    public init(target: Any, selector sel: Selector) {
        self.target = target
        self.selector = sel
        self.selParameterNumbers = DisplayLink.selectorParameterNumbers(sel)
        var timerRef: CVDisplayLink? = nil
        CVDisplayLinkCreateWithActiveCGDisplays(&timerRef)
        self.timer = timerRef
    }
    
    public func add(to runloop: RunLoop, forMode mode: RunLoop.Mode) {
        if let _ = self.source {
            return
        }
        self.source = createSource(with: runloop)
    }
    
    public func remove(from runloop: RunLoop, forMode mode: RunLoop.Mode) {
        self.cancel()
        self.source = nil
    }
    
    public var isPaused: Bool = false {
        didSet {
            isPaused ? suspend() : start()
        }
    }
    
    public func invalidate() {
        cancel()
    }
    
    deinit {
        if running() {
            cancel()
        }
    }
}

extension DisplayLink {
    /// Get the number of parameters contained in the Selector method.
    private class func selectorParameterNumbers(_ sel: Selector) -> Int {
        var number: Int = 0
        for x in sel.description where x == ":" {
            number += 1
        }
        return number
    }
    
    /// Starts the timer.
    private func start() {
        guard !running(), let timer = timer else {
            return
        }
        CVDisplayLinkStart(timer)
        if source?.isCancelled ?? false {
            source?.activate()
        } else {
            source?.resume()
        }
    }
    
    /// Suspend the timer.
    private func suspend() {
        guard running(), let timer = timer else {
            return
        }
        CVDisplayLinkStop(timer)
        source?.suspend()
    }
    
    /// Cancels the timer, can be restarted aftewards.
    private func cancel() {
        guard running(), let timer = timer else {
            return
        }
        CVDisplayLinkStop(timer)
        if source?.isCancelled ?? false {
            return
        }
        source?.cancel()
    }
    
    private func running() -> Bool {
        guard let timer = timer else { return false }
        return CVDisplayLinkIsRunning(timer)
    }
    
    private func createSource(with runloop: RunLoop) -> DispatchSourceUserDataAdd? {
        guard let timer = timer else {
            return nil
        }
        let queue: DispatchQueue = runloop == RunLoop.main ? .main : .global()
        let source = DispatchSource.makeUserDataAddSource(queue: queue)
        var successLink = CVDisplayLinkSetOutputCallback(timer, { (_, _, _, _, _, pointer) -> CVReturn in
            if let sourceUnsafeRaw = pointer {
                let sourceUnmanaged = Unmanaged<DispatchSourceUserDataAdd>.fromOpaque(sourceUnsafeRaw)
                sourceUnmanaged.takeUnretainedValue().add(data: 1)
            }
            return kCVReturnSuccess
        }, Unmanaged.passUnretained(source).toOpaque())
        guard successLink == kCVReturnSuccess else {
            return nil
        }
        successLink = CVDisplayLinkSetCurrentCGDisplay(timer, CGMainDisplayID())
        guard successLink == kCVReturnSuccess else {
            return nil
        }
        // Timer setup
        source.setEventHandler(handler: { [weak self] in
            guard let `self` = self, let target = self.target as? NSObjectProtocol else {
                return
            }
            switch self.selParameterNumbers {
            case 0 where self.selector.description.isEmpty == false:
                target.perform(self.selector)
            case 1:
                target.perform(self.selector, with: self)
            default:
                self.callback?(self)
                break
            }
        })
        return source
    }
}
#endif

濾鏡動態(tài)圖GIF

  • 注入靈魂出竅、rbga色彩轉(zhuǎn)換、分屏操作之后如下所展示;
let filters: [C7FilterProtocol] = [
    C7SoulOut(soul: 0.75),
    C7ColorConvert(with: .rbga),
    C7Storyboard(ranks: 2),
]
let named = " ``GIF Link`` "
imageView.play(with: named, filters: filters)

[圖片上傳失敗...(image-5b500b-1690859963573)]

該類是在寫GIF使用濾鏡時刻的產(chǎn)物,需要的老鐵們直接拿去使用吧。另外如果對動態(tài)圖注入濾鏡效果感興趣的朋友也可以聯(lián)系我,郵箱yangkj310@gmail.com,喜歡就給我點(diǎn)個星??吧!

??.

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

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

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