1. 项目缘起:从付费订阅到自主创造的必然之路
不知道你有没有和我一样的经历:电脑上总离不开一个趁手的剪贴板历史工具。无论是写代码时来回复制粘贴命令,还是写文档时整理多个来源的素材,一个能保存多条记录、支持快速搜索粘贴的工具,能极大提升效率。我之前一直用着一款口碑不错的第三方工具,它确实好用,但每年98元的订阅费,像一根小刺,时不时扎你一下。倒不是付不起,而是心里总有个疙瘩——一个本质上记录文本、提供历史查询的功能,真的需要持续付费吗?尤其是当你对它的核心实现原理有所了解后,这种“被收税”的感觉会更强烈。
于是,一个念头冒了出来:为什么不自己做一个?正好,我想深入实践一下最近开发者圈里讨论挺多的“Vibe Coding”模式。所谓Vibe Coding,我的理解是一种更注重直觉、心流和快速验证的编程状态,它不追求一开始就设计出完美的架构,而是快速搭建一个可运行的原型,在“运行-反馈-调整”的循环中逐步完善。这正适合一个功能相对明确、但细节需要打磨的个人工具项目。我的技术栈选择很明确:在macOS上,用SwiftUI来构建现代、声明式的用户界面,底层则借助成熟的AppKit框架来访问系统剪贴板这样的核心能力。为了让分发和安装更极客范儿,我还决定为它制作一个Homebrew Cask,这样自己和朋友都能通过一行命令brew install --cask my-clipboard-tool来安装。
这个项目,就是一个典型的“需求驱动学习+实践”的过程。目标很清晰:打造一个免费、轻量、无订阅、完全受自己控制的剪贴板增强工具。下面,我就把从零开始构思、编码、调试到打包分发的完整过程,以及其中踩过的坑和收获的经验,详细拆解一遍。
2. 核心设计:在简约与强大之间寻找平衡点
自己动手的第一个挑战,不是写代码,而是做设计。我需要明确,这个工具的核心价值是什么,以及它和那些成熟商业产品的区别在哪里。我不打算做一个全功能的“瑞士军刀”,而是聚焦于解决我最痛的那个点:高效检索和使用剪贴板历史。
2.1 功能边界划定:要什么,不要什么
经过思考,我确定了核心功能的“最小可行产品(MVP)”清单:
- 历史记录:自动保存所有复制到系统剪贴板的内容(文本为主)。
- 快速呼出:通过全局快捷键(如
Cmd+Shift+V)随时调出历史记录面板。 - 搜索与过滤:在面板中能通过键盘输入即时过滤历史记录。
- 快速粘贴:选中某条历史后,按回车键直接粘贴到当前焦点应用。
- 置顶/收藏:可以将常用片段置顶,避免被后续记录淹没。
- 纯本地存储:所有数据保存在本地,不上传任何信息,保障隐私。
同时,我也明确划掉了初期不做的“豪华”功能:
- 跨设备同步:这涉及复杂的账户体系和网络服务,背离了“简单本地工具”的初衷。
- 富媒体支持(图片、文件):初期只处理纯文本和RTF(富文本),简化数据模型。
- 复杂的编辑和组织功能:不内置强大的编辑器,专注于“复制-检索-粘贴”的核心流。
这个划界非常重要,它防止了项目无限膨胀,确保我能快速看到成果,进入Vibe Coding所倡导的积极反馈循环。
2.2 技术选型与架构草图
基于MVP,技术选型几乎是水到渠成的:
- UI框架:SwiftUI。对于这样一个需要精美UI和流畅交互的桌面应用,SwiftUI的声明式语法和实时预览功能是生产力利器。它能让我快速调整界面布局,并立即看到效果,这与Vibe Coding的理念完美契合。
- 底层能力:AppKit。虽然SwiftUI正在逐步完善,但要访问像
NSPasteboard(系统剪贴板)这样的底层系统API,目前仍需依靠AppKit。我需要使用NSPasteboard.general来监听和读取剪贴板变化。 - 数据持久化:Core Data 或 SQLite.swift。我需要一个本地数据库来存储可能成千上万条的历史记录,并支持高效的查询(尤其是搜索)。Core Data与SwiftUI集成度更高(有
@FetchRequest),但学习曲线稍陡。SQLite.swift更轻量、直观。我最终选择了SQLite.swift,因为它更符合我对这个项目的“完全掌控”感,且SQL查询对于搜索功能来说非常直接。 - 全局快捷键:Carbon API 或第三方库。注册系统级全局快捷键是必须的。虽然古老的Carbon API(
RegisterEventHotKey)仍可用,但我在社区发现了一个更Swifty的封装库HotKey,它用起来更简单直观,决定采用。 - 打包与分发:Homebrew Cask。这是macOS上一种优雅的软件分发方式。我需要将编译好的
.app捆绑包、必要的资源文件以及安装脚本,打包成一个标准的Cask Formula,让用户可以通过Homebrew一键安装和更新。
整个应用的架构雏形在我脑中形成:一个常驻后台的菜单栏应用(Status Bar App)。它默默监听剪贴板变化,将文本存入本地SQLite数据库。当用户按下全局快捷键时,主窗口弹出,从数据库加载并展示历史记录,同时接受键盘输入进行实时过滤。
3. 实操构建:从零到一的“Vibe Coding”之旅
有了设计图,接下来就是动手建造。我刻意不去追求完美的代码结构,而是遵循“让东西先跑起来”的原则。
3.1 项目初始化与基础框架搭建
首先,在Xcode中创建一个新的macOS项目,模板选择App,界面选择SwiftUI,生命周期选择SwiftUI App。这里不选Document App,因为我们不是文档型应用。
创建完成后,我首先改造它成为一个菜单栏应用。在主要的@mainApp结构体中,我移除了默认的WindowGroup,转而使用MenuBarExtra。
import SwiftUI @main struct MyClipboardApp: App { // 状态管理:控制主窗口是否显示 @State private var isMainWindowPresented = false var body: some Scene { // 1. 菜单栏图标和菜单 MenuBarExtra("ClipMaster", systemImage: "clipboard") { Button("显示历史") { isMainWindowPresented.toggle() } .keyboardShortcut("v", modifiers: [.command, .shift]) // 关联快捷键 Divider() Button("退出") { NSApplication.shared.terminate(nil) } } // 2. 主内容窗口 Window("历史记录", id: "main-window", isPresented: $isMainWindowPresented) { ContentView() // 这是我们的主界面 } .windowResizability(.contentSize) // 窗口大小由内容决定 .defaultPosition(.center) // 默认出现在屏幕中央 } }这段代码做了几件事:在菜单栏添加了一个剪贴板图标,点击图标有“显示历史”和“退出”两个选项。同时,它定义了一个与isMainWindowPresented状态绑定的窗口,用于显示主界面ContentView。keyboardShortcut修饰符为“显示历史”按钮添加了键盘快捷键提示,但注意,这还不是真正的全局快捷键,它只在菜单弹出时生效。真正的全局快捷键注册需要额外步骤。
实操心得一:
MenuBarExtra的注意事项在macOS 13 (Ventura) 及以后,MenuBarExtra是官方推荐的方式。但在之前的系统,你可能需要使用NSStatusBar。为了兼容性,我在这里直接使用了新的API,并假设用户系统较新。另外,MenuBarExtra的标题("ClipMaster")在菜单栏上可能不显示,只显示图标,这是正常行为。
3.2 实现剪贴板监听与数据层
这是应用的核心引擎。我需要一个服务,在后台持续运行,每当系统剪贴板内容变化时,就将新内容保存下来。
首先,我通过Swift Package Manager引入了SQLite.swift库,并创建了一个简单的数据模型和管理器。
// ClipboardItem.swift import Foundation import SQLite struct ClipboardItem: Identifiable { let id: Int64? var content: String var timestamp: Date var isPinned: Bool } // ClipboardStore.swift import SQLite class ClipboardStore { static let shared = ClipboardStore() // 单例 private var db: Connection? private let items = Table("clipboard_items") private let id = Expression<Int64>("id") private let content = Expression<String>("content") private let timestamp = Expression<Date>("timestamp") private let isPinned = Expression<Bool>("is_pinned") private init() { do { // 数据库文件存放在应用支持目录 let path = NSSearchPathForDirectoriesInDomains( .applicationSupportDirectory, .userDomainMask, true ).first! + "/ClipMaster" try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) db = try Connection("\(path)/db.sqlite3") // 创建表 try db?.run(items.create(ifNotExists: true) { t in t.column(id, primaryKey: .autoincrement) t.column(content) t.column(timestamp) t.column(isPinned, defaultValue: false) }) print("数据库初始化成功") } catch { print("数据库初始化失败: \(error)") } } // 插入新记录 func insert(item: ClipboardItem) -> Int64? { guard let db = db else { return nil } do { let insert = items.insert( content <- item.content, timestamp <- item.timestamp, isPinned <- item.isPinned ) let rowid = try db.run(insert) return rowid } catch { print("插入失败: \(error)") return nil } } // 查询所有记录(按置顶和时间倒序) func fetchAll() -> [ClipboardItem] { guard let db = db else { return [] } do { let query = items.order(isPinned.desc, timestamp.desc) return try db.prepare(query).map { row in ClipboardItem( id: row[id], content: row[content], timestamp: row[timestamp], isPinned: row[isPinned] ) } } catch { print("查询失败: \(error)") return [] } } // 切换置顶状态 func togglePin(for id: Int64) { guard let db = db else { return } do { let item = items.filter(self.id == id) if let currentPin = try db.pluck(item.select(isPinned))?.get(isPinned) { try db.run(item.update(isPinned <- !currentPin)) } } catch { print("更新置顶状态失败: \(error)") } } }接着,创建剪贴板监听服务。这里需要用到AppKit的NSPasteboard和Timer来轮询检查(因为macOS没有直接的剪贴板变化回调API)。
// ClipboardMonitor.swift import AppKit import Combine class ClipboardMonitor: ObservableObject { @Published var latestContent: String = "" private var lastChangeCount: Int = NSPasteboard.general.changeCount private var timer: Timer? private let store = ClipboardStore.shared init() { startMonitoring() } func startMonitoring() { timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in self?.checkPasteboard() } // 立即检查一次 checkPasteboard() } func stopMonitoring() { timer?.invalidate() timer = nil } private func checkPasteboard() { let pasteboard = NSPasteboard.general guard pasteboard.changeCount != lastChangeCount else { return } lastChangeCount = pasteboard.changeCount // 优先读取纯文本 if let string = pasteboard.string(forType: .string) { // 简单的去重:不和最近一条记录内容完全一样才保存 if string != latestContent && !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { latestContent = string let newItem = ClipboardItem(id: nil, content: string, timestamp: Date(), isPinned: false) _ = store.insert(item: newItem) // 可以在这里发送一个通知,让UI更新(如果需要实时刷新列表) } } } }实操心得二:剪贴板监听的“坑”与优化
- 轮询间隔:
0.5秒是一个平衡点。太短(如0.1秒)会频繁唤醒CPU,增加能耗;太长(如2秒)则体验不跟手。对于个人工具,0.5秒是可以接受的。- 内容去重:直接比较
latestContent是最简单的去重,防止连续复制相同内容产生多条重复记录。但更健壮的做法是计算内容的哈希值(如MD5)进行比较,并考虑时间阈值(例如10秒内的相同内容不重复记录)。- 类型过滤:这里只处理了纯文本(
.string)。实际应用中,你可能还想处理RTF(富文本)甚至URL。读取时可以使用pasteboard.pasteboardItems来获取所有类型,并制定一个优先级策略。- 性能:每次轮询都进行数据库写入,如果复制频率极高,可能会有性能问题。可以考虑引入一个缓冲队列,或者批量写入的机制。
3.3 构建主界面与实现全局快捷键
主界面ContentView需要展示历史记录列表,并支持搜索和粘贴。
// ContentView.swift import SwiftUI import HotKey // 引入第三方库 struct ContentView: View { @StateObject private var monitor = ClipboardMonitor() @State private var clipboardItems: [ClipboardItem] = [] @State private var searchText = "" @State private var selectedItemId: Int64? private let store = ClipboardStore.shared // 全局快捷键 private var hotKey: HotKey? = { // 注册 Cmd+Shift+V 为全局快捷键 let key = HotKey(key: .v, modifiers: [.command, .shift]) key.keyDownHandler = { // 快捷键按下时,切换主窗口显示状态 // 这里需要通过AppDelegate或通知来操作,因为View里不能直接控制Window NotificationCenter.default.post(name: .toggleWindow, object: nil) } return key }() var filteredItems: [ClipboardItem] { if searchText.isEmpty { return clipboardItems } else { return clipboardItems.filter { $0.content.localizedCaseInsensitiveContains(searchText) } } } var body: some View { VStack(spacing: 0) { // 搜索框 TextField("搜索历史记录...", text: $searchText) .textFieldStyle(RoundedBorderTextFieldStyle()) .padding() // 列表 List(filteredItems, selection: $selectedItemId) { item in HStack { VStack(alignment: .leading, spacing: 4) { Text(item.content) .lineLimit(2) .font(.body) Text(item.timestamp, style: .time) .font(.caption) .foregroundColor(.secondary) } Spacer() Button(action: { store.togglePin(for: item.id!) refreshItems() }) { Image(systemName: item.isPinned ? "pin.fill" : "pin") .foregroundColor(item.isPinned ? .accentColor : .secondary) } .buttonStyle(.borderless) } .padding(.vertical, 4) .contentShape(Rectangle()) // 让整个HStack区域都可点击 .onTapGesture(count: 2) { // 双击粘贴 pasteItem(item) } } .listStyle(.plain) .onAppear { refreshItems() } } .frame(width: 400, height: 500) .onReceive(NotificationCenter.default.publisher(for: .toggleWindow)) { _ in // 收到快捷键通知时,将焦点放到搜索框 // 注意:这里需要借助`NSApp`和`firstResponder`,实现略复杂,下文详述 bringWindowToFrontAndFocusSearch() } } func refreshItems() { clipboardItems = store.fetchAll() } func pasteItem(_ item: ClipboardItem) { let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(item.content, forType: .string) // 模拟Cmd+V粘贴 DispatchQueue.main.async { let source = CGEventSource(stateID: .combinedSessionState) let keyVDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true) // 0x09是V键 let keyVUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false) keyVDown?.flags = .maskCommand // 添加Cmd修饰键 keyVUp?.flags = .maskCommand keyVDown?.post(tap: .cghidEventTap) keyVUp?.post(tap: .cghidEventTap) // 粘贴后关闭窗口 NSApp.keyWindow?.close() } } func bringWindowToFrontAndFocusSearch() { // 这部分需要与AppDelegate配合,或使用NSWindow的扩展 // 是一个常见的难点,下面会专门讲 } } // 自定义通知名称 extension Notification.Name { static let toggleWindow = Notification.Name("toggleWindow") }实操心得三:全局快捷键与窗口管理的“硬骨头”上面代码中
hotKey的keyDownHandler和bringWindowToFrontAndFocusSearch函数是联动的难点。问题在于:HotKey的回调发生在后台,而SwiftUI的View无法直接控制NSWindow的显示/隐藏和焦点。解决方案:
- 使用AppDelegate:即使使用SwiftUI App生命周期,你仍然可以设置一个AppDelegate。在AppDelegate中持有
hotKey实例,并在其回调中直接操作NSApplication的窗口。- 使用NSWindowController:创建一个自定义的
NSWindowController来管理主窗口,在快捷键回调中通过它来显示/隐藏窗口并让搜索框成为第一响应者(makeFirstResponder)。- 简化方案(我采用的):由于我的应用结构简单(只有一个主窗口),我选择在
ContentView的onAppear中获取窗口引用并保存起来。// 在ContentView中 @State private var mainWindow: NSWindow? .background(WindowAccessor(window: $mainWindow)) // WindowAccessor是一个SwiftUI View,用于获取背后的NSWindow struct WindowAccessor: NSViewRepresentable { @Binding var window: NSWindow? func makeNSView(context: Context) -> NSView { let view = NSView() DispatchQueue.main.async { self.window = view.window } return view } func updateNSView(_ nsView: NSView, context: Context) {} } // 然后在bringWindowToFrontAndFocusSearch中 func bringWindowToFrontAndFocusSearch() { mainWindow?.makeKeyAndOrderFront(nil) // 显示并前置窗口 NSApp.activate(ignoringOtherApps: true) // 激活应用 // 将焦点设置到搜索框需要更多步骤,通常需要给搜索框一个FocusState,然后设置 // 这里涉及到SwiftUI的焦点API,是另一个话题。 }实际上,为了更好的体验,我最终将全局快捷键的处理和窗口管理都移到了一个独立的
AppCoordinator类中,它作为应用的中枢,协调ClipboardMonitor、HotKey和窗口状态。这超出了最初“快速原型”的范围,但却是项目走向可用的必要重构——这正是Vibe Coding的演进过程:先实现功能,再优化架构。
3.4 打包与Homebrew Cask分发
当应用功能基本完成后,就该考虑如何分享了。Homebrew Cask是macOS上分发GUI应用的一种优雅方式。
第一步:打包应用在Xcode中,选择Product->Archive。归档成功后,在Organizer窗口中,点击Distribute App,选择Copy App,这将生成一个干净的.app捆绑包。我将其命名为ClipMaster.app。
第二步:创建Cask FormulaHomebrew Cask的本质是一个Ruby脚本,描述了如何下载和安装你的应用。我需要为我的应用创建一个Cask文件。
- 在本地创建一个仓库(或使用GitHub/GitLab)。
- 在仓库中创建
Casks目录,然后创建文件clipmaster.rb。
cask "clipmaster" do version "1.0.0" sha256 "你的app压缩包的SHA256校验和" url "https://github.com/你的用户名/你的仓库名/releases/download/v#{version}/ClipMaster-#{version}.dmg" name "ClipMaster" desc "A lightweight, subscription-free clipboard history manager for macOS" homepage "https://github.com/你的用户名/你的仓库名" livecheck do url :homepage regex(/v?(\d+(?:\.\d+)+)/i) end depends_on macos: ">= :ventura" app "ClipMaster.app" zap trash: [ "~/Library/Application Support/ClipMaster", "~/Library/Preferences/com.yourdomain.ClipMaster.plist", ] end- 关键点说明:
version: 应用版本号。sha256: 这是url指向的安装包(通常是.dmg或.zip)的校验和。可以通过命令shasum -a 256 /path/to/ClipMaster-1.0.0.dmg计算。url: 指向应用安装包的直链。通常放在GitHub Releases上。zap trash: 定义了卸载应用时需要清理的残留文件路径,包括我们之前创建的~/Library/Application Support/ClipMaster数据库目录。
第三步:发布与测试
- 将
ClipMaster.app打包成.dmg镜像文件(可以使用create-dmg工具)。 - 在GitHub上创建版本发布(Release),上传
.dmg文件。 - 将包含Cask文件的仓库推送到GitHub。
- 本地测试:你可以通过
brew install --cask /本地路径/clipmaster.rb来安装测试。 - 提交到官方仓库(可选):如果你的工具足够通用,可以向Homebrew Cask官方仓库提交Pull Request。但对于个人小工具,更简单的方式是让用户通过
tap(第三方仓库)安装:brew tap yourusername/homebrew-yourrepo然后brew install clipmaster。
实操心得四:Homebrew Cask打包的细节
- 版本管理:每次更新应用,都需要更新Cask文件中的
version和sha256,并打新的GitHub Release。- 签名与公证:为了在macOS上不被安全拦截,最好对应用进行开发者签名(需要Apple开发者账号)并进行公证。未签名的应用用户需要手动在“安全性与隐私”中允许运行。
.dmgvs.zip:.dmg是更标准的macOS应用分发格式,可以包含背景图和应用程序快捷方式。.zip更简单。Cask两者都支持。livecheck:这个块用于让Homebrew自动检测新版本。它解析homepage的HTML,寻找版本号。如果发布流程规范,这能实现自动更新。
4. 避坑指南与进阶思考
在开发过程中,我遇到了不少预料之外的问题。这里记录下最典型的几个及其解决方案,希望能帮你节省时间。
4.1 常见问题排查速查表
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 剪贴板监听不生效,复制后无记录 | 1. 应用没有“辅助功能”权限。 2. 轮询Timer被暂停或未启动。 3. 数据库写入失败。 | 1.检查权限:前往系统设置 > 隐私与安全性 > 辅助功能,确保你的应用已被勾选。这是macOS沙盒安全机制的要求。2.调试Timer:在 checkPasteboard函数开始处添加print(“Timer fired”),看是否定期执行。3.检查数据库:查看初始化数据库的路径,确认文件是否成功创建。尝试在 insert函数后打印rowid。 |
| 全局快捷键与其他应用冲突或无响应 | 1. 快捷键已被系统或其他应用占用。 2. HotKey库注册失败。3. 回调函数中的窗口操作未在主线程执行。 | 1.更换快捷键:尝试使用不常用的组合,如Cmd+Ctrl+Shift+V。2.验证注册:在注册 hotKey后,打印其isPaused属性,应为false。3.确保主线程:在 keyDownHandler中涉及UI操作的代码,务必用DispatchQueue.main.async包裹。 |
| 应用无法前台弹出或搜索框无法聚焦 | 1. 应用不是活动应用(Active Application)。 2. SwiftUI焦点API使用不当。 3. 窗口层级问题。 | 1.激活应用:在显示窗口前调用NSApp.activate(ignoringOtherApps: true)。2.使用FocusState:为搜索框定义 @FocusState var isSearchFocused: Bool,并在窗口显示后设置为true。注意时机,可能需要稍作延迟(DispatchQueue.main.asyncAfter)。3.窗口级别:尝试设置 mainWindow?.level = .floating,但注意这可能让窗口始终在最前。 |
打包成.app后,数据库路径错误 | 沙盒(Sandbox)或应用路径问题。 | 在非沙盒应用中,使用NSSearchPathForDirectoriesInDomains获取的路径是可靠的。如果启用沙盒,则需要使用App Group或迁移到~/Library/Containers/<你的BundleID>/Data/Library/Application Support/下。建议个人工具初期关闭沙盒(在Xcode项目设置的Signing & Capabilities中移除App Sandbox)。 |
| Homebrew安装时报校验和错误 | Cask文件中的sha256值与实际下载文件的校验和不匹配。 | 重新计算.dmg或.zip文件的SHA256:shasum -a 256 /path/to/yourfile.dmg,并更新Cask文件。确保计算的是最终用户下载的文件,而不是你本地未压缩的.app。 |
4.2 性能与体验优化点
当基础功能跑通后,可以考虑以下优化,让工具更“好用”:
- 历史记录去重算法升级:如前所述,实现基于内容哈希和时间的智能去重,避免保存完全相同的连续复制,但保留间隔较久的相同内容(可能是有意为之)。
- 列表性能:当历史记录超过几千条时,一次性加载所有数据到内存可能卡顿。可以改用
LazyVStack或分页加载,结合搜索功能,大部分时间只展示过滤后的少量数据。 - 内存管理:
ClipboardMonitor使用Timer会产生循环引用,务必使用[weak self]。ClipboardStore使用单例是合适的,但要注意数据库连接的关闭(可在deinit中处理)。 - 用户体验细节:
- 粘贴后自动隐藏:这是关键体验。我实现了双击列表项粘贴并关闭窗口。也可以考虑增加一个延迟粘贴(按回车后延迟100毫秒再模拟按键),确保目标应用已准备好接收。
- 内容预览:对于长文本,在列表项中只显示前两行,鼠标悬停或选中时在工具提示(Tooltip)中显示全文。
- 清空历史:增加一个菜单项或快捷键,用于清空所有非置顶记录。
- 可配置性:提供一个简单的设置窗口(
Settings或Preferences),让用户可以修改全局快捷键、历史记录保存条数上限、是否开机启动等。
4.3 关于“Vibe Coding”的实践反思
回顾这个项目,它确实是一次典型的Vibe Coding实践。我没有先画完整的UML图,也没有设计完美的数据库迁移方案。而是从一个最核心的循环开始:“复制文本 -> 检测到 -> 保存 -> 能显示出来”。一旦这个循环跑通,成就感就来了,然后基于这个可运行的原型,一步步添加搜索、置顶、快捷键、打包等功能。
这种方式的优点是反馈及时,动力足。你总能看到一个“活”的东西在变得越来越好。但缺点也很明显:代码可能会变得混乱,尤其是在SwiftUI中,状态管理容易散落在各个View里。我的经验是,在实现2-3个核心功能后,就应该有意识地进行一次小重构,比如将ClipboardMonitor和HotKey管理抽离到一个AppState或Coordinator中,用ObservableObject统一管理,再通过@EnvironmentObject注入到各个View。这步重构虽然打断了“ vibe ”,但对于项目的长期可维护性至关重要。
最终,我得到了一个完全符合自己使用习惯、没有任何多余功能、且完全免费的剪贴板工具。它可能没有那些商业软件功能全面,但每一行代码我都了解,每一个交互细节都按我的喜好定制。更重要的是,这个过程本身带来的学习收获和掌控感,远超过98元订阅费的价值。如果你也有一个让你“如鲠在喉”的付费小工具,不妨试试用Vibe Coding的方式,自己动手做一个。