剪贴板监控背后的复杂度
从 0.5 秒到 15 秒——Deck 如何通过自适应轮询、内容类型解析、OOM 防护和 IDE 集成让剪贴板监控既灵敏又省电。
从 0.5 秒到 15 秒——Deck 如何让剪贴板监控既灵敏又省电
macOS 上剪贴板监控的核心很直白:用定时器轮询 NSPasteboard.general.changeCount,数值变了就读新内容。一个周末原型就能跑起来。
但一个每天运行 10 小时以上的剪贴板管理器,得处理原型完全忽略的问题——持续轮询的电池消耗、200 MB 截图引发的内存爆炸、Figma 的非标准剪贴板格式、以及跨五种编辑器家族的 IDE 源码追踪。本文介绍 Deck 如何处理这些问题:自适应轮询、内容类型解析、OOM 防护、IDE 集成,以及 Figma 剪贴板检测。
1. 自适应轮询:做最少的事
最朴素的做法是固定间隔定时器——比如每 0.5 秒一次。响应是快了,但一小时 7200 次唤醒,笔记本在用电池的时候根本受不了。
Deck 的方案是一套自适应轮询机制,根据三个信号动态调节间隔:用户活动状态、电源状态和系统热压力。
边界常量
| Constant | Value | Purpose |
|---|---|---|
minInterval |
0.5s | Fastest poll rate (active use) / 最快轮询间隔(活跃使用时) |
maxInterval |
2.0s | Normal ceiling / 正常最大间隔 |
idleSecondsThreshold |
8.0s | Idle detection threshold / 用户空闲判定阈值 |
idleMaxInterval |
15.0s | Ceiling when user is idle / 空闲时最大间隔 |
thermalMaxInterval |
10.0s | Ceiling under thermal pressure / 热状态下最大间隔 |
pollBoundsCacheTTL |
1.0s | Cache TTL for computed bounds / 轮询边界缓存有效期 |
核心函数 effectivePollBounds() 通过叠加这些条件来计算当前的最小/最大间隔:
func effectivePollBounds() -> (min: TimeInterval, max: TimeInterval) {
var effectiveMin = minInterval // 0.5s
var effectiveMax = maxInterval // 2.0s
// Low Power Mode: widen the interval significantly
if ProcessInfo.processInfo.isLowPowerModeEnabled {
effectiveMin = min(effectiveMin * 2.0, 2.0)
effectiveMax = max(effectiveMax * 4.0, 8.0)
}
// Thermal pressure: back off further
if thermalState == .serious || thermalState == .critical {
effectiveMin = max(effectiveMin, 1.0)
effectiveMax = max(effectiveMax, thermalMaxInterval) // 10s
}
// User idle: stretch the max
if secondsSinceUserInteraction() > idleSecondsThreshold {
effectiveMax = max(effectiveMax, idleMaxInterval) // 15s
}
return (effectiveMin, effectiveMax)
}当剪贴板发生变化时,间隔立即回到 min。无变化时,每次轮询后间隔乘以 1.5 倍退避,直到达到 max。因此用户频繁复制时,轮询间隔保持在 0.5 秒;8 秒无操作后,逐步拉长到 15 秒。
func adjustPollInterval(success: Bool) {
let bounds = effectivePollBounds()
if success {
pollInterval = bounds.min
} else {
pollInterval = min(pollInterval * 1.5, bounds.max)
}
}定时器架构
定时器运行在一个专用的 DispatchQueue(.utility 优先级),不在主线程上:
let pollQueue = DispatchQueue(label: "deck.clipboard.poll", qos: .utility)
let pollTimer = DispatchSource.makeTimerSource(queue: pollQueue)定时器设置了 25% 的 leeway(限制在 50–500ms 范围内),让 macOS 可以将定时器唤醒与其他系统定时器合并,在使用电池时能明显减少实际唤醒频率。
电源状态感知
Deck 监听 NSProcessInfoPowerStateDidChange 通知来检测低功耗模式的切换。除了调整轮询间隔,还会节制下游任务——比如在低功耗模式下,链接预览不再拉取图片:
let includeImage = !ProcessInfo.processInfo.isLowPowerModeEnabled
LinkMetadataService.shared.prefetch(for: url, includeImage: includeImage)2. ⌘C 拦截:跳过轮询周期
轮询本质上是被动的。即使 0.5 秒的间隔,也有体感上的延迟。Deck 通过全局拦截 ⌘C/⌘X 来消除这种延迟,在用户按键的瞬间就触发剪贴板检测。
func installCopyMonitor() {
NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { event in
guard event.modifierFlags.contains(.command) else { return }
let keyCode = event.keyCode
// 0x08 = C, 0x07 = X
guard keyCode == 0x08 || keyCode == 0x07 else { return }
self.pendingCopyCheck?.cancel()
self.pendingCopyCheck = Task {
try? await Task.sleep(nanoseconds: 80_000_000) // 80ms
await self.checkForChanges()
}
}
}80ms 的延迟是刻意设计的:来源应用需要一小段时间才能真正把数据写入 NSPasteboard。取消-重新调度的模式确保用户快速连按 ⌘C 时不会产生冗余检测。
3. 内容类型解析:优先级阶梯
NSPasteboard 不是一个只装一个值的容器。一次复制操作可能同时存入几十种表示形式——PNG、TIFF、RTF、HTML、纯文本、应用私有类型——剪贴板管理器必须从中挑出最有意义的一种。
Deck 定义了明确的优先级顺序:
// PasteboardType.supportedTypes (highest → lowest)
.png, .tiff, .jpeg, .heic, .heif, .gif, .webp, .bmp, // Images
.rtfd, .flatRTFD, .rtf, // Rich text
.fileURL, // Files
.publicURL, // URLs
.string // Plain text图片拥有最高优先级,因为它们信息密度最高且最难重建。纯文本在最底层——它几乎总是可以作为兜底项。
三级文本回退链
当主类型无法解析时,Deck 不会放弃。它会走一条三级回退链:
优先检测 HTML 内容或 Figma 标记。
2. resolveUTF16PlainText() — 尝试 public.utf16-external-plain-text,然后 public.utf16-plain-text。
遍历所有符合 UTType.text 的类型。
一个包含约 20 种类型的排除列表(包括 .string、.rtf、.html、.pdf、各种 WebKit/Chromium 内部类型和 macOS 系统类型)防止回退链重复处理已经尝试过的或已知无意义的类型。
一个实用优化:当 RTFD 条目同时携带纯文本表示时,Deck 优先使用纯文本——在内容本质上是文本时避免 RTFD 解码的开销。
4. OOM 防护:抵御 200 MB 截图
一个在复制大图时崩溃的剪贴板管理器,比没有剪贴板管理器还糟糕。Deck 实现了多层 OOM 防护,设置了逐级递增的阈值:
| Threshold | Value | What it guards |
|---|---|---|
maxPerTypeBytes |
1 MB | Per-type snapshot cap / 单类型快照上限 |
maxSnapshotBytes |
4 MB | Total snapshot byte limit / 粘贴快照总字节上限 |
maxInlineImagePasteBytes |
25 MB | Beyond this, skip inline image bytes if file URL exists / 超过此值且有文件 URL 时不写入图片字节 |
largeBlobThreshold |
512 KB | Data above this is stored as a separate blob file / 超过此值存为独立 Blob 文件 |
maxImageDataSize |
100 MB | Images above this are never loaded directly / 超过此值的图片不直接加载 |
512 KB 的 Blob 阈值特别值得一提。Deck 不将大数据内联存储在 SQLite 中,而是通过 BlobStorage.shared.storeAsync 异步写入独立文件,数据库中只存路径。这保持了数据库的精简,防止大 BLOB 造成 SQLite 页碎片。
对于超过 100 MB 的图片,resolvedData() 直接返回 nil——图片太大,不适合放在内存中。在显示时,thumbnail() 使用 CGImageSourceCreateThumbnailAtIndex 通过渐进解码生成缩略图,从不加载完整位图:
func generateSafeThumbnailFromData(_ data: Data, maxDimension: CGFloat) -> NSImage? {
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: false
]
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
else { return nil }
return NSImage(cgImage: thumbnail, size: .zero)
}5. IDE 源码定位追踪:知道代码从哪来
场景举例:你 20 分钟前从 VS Code 里复制了一段代码。现在要粘贴,但也想跳回当时复制的位置。
Deck 在复制时捕获一个 SourceAnchor——文件路径、行号、列号和 IDE 身份——让你一键就能跳回去。
struct SourceAnchor {
var ide: IDEKind
var filePath: String
var line: Int?
var column: Int?
var sourceBundleId: String?
var captureMethod: String?
var capturedAt: Int64?
var openHint: IDEOpenHint?
}支持的 IDE
| IDE | Bundle ID(s) |
|---|---|
| VS Code | com.microsoft.VSCode, com.microsoft.VSCodeInsiders |
| Cursor | com.todesktop.230313mzl4w4u92, com.anysphere.cursor, com.cursor.Cursor |
| Windsurf | com.exafunction.windsurf |
| Xcode | com.apple.dt.Xcode |
| JetBrains IDEs | com.jetbrains.* |
工作原理
来源识别从 org.nspasteboard.source 开始——一种某些应用(尤其是 IDE 相关工具)会写入的粘贴板类型。如果没有,Deck 退回使用 sourceApp.bundleIdentifier。
文件路径和光标位置来自 macOS 辅助功能 API。Deck 查询当前焦点 UI 元素的以下属性:
kAXDocumentAttribute/kAXFilenameAttribute/kAXURLAttribute→ file path / 文件路径 光标行号 选区起始行 列号计算
不同 IDE 的辅助功能树深度不同,因此 Deck 使用针对性调整的搜索预算:
| IDE Family | maxDepth |
maxNodes |
|---|---|---|
| VS Code / Cursor / Windsurf | 8 | 2000 |
| Xcode / JetBrains | 4 | 200 |
回跳方式
每个 IDE 系列都有各自的"打开文件到指定行"的方式:
6. Figma 剪贴板检测
Figma 作为基于 Chromium 的桌面应用,其剪贴板行为也带有 Chromium 特征。当你在 Figma 中复制一个画框或组件时,粘贴板收到的看起来是标准 HTML——但其中嵌入了用自定义 (figma) / (figmeta) 标记包裹的 base64 编码设计数据。大多数剪贴板管理器会把它当作普通 HTML 或纯文本处理,直接丢弃 Figma 专有数据。再粘贴回去时,设计结构就没了——得到的是扁平图片或乱码文本,而不是原来可编辑的组件。
Deck 通过 containsFigmaMarker(in:) 检测 Figma 内容,扫描 HTML 中的这些标记。一旦检测到,启动多步提取流程:
入口,仅对未识别类型执行。
2. extractFigmaPayload() — 从 UnsupportedPasteboardPayload.items 中搜索 Figma HTML。
需要时回退到 plist 解析。
从 HTML 中提取 (figma) 和 (figmeta) 的 base64 数据。
提取出的元数据结构为:
struct FigmaClipboardMeta {
var fileKey: String
var pasteID: String
var dataType: String
}
struct FigmaClipboardPayload {
var html: String
var figmaBase64: String
var figmetaBase64: String
var meta: FigmaClipboardMeta
var sourceURL: String? // from org.chromium.source-url
}一个关键的设计决策:一旦检测到 Figma 内容,Deck 完全跳过文本回退链。完整保留 Figma 的结构化数据,而不是将其展平为会丢失所有设计语义的纯文本。
7. 完整监控循环
把所有部分组合起来,每次剪贴板检测的完整流程如下:
sequenceDiagram
participant Timer as DispatchSourceTimer
participant Key as ⌘C Monitor
participant Check as checkForChanges()
participant PB as NSPasteboard
participant Filter as Skip Filters
participant Parse as Content Parser
participant IDE as IDEAnchorService
participant Store as DeckDataStore
alt Timer fires
Timer->>Check: poll interval elapsed
else ⌘C / ⌘X detected
Key->>Check: after 80ms delay
end
Check->>Check: guard !isCheckingForChanges (re-entrancy lock)
Check->>PB: read changeCount
PB-->>Check: currentChangeCount
alt changeCount unchanged
Check->>Check: adjustPollInterval(success: false) → interval × 1.5
else changeCount changed
Check->>Filter: apply skip filters
Note over Filter: • Deck's own writes (deckPasteboardType)<br/>• Recently pasted content<br/>• Sensitive / concealed items<br/>• Ignored applications
alt filtered out
Filter-->>Check: skip
else passes filters
Check->>Parse: ClipboardItem(with:pasteboard:)
Note over Parse: type priority resolution<br/>+ text fallback chain<br/>+ Figma detection<br/>+ OOM guards
Parse-->>Check: clipboardItem
Check->>IDE: captureAnchor(for: sourceApp)
IDE-->>Check: SourceAnchor?
Note over Check: optional post-processing:<br/>steganography decode,<br/>smart line-joining,<br/>link preview prefetch
Check->>Store: addParsedItemWithRules(item)
Check->>Check: adjustPollInterval(success: true) → min interval
end
end结语
这些系统之间大多相互关联。热压力改变轮询间隔,进而影响 ⌘C 拦截的时效性。一次 Figma 粘贴可能携带 20 多种粘贴板类型,同时触发内容优先级阶梯和 OOM 防护。IDE 锚点捕获必须在 80ms 窗口内完成,否则下一次轮询周期会覆盖辅助功能状态。
还有一些我们仍在优化的方向。辅助功能 API 在某些应用上可能较慢(特别是 DOM 树很深的 Electron 编辑器),我们正在试验缓存策略来减少 AX 查询频率。我们也在关注 Apple 是否会在未来的 macOS 版本中提供更多事件驱动的 NSPasteboard 监听 API,这将进一步减少轮询开销。
Deck 是一款一切留在本地的 macOS 剪贴板管理器。了解更多:deckclip.app。