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 面板改成原生的,其他面板保持网页,用真机测试,回答三个问题:

  1. 打开速度有多快?
  2. 颜色能不能跟着 Cleanzo 主题色走?
  3. 看起来是不是像那么回事?
Spike 时期的 CPU 面板,还是系统蓝

注:图里已经是改好颜色之后的版本了。最开始的 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来做大部分的实现工作。流程大概是这样:

  1. 我(或者 Claude)写一个「brief」——几百行的详细实现说明,包括具体的函数签名、算法逻辑、要跑的测试命令。
  2. Codex 按照 brief 实现,跑测试,写报告。
  3. 我(或者 Claude 的审查代理)读代码和报告,找问题,决策怎么改。
  4. 重复。

这套流程有几个我觉得关键的细节:

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 行

还有什么没做完

  1. 外接屏面板定位:等 mygo PR #942 合并,切换到 plain flyout 之后修复。
  2. 空闲 CPU:目前 0.7% 左右,规格上限是 0.3%。主要是 AppKit 主循环的固定成本,短期内不好继续优化。如果后续有更多读数开着会接近上限,到时候再看。
  3. 面板里的「退出 App」:两下退出(先「退出」再「确认退出」),流程和进程页一样。但没有「按名字定位到进程页里这一行」的功能——点名字只是打开进程页,不会滚到对应行。计划后续版本做。

写在最后

这次开发过程里,让我印象最深的是几个「用了才知道」的问题——面板闪、外接屏定位、PNG 解码的成本。这些都不是设计阶段能预见到的,都是拿到真机、实际使用之后才暴露的。

AI 帮我省掉的是写样板代码的时间,但发现这些问题、决定怎么解——这部分还是得人来。

0.3.0 已经发了,可以去 cleanzo.echovic.com 下载试试。


图片来源:本文所有截图均为真机截图或离屏渲染,数据为作者测试机器的真实数据。

参与讨论