You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

macOS自动滚动文本高CPU占用优化及替代方案咨询

macOS滚动文本控件CPU占用优化方案

问题背景

为macOS开发的滚动文本自定义类,通过Timer持续更新文本位置并频繁调用setNeedsDisplay实现滚动,CPU占用约12-14%,已排除CADisplayLink(不适用于macOS14以下版本)。


1. 如何优化现有实现降低CPU占用?

现有方案核心问题是频繁全量重绘和Timer与刷新率不同步,可从以下几点针对性优化:

优化点1:缩小重绘区域

不要每次调用setNeedsDisplay(bounds)刷新整个视图,只更新文本移动前后的区域:

@objc
func update(_ sender: Timer) {
    let oldX = point.x
    point.x -= 0.5
    
    // 计算需要重绘的区域:旧文本位置 + 新文本位置的并集
    let oldRect = NSRect(x: oldX, y: point.y, width: stringSize.width, height: stringSize.height)
    let newRect = NSRect(x: point.x, y: point.y, width: stringSize.width, height: stringSize.height)
    let dirtyRect = oldRect.union(newRect)
    
    setNeedsDisplay(dirtyRect)
    
    // 处理循环逻辑
    if point.x + stringSize.width < 0 {
        point.x += stringSize.width + spacing
    }
}

优化点2:预渲染文本为图片

把文本提前渲染成NSImage,后续仅绘制图片,避免每次重绘时重新计算文本布局:

// 添加私有变量缓存图片
private var textImage: NSImage?

// 在文本属性变化时生成图片
private func updateTextImage() {
    guard let text = text, let font = font else { return }
    let attributes: [NSAttributedString.Key: Any] = [
        .font: font,
        .foregroundColor: textColor
    ]
    let size = text.size(withAttributes: attributes)
    
    textImage = NSImage(size: size)
    textImage?.lockFocus()
    text.draw(at: .zero, withAttributes: attributes)
    textImage?.unlockFocus()
}

// 修改draw方法
override open func draw(_ dirtyRect: NSRect) {
    guard let textImage = textImage else { return }
    let context = NSGraphicsContext.current?.cgContext
    context?.saveGState()
    
    let clipPath = NSBezierPath(rect: bounds)
    clipPath.addClip()
    
    textImage.draw(at: point, from: .zero, operation: .sourceOver, fraction: 1.0)
    
    if point.x < 0 {
        let otherPoint = NSPoint(x: point.x + stringSize.width + spacing, y: point.y)
        textImage.draw(at: otherPoint, from: .zero, operation: .sourceOver, fraction: 1.0)
    }
    
    context?.restoreGState()
}

优化点3:对齐屏幕刷新率调整Timer间隔

屏幕刷新率通常为60Hz,将Timer间隔设为1/60秒(约0.0167),避免不必要的触发:

private var timerSpeed: Double? {
    return 1/60 // 固定对齐60Hz刷新率
}

// 修改update方法的步长控制
@objc
func update(_ sender: Timer) {
    let oldX = point.x
    let step = CGFloat(speed / 60) // 每帧移动的距离,由speed控制速度
    point.x -= step
    // ... 后续重绘逻辑
}

2. macOS中比Timer更合适的实现方式?

对于macOS14以下版本,CVDisplayLink(CoreVideo框架)是更优选择,它能同步屏幕刷新率,避免Timer的时间漂移和多余绘制:

实现步骤:

  1. 导入CoreVideo框架:import CoreVideo
  2. 添加私有变量:private var displayLink: CVDisplayLink?
  3. 创建并启动CVDisplayLink:
private func setupDisplayLink() {
    CVDisplayLinkCreateWithActiveCGDisplays(&displayLink)
    guard let displayLink = displayLink else { return }
    
    CVDisplayLinkSetOutputCallback(displayLink, { _, _, _, _, _, userInfo in
        guard let selfPtr = userInfo?.assumingMemoryBound(to: ScrollingTextView.self).pointee else {
            return kCVReturnSuccess
        }
        DispatchQueue.main.async {
            selfPtr.updateDisplayLink()
        }
        return kCVReturnSuccess
    }, Unmanaged.passUnretained(self).toOpaque())
    
    CVDisplayLinkStart(displayLink)
}

private func updateDisplayLink() {
    let step = CGFloat(speed / 60)
    let oldX = point.x
    point.x -= step
    
    // 重绘逻辑同优化后的update方法
    let oldRect = NSRect(x: oldX, y: point.y, width: stringSize.width, height: stringSize.height)
    let newRect = NSRect(x: point.x, y: point.y, width: stringSize.width, height: stringSize.height)
    setNeedsDisplay(oldRect.union(newRect))
    
    if point.x + stringSize.width < 0 {
        point.x += stringSize.width + spacing
    }
}

