Cleanzo 0.3.0 开发复盘:从「有点慢、会闪」到原生菜单栏面板
Cleanzo 0.3.0 上周发布了。这次版本的核心是菜单栏读数和详情面板——macOS 菜单栏里显示 CPU、内存等读数,点开是完整的详情面板,里面有图表、诊断、进程列表,CPU 和内存还能直接退出 App。
这篇文章是开发过程的复盘,讲几个真正有意思的决策和问题。
起点:「菜单栏丰富下吧」
这个功能的起点很简单:
菜单栏丰富下吧,点击也有弹窗可以看详情,做成自己的特色。
最开始的设计是把所有读数画进同一个菜单栏项,点哪个区域就打开哪个面板——有点像 Pulse 或者 Stats 那种感觉,但是一个整体项,不能各自拖动。
这个方向做完之后,维护者看了一眼就说:「类似 Pulse 这种,独立就行,也能独立拖拽放位置。」
所以改成了每个读数一个独立的菜单栏项。听起来是个小改动,但其实涉及整套命名、布局持久化和事件处理的重构。
面板的第一个版本:网页
第一版的面板是网页。用 mygo(Cleanzo 底层用的 Go UI 框架)打开一个系统 popover,里面加载一个本地 HTML 页面,页面用 React 写,通过 Go 绑定实时读数据。
这套方案做完能用,测试通过,但维护者在真机上一试就发现了问题:
「为什么打开面会闪一下,而且性能好像一般。」
为什么会闪? 系统 popover 打开之后,里面的网页是异步加载的,所以先看到一个空白或者小尺寸的窗口,等网页加载完、数据到了,窗口才稳定。FitContent 这个选项让窗口高度跟着内容撑开,所以你会看到它从小到大。从 290px 长到 569px,用了 1.8 秒。
闪的本质 不是渲染慢,而是架构问题——webview 是异步的,内容和尺寸都不能在打开瞬间就确定。
维护者又问了一句:
「原生组件可以用到吗?」
从网页到原生:做一个 spike
mygo 有自己的原生 UI 包(github.com/egoist/mygo/ui),用 Metal GPU 渲染,布局逻辑和 SwiftUI/Flutter 类似:声明式,组合式,跑在主线程。
在动手改之前,我们先做了一个 spike——只把 CPU 面板改成原生的,其他面板保持网页,用真机测试,回答三个问题:
- 打开速度有多快?
- 颜色能不能跟着 Cleanzo 主题色走?
- 看起来是不是像那么回事?

注:图里已经是改好颜色之后的版本了。最开始的 spike 用的是系统默认蓝色。
Spike 的结果很明确:
- 原生面板打开是瞬间的。第一次打开 97ms,之后约 20ms。(网页面板第一次约需 2-3 秒。)
- 颜色默认跟系统 accent 色(蓝色),需要手动设进去。
维护者看了 spike 效果之后说了一句:
「菜单栏下拉框的颜色应该跟着 Cleanzo 主应用的主题色吧?」
这一句话定了原生面板的核心设计原则:面板不是系统弹出框的附属品,它和 Cleanzo 主窗口是一个整体,用同一套颜色。
颜色方案:不跟菜单栏,跟应用
这里有个有趣的微妙之处。macOS 菜单栏有自己的浅色/深色判断(比如 Notch 区域是深色,但某些壁纸配置下菜单栏是浅色)。面板的背景色应该跟菜单栏的明暗走,还是跟应用的明暗走?
我们选择的是跟应用走:
func menuBarAppDark() bool {
// 读 NSApp.effectiveAppearance,而不是菜单栏的外观
app := objc.ID(objc.GetClass("NSApplication")).Send(nsSel("sharedApplication"))
// ... 判断是否为 DarkAqua
}原因很简单:Cleanzo 的主窗口是这样做的,面板和主窗口保持一致,比和菜单栏保持一致更重要。
颜色 token 直接从 src/style.css 里搬:
func menuBarPanelTheme(base *ui.Theme, dark bool) *ui.Theme {
t := *base
if dark {
t.Background = ui.Hex("#22252a")
t.Accent = ui.Hex("#30D158") // Cleanzo 深色主题绿
// ...
} else {
t.Background = ui.Hex("#ffffff")
t.Accent = ui.Hex("#1FA355") // Cleanzo 浅色主题绿
// ...
}
return &t
}浅色和深色两套,Cleanzo 的品牌绿,卡片白/暗,灰色辅助文字。


