资讯动态

用SwiftUI+AppKit打造macOS剪贴板历史工具OneClip

发布时间:2026/9/16 3:24:34 来源:尧图企业网站定制
不知道你有没有经历过这个场景刚复制好一段重要的配置想贴到另一个窗口结果手一抖又复制了别的东西之前那段就再也找不回来了我在日常开发中被这个问题折磨了很久。macOS 自带的剪贴板只能记住最后一次复制的内容每次覆盖都是永久性的。代码片段、临时命令、几行日志、一段 URL这些零碎内容在一天的开发工作里流转频率极高丢一次就要重新翻历史记录、重新搜索、重新复制非常打乱节奏。市面上的剪贴板增强工具其实不少但要么收费、要么做得太重、要么隐私策略让我不放心所以最后决定自己动手写一个。这个项目就是 OneClip一个纯粹、克制、完全本地运行的 macOS 剪贴板历史管理工具。这篇文章我会把整个开发过程拆开来讲从最初的需求定位、技术选型到状态栏应用的核心架构再到剪贴板监听、历史记录存储、搜索交互这些关键模块的实现最后是签名打包和分发上的一些经验。如果你正在考虑做自己的第一个 macOS 应用或者想看看一个 Menu Bar 小工具是怎么从零到一落地变成能用、顺手、敢分发给别人的产品的这篇文章应该能给你不少参考价值。过程中我也会提到一些实际踩坑的地方那些在官方文档里很难一次查到的东西。1. 先想清楚OneClip 要解决的真实问题和 MVP 边界1.1 macOS 剪贴板的天然缺陷它天生只有一格macOS 的剪贴板在系统层面就是一个全局的NSPasteboard实例负责承载复制、剪切、粘贴这种跨应用的数据交换。它好用但设计上就是“单槽位”的新内容进来旧内容立刻被覆盖。这在几十年来的桌面交互里虽然够用对开发者、写作者、运营这类高频复制粘贴人群来说就成了明显的效率瓶颈。我复盘自己一天的工作流大概会在这些场景里频繁使用剪贴板从 GitHub 或文档里复制命令行在 IDE 和浏览器之间搬运代码片段把报错信息复制出来搜索临时保存几段配置或 JSON 结构。每次复制都意味着上一条数据“人间蒸发”而这种丢失往往发生在最着急的时候。所以我最初的需求非常朴素让每一条复制过的东西都能被找回来并且找回的成本要足够低——最好一个快捷键就能弹出历史列表再敲两三个关键字就定位到目标。1.2 MVP 边界做减法比做加法难得多很多个人项目死在功能膨胀上。一开始想做剪贴板历史后来觉得应该加云同步再加图片 OCR、固定片段、团队共享……每一个功能看起来都合理但合在一起就是一个永远发不出来的工程。OneClip 第一版明确砍掉了几个东西不做云同步。剪贴板内容往往包含密码、Token、临时链接这些敏感信息本地都不能乱存太久上云更是风险而且需要账号体系、后端服务工作量陡增。不做图片和文件的历史管理。第一版只处理纯文本图片粘贴板在技术上完全可以监听但存储和渲染的复杂度不一样优先级低。不做复杂的分类和标签体系。先提供“时间倒序 搜索”这一个核心交互等验证完主路径再考虑扩展。第一版真正保留的就三件事监听剪贴板变化、把文本历史存下来、提供一个能搜索又能快速回填的界面。这个边界克制到不能再克制但也正是这种克制让我能在三周内从零到一推出一版可以日常自用的应用。做个人项目尤其是一人开发的工具软件一定要问自己一句如果没有某个功能用户会因为缺失而离开吗大多数情况下答案是不会。2. 技术选型SwiftUI 为主AppKit 补位的组合方案2.1 为什么选 SwiftUI开发效率和声明式 UI 的胜利macOS 上的原生 UI 框架有 AppKit 和 SwiftUI 两条路线。AppKit 成熟、底层、几乎什么都能做但代码量大状态管理容易乱一个列表要写NSTableView dataSource delegate一堆样板代码。SwiftUI 在 macOS Big Sur 之后已经达到可以认真做工具类 App 的成熟度声明式语法让界面和状态的关系非常直观在 Menu Bar 弹窗这种界面不算复杂的场景里SwiftUI 的体验是碾压级的。OneClip 的主要界面就是一个状态栏图标、一个弹出面板、一个列表、一个搜索框。这种结构用 SwiftUI 来表达代码量比 AppKit 少一半以上而且状态同步是自动的。比如“搜索框输入内容 → 列表自动过滤”这组交互SwiftUI 里通过一个State字符串就能驱动写 AppKit 则要手动管理数据源刷新、选中态恢复、滚动位置保持工作量完全不在一个量级。2.2 状态栏应用的整体架构NSStatusItem NSPopovermacOS 的 Menu Bar 应用标准架构是NSStatusItem承载一个状态栏图标点击后弹出NSPopover。Poppover 里放一个NSHostingController把 SwiftUI 视图包进来。这套组合在无数剪贴板工具、菜单栏效率工具里都在用方案相当成熟。// AppDelegate 中创建状态栏图标和弹窗 let statusItem NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) if let button statusItem.button { button.image NSImage(systemSymbolName: doc.on.clipboard, accessibilityDescription: OneClip) } let popover NSPopover() popover.behavior .transient // 点击外部自动关闭 popover.contentViewController NSHostingController(rootView: ContentView()) popover.contentSize NSSize(width: 360, height: 480)这里有两个关键点值得说一下。第一popover.behavior .transient决定了弹出窗失去焦点时会不会自动关闭。剪贴板工具的使用场景是“弹出来、选中、回车、窗口消失”如果用.semitransient或.applicationDefined关闭时机不可控手感和系统工具不一致。.transient是这一类应用的默认选项实测体验最接近原生。第二状态栏图标的点击事件默认是“单击切换”不需要额外处理。但如果你后续要支持“单击弹出 / 右键出菜单”就需要重写button.action自己区分NSStatusBarButton的点击方式和鼠标位置。OneClip 第一版保留了最简单的单击弹出右键菜单只在状态栏层面提供“退出”功能。2.3 一个需要的心理准备SwiftUI 在 macOS 上的坑不比 iOS 少SwiftUI 从 iOS 迁移到 macOS 并不是完全无缝的。简单说TextField在 iOS 上自动获得焦点有一套行为在 macOS 上则需要手动调用NSApp.keyWindow?.makeFirstResponder()或者用FocusState来绑定稍不留神就会出现“弹窗出现但输入框没有光标”的尴尬情况。类似的小问题有很多后面有一节我会专门讲排查过程。另外版本兼容是个不能忽视的成本。比如MenuBarExtra是 macOS 13 才引入的新 API它能让纯 SwiftUI 应用直接拥有状态栏图标不需要碰 AppKit。但如果你的用户群体还有人在用 macOS 12 甚至 11就得回退到NSStatusItem方案或者自己封装一层兼容层。OneClip 的部署目标定在 macOS 12.0所以我选择了一直用NSStatusItem NSPopover这也是很多长期维护的同类工具还在使用的方案。兼容性好于花哨的新 API这是工具类应用最朴素的运行逻辑。3. 核心功能实现剪贴板监听、历史记录存储与搜索交互3.1 剪贴板监听的两种方案轮询 vs 通知macOS 没有提供一个“剪贴板内容变了就通知我”的公开发布系统 API。所以开发剪贴板监听类应用主流做法就是轮询NSPasteboard.general.changeCount。final class ClipboardMonitor { private var lastChangeCount NSPasteboard.general.changeCount private var timer: Timer? func startMonitoring() { timer Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in let currentCount NSPasteboard.general.changeCount guard currentCount ! self?.lastChangeCount else { return } self?.lastChangeCount currentCount self?.handleNewChange() } } private func handleNewChange() { guard let items NSPasteboard.general.pasteboardItems, let string items.first?.string(forType: .string), !string.isEmpty else { return } // 这里把内容交给存储层 } }changeCount是系统维护的一个单调递增计数器只要剪贴板内容发生变化这个值就会改变。监听它的妙处在于我们不需要读剪贴板全文来比对是否变化只需要记住上次的值。这个设计既简单又高效是整个应用的基石。轮询间隔我固定在 0.5 秒。为什么不是 0.1 秒因为太快的轮询无论剪贴板有没有变化都会白白消耗 CPU还会让 Mac 的风扇莫名转起来。为什么不是 1 秒因为复制完内容后立刻切到 OneClip 弹出搜索如果监听延迟太长会出现“复制了但列表里没有”的错觉体验掉分。实测 0.5 秒在响应和资源占用之间是比较平衡的普通使用中根本感觉不到延迟。注意剪贴板也可能包含格式化的富文本、图片、文件 URL 等类型。OneClip 第一版只在.string类型上做文章。如果只判断changeCount变了但拿不到字符串就直接跳过不记录。这样能避免图片复制导致的历史记录里出现一堆空白条目。3.2 历史记录存储第一版用 UserDefaults但这有一个前提存储方案是这类工具很容易被过度设计的地方。有人会推荐 Core Data有人推荐 SQLite有人说直接写 JSON 文件。对于 OneClip 这种最多保存几百条纯文本的轻量场景UserDefaults其实是最简单的选择——只要你会封装它完全可以胜任。struct ClipboardHistory { private static let key clipboard.history private static let limit 500 static func load() - [String] { return UserDefaults.standard.stringArray(forKey: key) ?? [] } static func append(_ item: String) { var items load() // 如果内容已经存在先移除旧位置再放到最前面 if let existingIndex items.firstIndex(of: item) { items.remove(at: existingIndex) } items.insert(item, at: 0) if items.count limit { items Array(items.prefix(limit)) } UserDefaults.standard.set(items, forKey: key) } }几个设计细节说明一下去重并置顶。复制同一段代码三次历史列表里不应该出现三行相同的内容。这个“去旧位置、插最前”的操作让重复复制变得无感用户看到的是这段内容自动跑到第一条。上限 500 条。UserDefaults本质是写入~/Library/Preferences/下的 plist 文件虽然支持存数组但体积过大会拖慢读写。单条纯文本按平均 1KB 算500 条也就 500KB 左右完全可控。这个容量对日常使用已经足够真想要更长的历史后面换 SQLite 也可以平滑迁移。敏感数据只在本地存。OneClip 走的是纯本地链路历史记录不离开设备。这一点在功能设计时就明确过了剪贴板会过手密码、验证码、私钥一类的内容任何上传到服务端的方案都是给自己挖坑。搜索逻辑我第一版甚至没有单独建索引——在 500 条字符串里用localizedCaseInsensitiveContains做一遍过滤性能完全无压力。只有数据量到几千条时才需要用到NSPredicate或者引入 SQLite FTS5。做 MVP 的标准就是当功能简单到不需要额外方案时就绝不动用复杂方案。3.3 搜索框和列表的协同让键盘完成整个闭环剪贴板工具的高频用户基本是键盘党。一个理想路径是按快捷键弹出 → 输入关键词 → 列表过滤 → 上下键选择 → 回车复制回剪贴板 → 弹窗消失。要让这个路径成立需要处理三个细节。第一个是自动聚焦。弹窗出现后TextField必须立刻拿到输入焦点否则用户还要用鼠标点一下搜索框效率折半。SwiftUI 里可以这样实现FocusState private var isSearchFocused: Bool var body: some View { VStack { TextField(搜索剪贴板历史, text: $searchText) .focused($isSearchFocused) } .onAppear { isSearchFocused true } }这在第一版工作时看起来毫无问题。但实际测试发现应用在 Menu Bar 场景下有时onAppear会出现在 popover 尚未完全展开的时机焦点设置失败。我后来在NSPopover的show调用后加了DispatchQueue.main.async延迟执行聚焦基本稳定了。这个属于“文档里不会教你但实际开发一定会遇到”的细节。第二个是列表选中态跟随键盘。SwiftUI 的List配selection参数配合.keyboardShortcut(.return)可以拦截回车事件。选中行时把内容写回剪贴板然后关闭 popover。关闭可以走NSApp.windows.first?.close()或者维护一个isPopoverShown的 binding个人实践下来通过NSApp.abortModal()不可靠最简单的还是持有一个 popover 引用调popover.performClose(nil)。第三个是空结果的反馈。搜索没有匹配时直接显示一个空白列表非常不友好。我加了一个ContentUnavailableView风格的占位文本。这类小细节决定了工具的质感属于“用了才发现舒服”的隐形体验。4. 做一个真正“顺手”的 Menu Bar 应用那些不能忽视的细节4.1 全局快捷键的选择避开系统占用行为要符合直觉Menu Bar 应用的最大优势是“全局呼唤”所以全局快捷键是第一版就要有的能力。macOS 上注册全局快捷键的标准方案是 Carbon 的RegisterEventHotKey虽然 API 老旧但稳定至今仍然是主流做法。import Carbon.HIToolbox var hotKeyRef: EventHotKeyRef? let hotKeyID EventHotKeyID(signature: OSType(0x4F4E4543), id: 1) // ONEC func registerHotKey() { var gMyHotKeyID hotKeyID var eventType EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) InstallEventHandler(GetApplicationEventTarget(), { (_, event, _) - OSStatus in // 弹出或关闭 OneClip 面板 return noErr }, 1, eventType, nil, nil) RegisterEventHotKey(UInt32(kVK_ANSI_V), UInt32(cmdKey | shiftKey), gMyHotKeyID, GetApplicationEventTarget(), 0, hotKeyRef) }默认快捷键我选了⌘⇧V。这个组合键比⌥⇧V更顺手比⌥Space这样的组合冲突概率低。注册之前建议先判断一下是否被其他应用占用但技术上很难精确探测所以最稳的方案是默认给一个同时在设置界面允许用户自己修改或直接禁用。注意 OCR 类和输入法类应用经常占用各种组合键所以“可配置”是这类功能的底线。4.2 关于麦克风权限的一个冷知识读取剪贴板本身不需要任何授权网上不少初学 macOS 开发的人会担心读取剪贴板会不会被系统弹权限框答案是不会。NSPasteboard的读取不需要任何Info.plist声明也没有 TCC 弹窗系统会把剪贴板视为一个非敏感资源。应用可以随时读取。这也意味着剪贴板数据事实上对所有应用透明——你复制过的密码任何本地应用想看都能看到。所以那些强调“不联网、不统计、不采集”的剪贴板工具确实是有意义的差异化卖点。OneClip 没有加入任何网络权限在沙盒设置里干脆不开 outgoing network从基础上保证数据不出设备。4.3 外观细节跟随系统深浅色和字体选择一个显示代码片段的剪贴板工具列表的字体渲染直接影响使用体验。OneClip 列表里我用了等宽字体来展示代码类内容。macOS 系统自带的SF Mono是最稳妥的选择它是 Xcode 和 Terminal 的默认字体在 Retina 屏上的可读性和渲染效果都是为了 Mac 的显示器调过的。.listRowBackground(Color.clear) .font(.system(.body, design: .monospaced))这里稍微提一下字体对开发体验的影响。我见过不少人在 Linux 上写代码时费劲找接近 macOS 的字体——因为 macOS 的字体渲染偏厚润、清晰度好Windows/Linux 上的默认字体最终效果都不一样。做 Mac 原生应用有一个隐形红利直接用系统字体就能拿到最优的渲染结果几乎不需要做额外的字体订制。所以如果你在做一个 Mac 上的开发者工具尽量用system()提供的字体别自己去加载一堆第三方字体文件——又慢又不统一。浅色/深色模式方面SwiftUI 默认自适应唯一要注意的是自定义颜色要使用Color(nsColor: .textColor)这类语义色而不是写死Color.black或Color.white。这样系统切主题时界面会自动跟着变不需要额外判断。4.4 状态栏图标的显示与设置状态栏图标在系统浅色和深色主题下都要清晰可见。用 SF Symbols 的NSImage会自动适配但如果用自定义图片一般做法是提供template图片让系统根据菜单栏颜色自动渲染。OneClip 的图标我选了doc.on.clipboard这个 SF Symbol。开发初期其实踩过一个坑直接NSImage(systemSymbolName:)设置给statusItem.button图标在某些系统版本下会非常淡几乎看不清。原因是 menu bar 图标最好用isTemplate true的图片让系统自动处理高亮和明暗适配。SF Symbols 默认就是 template 模式所以现在代码已经很简洁。如果你在别的项目里换自定义图标记得设置.isTemplate true。4.5 防止应用被误退出Menu Bar 应用一个尴尬的地方是用户可能会在 Dock 里点上关闭按钮期待它退出但实际上状态栏应用应该继续驻留。做法有几种在Info.plist里设置LSUIElement YES应用不显示 Dock 图标只显示状态栏图标。这是最标准的 Menu Bar 应用配置。如果还想保留设置窗口可以在需要时手动创建一个NSWindow展示设置界面。用LSUIElement会让应用彻底没有传统窗口这是剪贴板工具最干净的形态。缺点是你不能依赖系统“退出”菜单得自己在状态栏右键菜单里提供退出入口。5. 打包、签名与分发从“本地能跑”到“别人能用”5.1 代码签名与公证macOS 应用的门槛本地通过 Xcode 运行应用不代表它能被分发。macOS 有完善的代码签名和 Gatekeeper 机制未签名或未公证的应用在别的机器上首次运行时会被拦下来默认无法打开。给 OneClip 做签名和公证的完整流程大概是在 Apple Developer 后台创建 App ID开启对应 CapabilitiesOneClip 只用了沙盒和 App Sandbox。用 Xcode 的 Signing Capabilities 面板自动签名开发阶段用 “Sign to Run Locally” 即可但分发必须用 Developer ID 证书。先用xcodebuild archive导出 Release 构建然后上传到 Apple 做 notarizationxcrun notarytool submit OneClip.zip --keychain-profile OneClip-notary --waitnotarization 通过后再用 stapler 把凭证打进应用xcrun stapler staple OneClip.app这个过程第一遍走的时候非常容易卡在钥匙串权限、证书类型不对、notarization 流程不熟这些地方。我的建议是给应用打一个 zip 包再提交不要直接提交 .app 文件--wait参数可以避免手动去查轮询状态账号密钥建议单独创建 App Store Connect API Key不要用开发者账号的两步验证码。5.2 发布渠道App Store 与官网直下App Store 的优势是用户信任度高、自动更新、系统集成好但审查流程、沙盒限制和提审周期需要适应。对剪贴板工具这种明明没有任何网络请求的应用App Store 审核相对容易过但注意要让用户在隐私标牌上看到“不收集任何数据”这点对信任有加分。官网直下Developer ID 签名的优点是分发灵活用户不需要经过 App Store版本迭代即时生效。缺点是没有自动更新机制得自己实现或引入 Sparkle。第一版 OneClip 我选择的是官网直下 手动检查更新好处是省掉了提审等待第一时间能拿到用户反馈。提示无论走哪个渠道都记得为应用单独创建.icns图标文件。虽然代码签名不检查图标但一个专业的图标对工具软件的可信度影响比想象中更大。5.3 沙盒与无网络给用户一个安心的隐私预期OneClip 开启 App Sandbox 后写UserDefaults和读取剪贴板都不受影响。唯一要注意的是沙盒环境下UserDefaults的存储位置不同但 API 层面无感不需要特殊处理。我不在 Entitlements 里添加任何网络相关的权限这让应用天然无法联网配合“纯本地存储”的产品宣传用户会更容易信任。另一个细节是崩溃日志收集。没有网络的 App 怎么收集崩溃方案是使用系统自带的NSLog和 CrashReporter或者后续接入自愿的日志上传。第一版我先不做等用户反馈多了再决定要不要加一个“发送诊断信息”的按钮。6. 踩坑实录开发过程中最值得记录的五个问题6.1 Popover 点击外部不关闭这是初版最常见的交互问题点击弹窗外侧popover 纹丝不动。排查过程走了弯路才发现NSPopover.behavior .transient必须在 popover 创建时设置如果在某个自定义 init 或viewDidLoad之后再改有概率不生效。后来我把所有 popover 配置收敛到一个工厂方法里在 show 之前统一设置问题消失。6.2 自动聚焦失败前面提到过popover 未完全展开时设置 first responder 会失败。我最终用了双层保险DispatchQueue.main.asyncAfter(deadline: .now() 0.1) { self.isSearchFocused true }这个延迟在绝大多数场景下已经足够。如果后续有用户反馈焦点问题再考虑监听 popover 的didShow通知。6.3 历史记录里出现空格条目某段时间历史记录里经常出现“\n”或者空字符串条目。排查后发现是某些应用复制内容时会触发多次changeCount其中一部分是普通的排版命令。解法是在handleNewChange里对字符串做 trim空格、换行被清空的内容直接不入库。let trimmed string.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return }这个判断非常小但能把历史列表里的垃圾条目直接挡在门外效果立竿见影。6.4 状态栏图标在暗色主题下看不清SF Symbols 本身是 template 模式理论上没有这个问题但如果你后来替换了自定义图标一定要用带 alpha 通道的 PDF 资源并且设置isTemplate true。否则在深色菜单栏场景下纯黑色图标直接隐身。这个问题属于必须提前规划的类型等用户截图来问“你的图标去哪了”就很尴尬了。6.5 内存占用悄悄变大开发阶段一切正常用了半个月后发现 OneClip 的内存占用从 40MB 涨到了 100 多 MB。定位半天发现是历史记录数组的字符串一直被保留着即使被去重和裁剪也会因为 Swift 的字符串桥接机制残留占用。后来把存储上限从 1000 条降到 500 条并在写入时做一次字符串 copy内存稳定在 50MB 上下。对菜单栏工具来说这个占用可以接受但持续监控还是有必要的。这里也分享一个通用经验个人开发和真实用户使用最大的差别在于“数据模式不同”。你自己测试可能只会复制几百条短文本用户会复制几百 KB 的日志甚至更长的内容。在设计存储和列表渲染时就要考虑极端输入别只看第一版的数据量。问题原因解决方案popover 不自动关闭behavior 设置时机错误集中创建并预配置 NSPopover输入框没有焦点焦点设置在 popover 未完成布局时执行延迟 0.1s 再设置 FocusState空条目污染历史剪贴板变化包含非文本内容入库前 trim空内容直接跳过菜单栏图标不可见自定义图标不是 template 模式使用 SF Symbols 或用带 alpha 的 PDF内存持续增长大字符串常驻 存储上限太高降低上限显式 copy 字符串有几个我特别想分享的体会做一个 Menu Bar 小工具的整个过程让我重新理解了“够用”和“好用”之间的隐形成本。功能层面OneClip 第一版的所有逻辑加起来不到两千行 Swift但真正占用我时间的其实是那些没有写在需求文档里的东西键盘交互的完整闭环、字体渲染的观感、焦点切换的时机、隐私感知的默认设置。用户不会看到这些细节但他们用一次就能感知到“这工具是不是原生 Mac 的感受”。如果你也在考虑开发自己的第一个 macOS 应用我的建议是专注一个真正高频的痛点把方案收敛到无法再收敛再把体验打磨到超过市面免费工具的平均水平然后尽快发布。个人开发者的优势不是功能全而是你对某一个场景的理解足够深刻并且愿意为了那几个关键体验节点去死磕。OneClip 目前仍然只做了剪贴板历史这一件事但我会继续把它打磨到真正令人爱不释手的程度。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价