关于macOS替代品之CADisplayLink

9,120 阅读7分钟

什么是CADisplayLink

CADisplayLink是一个能让我们以和屏幕刷新率相同的频率将内容画到屏幕上的定时器。

  • CADisplayLink以特定模式注册到runloop后,每当屏幕显示内容刷新结束的时候,runloop就会向CADisplayLink指定的target发送一次指定的selector消息,CADisplayLink类对应的selector就会被调用一次。

  • 通常情况下,iOS设备的刷新频率事60HZ也就是每秒60次,那么每一次刷新的时间就是1/60秒大概16.7毫秒。

  • iOS设备的屏幕刷新频率是固定的,CADisplayLink 在正常情况下会在每次刷新结束都被调用,精确度相当高。但如果调用的方法比较耗时,超过了屏幕刷新周期,就会导致跳过若干次回调调用机会。

  • 如果CPU过于繁忙,无法保证屏幕 60次/秒 的刷新率,就会导致跳过若干次调用回调方法的机会,跳过次数取决CPU的忙碌程度。

DisplayLink概览

对外开放方法属性,简单模拟iOS系统对应的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 }
    
    /// 是否处于暂停状态
    var isPaused: Bool { get set }
    
    /// 使用您指定的目标和选择器创建显示链接
    /// 将在“target”上调用名为“sel”的方法,该方法对应``(void)selector:(CADisplayLink *)sender``
    init(target: Any, selector sel: Selector)
    
    /// 将接收器添加到给定的运行循环和模式中
    func add(to runloop: RunLoop, forMode mode: RunLoop.Mode)
    
    /// 从运行循环的给定模式中移除接收器
    func remove(from runloop: RunLoop, forMode mode: RunLoop.Mode)
    
    /// 销毁计时器,并释放“目标”对象
    func invalidate()
}

DisplayLink方法和属性介绍

  • 初始化

然后把 CADisplayLink 对象添加到 runloop 中后,并给它提供一个 target 和 select 在屏幕刷新的时候调用

/// 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
}()
  • 停止方法

执行 invalidate 操作时,CADisplayLink 对象就会从 runloop 中移除,selector 调用也随即停止

deinit {
    if displayLinkInitialized {
        displayLink.invalidate()
    }
}
  • 开启or暂停

开启计时器或者暂停计时器操作,

/// 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)
}
  • 定义每次之间必须传递多少个显示帧

用来设置间隔多少帧调用一次 selector 方法,默认值是1,即每帧都调用一次。如果每帧都调用一次的话,对于iOS设备来说那刷新频率就是60HZ也就是每秒60次,如果将 frameInterval 设为2那么就会两帧调用一次,也就是变成了每秒刷新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的重复绘制,如:下载进度条,自定义动画设计,视频播放渲染等;

/// 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设计实现

由于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

滤镜动态图GIF

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

tutieshi_640x524_1s.gif

该类是在写GIF使用滤镜时刻的产物,需要的老铁们直接拿去使用吧。另外如果对动态图注入滤镜效果感兴趣的朋友也可以联系我,邮箱yangkj310@gmail.com,喜欢就给我点个星🌟吧!

✌️.