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的时间漂移和多余绘制:
实现步骤:
- 导入CoreVideo框架:
import CoreVideo - 添加私有变量:
private var displayLink: CVDisplayLink? - 创建并启动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
相关产品推荐
相关产品推荐

