关于macOS替代品之CADisplayLink

什么是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)

[图片上传失败...(image-5b500b-1690859963573)]

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

✌️.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容