// 在updateTraits中替换Timer为CVDisplayLink
func updateTraits () {
    clearTimer()
    clearDisplayLink()
    if stringSize.width >= length {
        if isDelayed {
            Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in
                self?.setupDisplayLink()
            }
        } else {
            setupDisplayLink()
        }
    } else {
        stopScrolling()
    }
}

// 清理方法
private func clearDisplayLink() {
    if let displayLink = displayLink {
        CVDisplayLinkStop(displayLink)
        self.displayLink = nil
    }
}

3. Core Animation是否是更优方案?如何实现?

Core Animation是更优方案,它利用GPU加速,无需手动调用setNeedsDisplay,CPU占用会大幅降低,动画也更流畅。

实现思路:

用CATextLayer承载文本,通过CABasicAnimation实现滚动,再通过两个文本层循环实现无缝滚动:

代码实现:

open class CAScrollingTextView: NSView {
    open var text: String = "" {
        didSet {
            textLayer.string = text
            textLayer2.string = text
            updateTextSize()
        }
    }
    open var font: NSFont = .systemFont(ofSize: 14) {
        didSet {
            textLayer.font = font
            textLayer2.font = font
            updateTextSize()
        }
    }
    open var textColor: NSColor = .black {
        didSet {
            textLayer.foregroundColor = textColor.cgColor
            textLayer2.foregroundColor = textColor.cgColor
        }
    }
    open var speed: Double = 10.0 // 滚动速度,单位:点/秒
    
    private let textLayer = CATextLayer()
    private let textLayer2 = CATextLayer()
    private var textWidth: CGFloat = 0
    private let spacing: CGFloat = 20
    
    override public init(frame: NSRect) {
        super.init(frame: frame)
        setupLayers()
    }
    
    required public init?(coder: NSCoder) {
        super.init(coder: coder)
        setupLayers()
    }
    
    private func setupLayers() {
        wantsLayer = true
        layer?.masksToBounds = true
        
        // 配置文本层
        textLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0
        textLayer.alignmentMode = .left
        textLayer.isWrapped = false
        
        textLayer2.contentsScale = textLayer.contentsScale
        textLayer2.alignmentMode = .left
        textLayer2.isWrapped = false
        
        layer?.addSublayer(textLayer)
        layer?.addSublayer(textLayer2)
    }
    
    private func updateTextSize() {
        textLayer.layoutIfNeeded()
        textWidth = textLayer.preferredFrameSize().width
        startScrolling()
    }
    
    private func startScrolling() {
        // 移除现有动画
        textLayer.removeAllAnimations()
        textLayer2.removeAllAnimations()
        
        guard textWidth > bounds.width else {
            // 文本长度不足,无需滚动
            textLayer.frame = CGRect(x: 0, y: (bounds.height - textLayer.preferredFrameSize().height)/2, width: textWidth, height: bounds.height)
            textLayer2.isHidden = true
            return
        }
        
        textLayer2.isHidden = false
        
        // 初始位置
        textLayer.frame = CGRect(x: 0, y: (bounds.height - textLayer.preferredFrameSize().height)/2, width: textWidth, height: bounds.height)
        textLayer2.frame = CGRect(x: textWidth + spacing, y: textLayer.frame.origin.y, width: textWidth, height: bounds.height)
        
        // 计算动画时长
        let totalDistance = textWidth + spacing + bounds.width
        let duration = totalDistance / CGFloat(speed)
        
        // 创建动画
        let animation = CABasicAnimation(keyPath: "position.x")
        animation.fromValue = textLayer.position.x
        animation.toValue = textLayer.position.x - totalDistance
        animation.duration = duration
        animation.repeatCount = .infinity
        animation.isRemovedOnCompletion = false
        animation.fillMode = .forwards
        
        textLayer.add(animation, forKey: "scroll")
        textLayer2.add(animation, forKey: "scroll")
    }
    
    override open func layout() {
        super.layout()
        startScrolling()
    }
    
    open func stopScrolling() {
        textLayer.removeAllAnimations()
        textLayer2.removeAllAnimations()
        textLayer.frame = CGRect(x: 0, y: (bounds.height - textLayer.preferredFrameSize().height)/2, width: textWidth, height: bounds.height)
        textLayer2.isHidden = true
    }
}

内容的提问来源于stack exchange,提问作者Avi Rok

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.19 03:24:52