面板高度:固定还是自适应?
原生面板遇到了一个 mygo 的限制:ui 包里的 flyout(下拉面板)不支持 FitContent。打开之前需要指定高度。
我们的解法是测量驱动的固定高度:写一个测试,往面板里塞最长的真实数据(中英文、最长诊断文字、满 5 行进程列表),用 ui.Render 离线渲染,测量布局后的实际高度,加一点余量,写进高度表。
// menuBarPanelHeight returns the fixed height for item's panel in DIPs.
// Heights were measured with worst-case content in both zh and en using
// ui.Render at 320 DIPs wide; each fits with about 4 DIPs to spare.
func menuBarPanelHeight(item string) float32 {
switch item {
case "cpu": return 507
case "mem": return 597
case "disk": return 650
// ...
}
}测试会 fail 如果任何一种语言、任何数据状态超过了表里的高度。这样改了面板内容之后,不会悄悄破坏高度。
缺少数据的行不显示,但不会让面板收缩——页面正文在一个滚动区域里,页脚固定在底部。少量数据只是在中间留一些空白,看起来也还好,关键是不会跳。
诊断行:不能截断
第一版的原生面板把诊断文字限制在两行(MaxLines(2)),理由是省空间。代码审查时被发现了问题:
内存压力的诊断文字是这样的:「内存紧张,占得最多的是 Trae(8 GB)。交换文件占了 4 GB 磁盘。」如果截断到两行,英文版就会丢掉后半句。那正好是最紧张的状态里最需要看到的信息。
修复很简单:去掉 MaxLines,把页面正文放进 ui.Scroll,页脚钉在底部。每个面板的高度已经够长,正常数据不需要滚动;特别长的诊断才会滑一点点。
多显示器的坑
Cleanzo 发布之后,维护者试了一下外接屏:
「我有外接屏幕,在外接屏幕点击的时候,下拉会显示在主屏幕上。」
这是 macOS 26 里一个比较隐蔽的问题。
问题的根源: NSStatusItem(菜单栏项)在每台显示器上都有一份「副本」——你在主屏上看到它,也在外接屏上看到它,但它实际上只有一个底层窗口,这个窗口停在某台显示器上。mygo 用 Tray.Bounds() 来定位面板,而 Bounds() 返回的是这个底层窗口的 frame,而不是你实际点击的那份副本的位置。
所以你在外接屏点,面板出现在主屏——因为底层窗口在主屏。
修法: 在点击发生的那一刻,记录鼠标的屏幕坐标(NSEvent.mouseLocation,这是全局坐标,不依赖哪个窗口),减去点击在 item 窗口内的偏移(NSEvent.locationInWindow),就得到了那份副本的实际位置。用这个位置替换 Bounds() 的返回值,面板就会锚定在你点的那台屏幕上。
这个修复已经提了 mygo PR(#942)。等合并之后,Cleanzo 会切换到不带 Popover: true 的普通 flyout,就能彻底修好外接屏问题。
性能:PNG 解码的隐藏成本
面板做好了之后,测了一下菜单栏读数的空闲 CPU。两个读数(CPU + 内存)在主窗口关闭的情况下:约 0.9%。
规格里写的上限是 0.3%(两个读数)/ 0.6%(全开)。有差距。
用 Go pprof 采样 60 秒,看到的是这样的调用栈:
main.(*menuBarController).paint
main.(*menuBarController).width ← readoutWidth: CoreText 测宽
main.drawReadout ← drawReadout: NSBitmapImageRep 绘图
main.bitmapPNG ← bitmapPNG: PNG encode
github.com/egoist/mygo.(*Tray).SetIcon ← SetIcon: PNG decode每次读数更新(每 2 秒一次),每个读数都经历一遍:测量文字宽度(CoreText)→ 绘制 NSBitmapImageRep → 编码成 PNG → mygo SetIcon 接收 PNG 再解码。
问题是:大多数时候读数根本没变。CPU 的数字可能已经稳定在 34% 很多轮了,但每次都还是走一遍完整的绘制链路。
解法很直接:给每个读数缓存上次渲染的「key」(文字内容 + 宽度 + 颜色状态),如果这次和上次完全一样,直接跳过 drawReadout 和 SetIcon:
type menuBarPaintKey struct {
text menuBarText
width float64
ink readoutInk
}
func (m *menuBarController) paint(item string, tray *mygo.Tray, t menuBarText) {
// ...
key := menuBarPaintKey{text: t, width: w, ink: ink}
if m.paintKeys[item] == key {
return // 和上次一样,什么都不用做
}
// 只有真的变了才走下面的绘制路径
// ...
}加了这个缓存之后,空闲 CPU 降到了 0.59–0.91%。大部分时候在 0.7% 以下。
剩下的成本几乎都是 AppKit 主事件循环的固定开销(通过 mygo.RunOnMain 每 2 秒唤醒一次主线程),不是 Cleanzo 这边能控制的。
用 AI 写代码的真实体感
这次开发用了 Codex来做大部分的实现工作。流程大概是这样:
- 我(或者 Claude)写一个「brief」——几百行的详细实现说明,包括具体的函数签名、算法逻辑、要跑的测试命令。
- Codex 按照 brief 实现,跑测试,写报告。
- 我(或者 Claude 的审查代理)读代码和报告,找问题,决策怎么改。
- 重复。
这套流程有几个我觉得关键的细节:
Brief 越具体越好。 如果 brief 只是说「写一个面板」,代理会做出一个可以编译的东西,但里面的细节决策(高度怎么定、颜色从哪来、哪些边界情况要处理)就都是代理自己猜的。猜的结果需要多轮修正。如果 brief 里直接写明「高度用 ui.Render 离线测量,测量方法是这样的」,代理就能按步骤来,第一轮通过率高很多。
测试要写对,不然是在骗自己。 Codex 实现完发现一个问题:3 秒退出确认的超时测试用了子串匹配(HasText("退出")),而「确认退出」也包含「退出」,所以超时没有触发、确认没有撤销的情况下测试也通过了。代码审查发现了这个问题,重写了测试——用精确匹配,手动触发计时器回调。
Reviewer 要真的读代码。 我们用了一个 Claude opus 实例作为代码审查者,它会实际跑测试、构造失败输入、检查边界情况。这比「让代理自我审查」靠谱很多——同一个代理很难发现自己的系统性盲点。
一些数字
| 指标 | 值 |
|---|---|
| 面板打开速度(第一次,冷启动) | ~97 ms |
| 面板打开速度(后续打开) | ~20 ms |
| 网页面板打开到稳定 | ~1.8 s(从 290px 长到 569px) |
| 空闲 CPU(两个读数,主窗口关闭,优化后) | 0.59–0.91% |
| 发布的面板数量 | 7 个(CPU、内存、GPU、网络、磁盘、温度、电池) |
| Go 实现代码行数(纯规则文件) | ~1,000 行 |
| 原生视图代码行数 | ~870 行 |
| 对应测试代码 | ~1,200 行 |
还有什么没做完
- 外接屏面板定位:等 mygo PR #942 合并,切换到 plain flyout 之后修复。
- 空闲 CPU:目前 0.7% 左右,规格上限是 0.3%。主要是 AppKit 主循环的固定成本,短期内不好继续优化。如果后续有更多读数开着会接近上限,到时候再看。
- 面板里的「退出 App」:两下退出(先「退出」再「确认退出」),流程和进程页一样。但没有「按名字定位到进程页里这一行」的功能——点名字只是打开进程页,不会滚到对应行。计划后续版本做。
写在最后
这次开发过程里,让我印象最深的是几个「用了才知道」的问题——面板闪、外接屏定位、PNG 解码的成本。这些都不是设计阶段能预见到的,都是拿到真机、实际使用之后才暴露的。
AI 帮我省掉的是写样板代码的时间,但发现这些问题、决定怎么解——这部分还是得人来。
0.3.0 已经发了,可以去 cleanzo.echovic.com 下载试试。
图片来源:本文所有截图均为真机截图或离屏渲染,数据为作者测试机器的真实数据。