要做什么
做一个剪贴板历史工具:后台自动记录每次复制的内容,历史列表可搜索,点条目把内容回填到剪贴板并收起窗口。三句话讲完,但”剪贴板”这个约束会让每个设计决策都变重——它没有变更通知,内容可能不是文本,甚至可能是刚复制的密码。工具形态定为常驻:点关闭不退出,再双击图标能把窗口唤回来。
验收指标先把丑话说在前面,每条都带测法:
| 指标 | 目标值 | 怎么测 |
|---|---|---|
| 复制到出条目延迟 | 不迟于一个轮询周期(500ms) | 连续复制 10 次不同文本,每次 1 秒内条目出现、无漏记 |
| 去重正确性 | 同内容只留一条且置顶到最新 | 连续复制同一文本 5 次,历史里只有 1 条、时间最新 |
| 容量与截断 | 历史上限 100 条,单条截断 5000 字符 | 高强度复制后数条数;粘一整篇文章进去看是否截断 |
| 关闭与唤回 | 点关闭窗口消失、进程在;再双击 exe 窗口回来 | 按流程手工走两遍 |
| 数据竞争 | go test -race 零报告 |
历史管理逻辑抽成独立包跑 -race,绑定层手工操作验证 |
| 重启恢复 | 杀进程再启动,历史还在 | 记录 3 条后结束进程,重启核对列表 |
整体架构
这个应用的职责分界很清楚:采集是 Go 的活,展示是前端的活,中间靠绑定与事件连起来。
系统剪贴板(OS)
│ 每 500ms 由 Go 后台 goroutine 读取
▼
Go 后端
├─ 轮询器:ticker + ClipboardGetText,异常静默跳过
├─ History:mutex 保护的切片(去重、截断、上限)
└─ 持久化:JSON 文件,退出与每次变更后落盘
│ Bind 暴露 GetHistory/Restore/ClearAll
│ EventsEmit 广播 history:updated
▼
WebView 前端
├─ 历史列表 + 搜索框(只消费 Go 给的数据)
└─ 点条目 → Restore() 回填剪贴板并隐藏窗口
选型上刻意克制:持久化用单个 JSON 文件而不是 SQLite——百条上限、每条几 KB 的数据量,JSON 读写是毫秒级,引入数据库属于给工具箱里的扳手盖一栋仓库。什么时候该换 SQLite:条数上千、需要按内容全文检索、或者开始存结构化的多类型条目。同理,不需要给采集器上 worker 池——剪贴板一次只有一个内容,单 goroutine 轮询是恰好正确的复杂度。事件、绑定这两个机制的原理见《Wails 框架要点》,本文只讲在这个具体场景里怎么用。
难点一:剪贴板没有”变更事件”
这是整个工具的地基问题:操作系统剪贴板不会主动通知你”内容变了”,一切方案都在解决”怎么感知变化”。
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| 监听系统剪贴板消息(如 Windows 的剪贴板监听窗口) | 零延迟、零空转 | 各平台 API 完全不同,要 CGO 与三份平台代码;Wails v2 未内置 | 不采用,成本远超收益 |
| Go 侧定时轮询 | 三个平台同一份代码;窗口隐藏也照常采集 | 有轮询间隔延迟;空转调用开销 | 采用,500ms 延迟对”复制后粘贴”场景无感 |
| 前端 setInterval 调绑定方法轮询 | 代码集中在一处 | WebView 隐藏或失焦时 JS 定时器可能被节流,正是常驻工具的主要工况,会漏记;每次轮询多跨一次 JS→Go 调用 | 反模式,不采用 |
轮询必须放在 Go 侧而不是前端 JS 里,这是本工具最容易被写错的一处。实现如下,注意退出通道——常驻 goroutine 必须能被干净地取消:
// 在 OnStartup 里调用,ctx 存入结构体供运行时 API 使用
func (a *App) startPolling(ctx context.Context) {
a.ctx = ctx
ticker := time.NewTicker(500 * time.Millisecond)
go func() {
defer ticker.Stop()
for {
select {
case <-ctx.Done(): // OnShutdown 后 ctx 取消,goroutine 退出
return
case <-ticker.C:
a.checkClipboard()
}
}
}()
}
反模式点名:把轮询器写成 for { ... time.Sleep(500ms) } 的裸循环且不接 ctx——应用退出后 goroutine 还在,退出行为变得不可预测。另外轮询间隔别压到 50ms 级别求”实时”:每次调用都要进系统剪贴板 API,收益只有几十毫秒延迟的差别,代价是无谓的唤醒次数,500ms 到 1s 是这个场景的合理区间。
难点二:轮询 goroutine 与绑定方法的并发安全
采集 goroutine 每 500ms 写一次历史,前端的 GetHistory、Restore、ClearAll 随时读写同一个切片——不设防就是数据竞争,轻则列表错乱,重则 panic。方案对比:
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| sync.Mutex 保护切片 | 直观,临界区小,改动点集中 | 忘了加锁就出错,靠 -race 兜底 | 采用,状态共享型场景的标准解 |
| 单个 goroutine 独占 + channel 传指令 | 无锁,所有权清晰 | 查询类操作也要走往返,前端调用延迟增加 | 写入极频繁、读取少的场景才划算 |
| sync.RWMutex | 读多写少时并行读 | 本场景读都在毫秒内完成,收益可忽略 | 列表上千条且高频读时再演进 |
百条量级的数据,Mutex 就是终点方案。核心代码:
type Entry struct {
Text string `json:"text"`
CreatedAt int64 `json:"created_at"` // Unix 毫秒,前端负责格式化
}
const (
maxItems = 100
maxRunes = 5000 // 按"字符"截断而非字节,中文不会被腰斩
)
func (a *App) checkClipboard() {
text, err := runtime.ClipboardGetText(a.ctx)
if err != nil || text == "" {
return // 剪贴板里是图片/文件等非文本内容,跳过而不是记空条目
}
runes := []rune(text)
if len(runes) > maxRunes {
text = string(runes[:maxRunes]) // 防超大文本把内存与 UI 一起拖垮
}
a.mu.Lock()
defer a.mu.Unlock()
// 同内容去重:已存在就先移除,等价于"置顶到最新"
for i, e := range a.history {
if e.Text == text {
a.history = append(a.history[:i], a.history[i+1:]...)
break
}
}
a.history = append([]Entry{{Text: text, CreatedAt: time.Now().UnixMilli()}}, a.history...)
if len(a.history) > maxItems {
a.history = a.history[:maxItems]
}
runtime.EventsEmit(a.ctx, "history:updated") // 增量通知,前端拉全量列表
}
两个反模式点名。其一,”先查再插”防重:先遍历判断不存在再 append,两步之间轮询 goroutine 和绑定方法可能交错,重复条目照样出现——去重和写入必须在一个临界区里完成,上面代码正是这么做的。其二,去重比对用原始文本:某些应用复制时会在末尾带一个不可见换行,肉眼相同的内容就变成了两条,比对前先做规范化处理(去首尾空白)能消掉一大类”灵异重复”。
验证手段:go vet ./... 加上把历史管理抽成独立包后 go test -race ./...——用两个 goroutine 并发调用增删查跑几百次,-race 报告干净才算过。并发原语的地基见《Go 并发基础》。
难点三:回贴、落盘与”明文存储”的边界
点条目回贴剪贴板并收起窗口,是这类工具的核心交互,回贴即隐藏在体感上才像”系统级”工具:
func (a *App) Restore(index int) error {
a.mu.Lock()
if index < 0 || index >= len(a.history) {
a.mu.Unlock()
return fmt.Errorf("index out of range: %d", index)
}
text := a.history[index].Text
a.mu.Unlock()
if err := runtime.ClipboardSetText(a.ctx, text); err != nil {
return err
}
runtime.Hide(a.ctx) // 回贴完成即收起,用户的下一步永远是粘贴
return nil
}
持久化选了最朴素的路线:每次变更后把整个历史写成 JSON 文件,存到用户配置目录,启动时读回。百条量级下这个量级的开销可以忽略,换 SQLite 的时机前面已经说清。代码里值钱的是边界意识而不是写文件本身:
func historyPath() (string, error) {
dir, err := os.UserConfigDir() // Windows 是 AppData,macOS 是 ~/Library
if err != nil {
return "", err
}
return filepath.Join(dir, "cliphist", "history.json"), nil
}
必须诚实面对的边界:剪贴板历史等于把你复制过的密码也明文存了一份。缓解手段按投入排序:复制内容超过一定长度才入历史、提供”清除全部”的一键入口(本文实现了)、对来自密码管理器的内容做排除——最后一条没有可靠通用的判断方法,因为多数密码管理器复制后本来就会自动清剪贴板,这是既有生态的现实约束。这是工具与隐私的真实代价,写进 README 比藏在代码里更有价值。
难点四:常驻与唤回——v2 没有系统托盘
“常驻”碰到 v2 的能力边界:没有系统托盘,也没有全局快捷键(托盘是 v3 alpha 才在规划的能力,本文不依赖它)。可用方案对比:
| 方案 | 说明 | 结论 |
|---|---|---|
| HideWindowOnClose + SingleInstanceLock | 点关闭改为隐藏进程常驻;再双击 exe 由单实例锁回调唤回窗口,均为 v2 已发布能力(后者 v2.7.0 起) | 采用,三平台行为一致 |
| 系统托盘 | v2 无此能力 | 不可用,产品要求必须有托盘就别选 v2 |
| 全局快捷键 | v2 无内置,需要第三方平台库 | 不引入,破坏”零 CGO”的简洁性 |
err := wails.Run(&options.App{
Title: "ClipHist",
Width: 520,
Height: 640,
HideWindowOnClose: true, // 点关闭 = 隐藏,进程继续采集
SingleInstanceLock: &options.SingleInstanceLock{
UniqueId: "b1f0d3a2-4c5e-4f6a-8b7c-cliphist-uuid",
OnSecondInstanceLaunch: app.onSecondInstanceLaunch,
},
OnStartup: app.startup,
OnShutdown: func(ctx context.Context) {
app.save() // 退出前落盘
},
Bind: []interface{}{app},
})
// 第二次双击 exe 走到这里:官方文档明确回调不会自动聚焦窗口
func (a *App) onSecondInstanceLaunch(data options.SecondInstanceData) {
runtime.WindowUnminimise(a.ctx)
runtime.Show(a.ctx)
}
反模式点名:自己用锁文件实现单实例——进程崩溃后残留的锁文件会把应用锁在门外,而 SingleInstanceLock 在 Windows/macOS/Linux 上分别用系统命名互斥体与通知机制实现,进程死了锁就释放。轮子就在那里,而且是跨平台一致的。
验证
分阶段定位表,覆盖实现过程中真实会卡住的环节:
| 阶段 | 现象 | 根因在哪 | 怎么改 |
|---|---|---|---|
| 采集 | 复制后条目不出现 | 轮询没启动(OnStartup 前调用)或非文本内容报错被吞 | 先在轮询回调里打一行 LogDebug 确认周期在走,再看 GetText 错误分支 |
| 去重 | 肉眼相同的内容出现两条 | 复制源带了不可见字符(尾部换行、零宽字符) | 比对前规范化:strings.TrimSpace 起步,仍复现再查零宽字符 |
| 关闭 | 点关闭进程直接退了 | HideWindowOnClose 没配或拼错字段 | 对照 options.App 字段名检查配置 |
| 唤回 | 双击 exe 没反应(尤其 Linux) | 回调只传数据不聚焦窗口;Linux 窗口管理器可能拒绝抢焦点 | 确认 WindowUnminimise + Show 已写;Linux 上属平台行为,点任务栏图标 |
| 性能 | 列表滚动卡顿 | 前端一次性渲染全部条目或大文本未截断 | 截断上限兜底后,前端改虚拟滚动或分页 |
完整验收按开头的指标表逐条过:延迟与去重用手工复制序列测,重启恢复杀进程验证,-race 报告作为并发安全的硬门槛——这条过不了,其他都白测。
数字与边界
本篇未做定量实测,以下为定性口径:产物体积十几 MB 量级(与开山篇一致,不含 Chromium 是根本原因);常驻内存几十 MB 量级,大头是系统 WebView 而非 Go 进程;500ms 轮询的空转调用在任务管理器里观察不到可感的 CPU 变化。均以你机器的实际表现为准。
天花板也交代清楚:这个实现不做全文检索(百条量级前缀过滤够用)、不做多格式条目(图片历史需要另一套采集与存储)、不承诺密码安全(明文落盘的边界如实告知)。再往上一档,评估的是 SQLite + FTS 与系统钩子采集,那是另一个量级的工程。
总结
这个项目的四条硬收获:剪贴板历史工具的本质是”把易失状态变成常驻状态”,难点全在采集感知与并发安全,UI 是最薄的一层;Go 侧轮询而不用前端定时器,是被 WebView 节流行为逼出来的正确决策,也再次说明桌面应用里”JS 侧尽量薄”的分量;去重与写入必须在同一临界区,”先查再插”的直觉在并发下必错;v2 没有托盘与全局热键,产品形态要求这两样时要换框架而不是硬凑——Wails 的能力边界在开山篇的取舍表里,补齐它需要等 v3。
API 细节随查随用的字典见《Wails 开发参考字典》;从零起步的完整路径见《Wails 学习路线》。
版本核实(以官方文档为准):写作时 Wails v2 最新稳定版为 2.9.2(2024 年 9 月发布);本文使用的 ClipboardGetText/ClipboardSetText、HideWindowOnClose、WindowSetAlwaysOnTop(v2.6.0 起)、SingleInstanceLock(v2.7.0 起)、EventsEmit、Show/Hide 均为 v2.9.x 已发布行为;v2 无系统托盘与全局快捷键、v3 尚在 alpha,均与官方仓库与文档现状一致。