我最近一直在琢磨一个不用打开浏览器就能看 ETH 价格的办法。需求听起来特别简单,但真正放到 macOS 顶部那块“常驻菜单栏”时,我先后试了脚本方案、试了几个现成封装,最后干脆用 Golang 直接调 AppKit,写了一个核心代码只有 100 行左右的小工具。这篇文章不是讨论行情本身,而是把从“想法”到“跑在菜单栏上”的完整路径拆给你看,包括为什么选 Golang、为什么没有走更省事的路线、以及那几个不踩一次就不会注意的坑。如果你也需要一个类似的菜单栏常驻小工具,顺着这条思路往下走,应该能比较快地跑起来。
1. 为什么这个场景适合用 Golang,而不是脚本或 Electron
1.1 需求本身很简单,但我为什么没有走捷径
先把这个工具的实际需求说清楚:一个常驻进程,每隔一段时间拉一次 ETH 价格,刷新到菜单栏标题上,点击菜单可以退出,仅此而已。不需要图表,不需要K线,不需要复杂的 UI 交互。
按理说,这样的需求用 Python 脚本或者 AppleScript 都可以做,我一开始也确实往“最容易实现”的方向想。但稍微列了一下要求,就发现其实是给“个人长期使用的小工具”定的边界:
- 编译后最好是一个独立的二进制文件,拷到任何一台 Mac 上都能运行,不依赖解释器和一堆第三方依赖。
- 常驻时的内存占用和 CPU 占用尽量低,不能为了显示一个价格占用几百 MB 内存。
- 网络请求、JSON 解析这些基础能力最好由标准库直接提供,不要为了一个小功能引入大框架。
这几点 Golang 都正好对上:标准库自带net/http和encoding/json,交叉编译简单,编译出的二进制体积小,常驻内存也能控制在几十 MB 以内。对我这种“工具型程序”的使用场景来说,工程收益很高。
1.2 三条路线对比,我最终为什么选择原生绑定
我把可能的方案理了一遍,列成一张简单的对比表:
| 方案 | 菜单栏文本常驻 | 依赖体积 | 开发难度 | 跨平台 |
|---|---|---|---|---|
| Python 脚本 + 状态栏封装 | 支持 | 中等 | 低 | 仅 macOS |
| Electron / Tauri | 支持 | 大 | 中 | 全平台 |
| Go 直接调 AppKit | 支持 | 极小 | 偏高 | 仅 macOS |
Electron 对个人小工具来说太重了,一个价格显示程序动不动占用两三百 MB 内存,实在不像话。Python 脚本虽然开发快,但要保证本机环境里正好有合适的包,打包成独立程序时也经常遇到依赖不全的问题,后续维护成本并不低。Tauri 的质量很好,但技术栈复杂度没有比 Go 方案低多少。
最终决定直接在 Go 里通过 cgo 调用 AppKit,图的就是两件事:一是菜单栏标题(NSStatusItem.title)是原生能力,显示效果和刷新响应最可控;二是整个程序只有一个 Go 二进制,不依赖其他运行时。我自己的经验是,只要用对线程模型,这方案并没有想象中那么难。
2. 行情数据层:接口选择、请求节奏和容错设计
2.1 行情接口为什么不写死在代码里
所有“取价格”的模块,业务核心其实都差不多:构造 HTTP 请求、解析 JSON、取出目标价格。难点往往不在代码,而在“选择哪个数据源”。
我这次没有把某个特定接口写死,而是在代码里抽象成了一个常量priceAPI。你可以把它替换成自己常用的任何行情地址,只要返回结构能对上就行。这样做的另外一个好处是,以后换数据源的时候不需要改动任何 UI 逻辑,只改一处请求地址。
一个典型的免费行情接口返回结构是这样的:
{ "ethereum": { "usd": 1720.35, "cny": 12458.21 } }对这种结构,Go 用map[string]float64来解析是最省事的。因为不管你是想看美元还是人民币,都可以通过同一个字段名去取,不用为每个币种写死一个结构体字段。
2.2 数据层实现:HTTP 超时、JSON 解析和错误降级
数据层我写成这样,注意几个关键点:
type priceResponse struct { Ethereum map[string]float64 `json:"ethereum"` } func fetchPrice(symbol, currency string) (string, error) { url := fmt.Sprintf("https://api.example.com/simple/price?ids=%s&vs_currencies=%s", symbol, currency) client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get(url) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("行情接口返回状态码: %d", resp.StatusCode) } var pr priceResponse if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil { return "", err } price, ok := pr.Ethereum[currency] if !ok || price == 0 { return "", fmt.Errorf("响应中没有找到目标币种价格") } return makeTitle(price, currency), nil }代码里有两个很容易被忽略的细节:
第一是http.Client{Timeout: 5 * time.Second}。如果接口一直不响应,Go 的默认http.Get可能等很久,菜单栏标题就会卡在“加载中”状态。设置超时之后,最多等 5 秒就会进入错误处理分支,下个刷新周期再重试,这比一直卡死强得多。
第二是resp.StatusCode的判断。很多免费接口在限流时返回 429 或 503,如果不做状态码检查,直接解析 JSON 可能会得到一个空结构。虽然解析本身不会报错,但价格变成了 0,显示出来对用户根本没有意义。
makeTitle根据计价单位生成显示文本,比如美元是ETH $1720.35,人民币是ETH ¥12458.21:
func makeTitle(price float64, currency string) string { symbol := "$" if currency == "cny" { symbol = "¥" } return fmt.Sprintf("ETH %s%.2f", symbol, price) }2.3 刷新间隔的取舍:为什么默认值定在 30 秒
行情工具有个天然矛盾:刷得太频繁,菜单栏标题会像跑马灯一样不断变化,而且免费接口容易触发限流;刷得太慢,价格信息又不够“实时”。
我按照接口请求次数算了一笔账:
- 5 秒一次:一天约 17280 次请求,免费接口大概率被限流。
- 10 秒一次:一天约 8640 次请求,依然偏多。
- 30 秒一次:一天约 2880 次请求,对正常的小工具来说已经足够温和。
- 60 秒一次:一天约 1440 次请求,但打开菜单栏看价格时经常觉得不够新。
对“瞄一眼大概价格”这个场景来说,30 秒的刷新频率是最合适的。如果某一天你确实需要更实时的数据,把代码里的time.Sleep(30 * time.Second)改成 10 秒就行,但我不建议低于 5 秒,否则在菜单栏这种高频更新区域很容易看到明显的文字闪烁。
3. 用 cgo 直接操作 NSStatusItem,把价格画到菜单栏
3.1 最小可运行的 cgo 绑定代码
macOS 的菜单栏常驻项,核心就是 NSStatusItem。给title设置一段文本,它就会显示在系统菜单栏右侧。整个过程不需要创建窗口,也不需要在 Dock 里出现图标。
我直接通过 cgo 封装了三个 C 函数:初始化、更新标题、启动事件循环。注意,cgo 的 preamble 默认是当作 C 语言编译的,不能直接写 Objective-C 的方括号语法,所以在 CFLAGS 里加一个-x objective-c,让 clang 按 Objective-C 模式处理这段代码。
package main /* #cgo CFLAGS: -x objective-c #cgo LDFLAGS: -framework Foundation -framework AppKit #import <Cocoa/Cocoa.h> #import <dispatch/dispatch.h> static NSStatusItem *gStatusItem; static void setupStatusItem(const char *initialText) { [NSApplication sharedApplication]; [[NSApplication sharedApplication] setActivationPolicy:NSApplicationActivationPolicyAccessory]; NSStatusBar *bar = [NSStatusBar systemStatusBar]; gStatusItem = [bar statusItemWithLength:NSVariableStatusItemLength]; NSString *text = [NSString stringWithUTF8String:initialText]; [gStatusItem setTitle:text]; NSMenu *menu = [[NSMenu alloc] init]; NSMenuItem *quitItem = [[NSMenuItem alloc] initWithTitle:@"退出" action:@selector(terminate:) keyEquivalent:@"q"]; [quitItem setTarget:[NSApplication sharedApplication]]; [menu addItem:quitItem]; [gStatusItem setMenu:menu]; } static void updateTitle(const char *text) { NSString *nsText = [[NSString alloc] initWithUTF8String:text]; dispatch_async(dispatch_get_main_queue(), ^{ [gStatusItem setTitle:nsText]; [nsText release]; }); } static void runLoop(void) { [[NSApplication sharedApplication] run]; } */ import "C" import ( "encoding/json" "flag" "fmt" "net/http" "runtime" "time" "unsafe" ) const priceAPI = "https://api.example.com/simple/price?ids=ethereum&vs_currencies=usd" type priceResponse struct { Ethereum map[string]float64 `json:"ethereum"` } var currency = flag.String("currency", "usd", "计价单位:usd 或 cny") func makeTitle(price float64, currency string) string { symbol := "$" if currency == "cny" { symbol = "¥" } return fmt.Sprintf("ETH %s%.2f", symbol, price) } func fetchPrice(symbol, currency string) (string, error) { url := fmt.Sprintf("https://api.example.com/simple/price?ids=%s&vs_currencies=%s", symbol, currency) client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get(url) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("行情接口返回状态码: %d", resp.StatusCode) } var pr priceResponse if err := json.NewDecoder(resp.Body).Decode(&pr); err != nil { return "", err } price, ok := pr.Ethereum[currency] if !ok || price == 0 { return "", fmt.Errorf("响应中没有找到目标币种价格") } return makeTitle(price, currency), nil } func updateMenuTitle(title string) { cTitle := C.CString(title) defer C.free(unsafe.Pointer(cTitle)) C.updateTitle(cTitle) } func refreshLoop() { for { title, err := fetchPrice("ethereum", *currency) if err != nil { updateMenuTitle("ETH 暂无报价") } else { updateMenuTitle(title) } time.Sleep(30 * time.Second) } } func main() { flag.Parse() runtime.LockOSThread() initial := C.CString("ETH 加载中...") C.setupStatusItem(initial) C.free(unsafe.Pointer(initial)) go refreshLoop() C.runLoop() }这段代码已经能完成最核心的功能:启动后菜单栏出现“ETH 加载中...”,30 秒后变成实际价格,之后每 30 秒刷新一次。命令行参数-currency=cny可以切换成人民币显示。
要注意的是代码里的api.example.com只是示意用的保留地址。你在真正运行时需要换成自己可用的行情接口,只要返回字段结构匹配map[string]float64就可以。
3.2 菜单怎么加,退出事件怎么接
macOS 的菜单栏状态项不只是显示文字,它还可以挂一个NSMenu。我在这里只放了一个“退出”菜单项,点击后直接调用terminate:退出程序。
有一个细节值得注意:我把退出菜单项的target设置成了[NSApplication sharedApplication]。如果不设置对应的 target,点击菜单项时事件会沿着 responder chain 走,有时能找到能处理的响应者,有时没有,行为比较飘。显式指定 target 之后,点击事件一定会落到 NSApplication,然后调用terminate:,行为非常明确。
对个人小工具来说,菜单项不需要多,一个退出就够了。如果你想加“打开行情网页”这类菜单,需要处理 selector 回调到 Go 函数的问题,复杂度会高很多,建议直接走下一节说的更轻量的替代方案。
3.3 所有 UI 更新都必须回到主线程
这是整个工具最容易翻车的地方,也是我最想强调的一点。AppKit 不是线程安全的,所有 UI 操作都必须在主线程完成。Go 的 goroutine 模型天然是“随便起线程”,所以这里有两个阶段必须处理:
第一阶段是初始化。调用[NSApplication sharedApplication]之前,必须先在当前 goroutine 上调用runtime.LockOSThread(),把这个 goroutine 锁死在操作系统的一个线程上。否则 Go 的调度器可能让这个 goroutine 迁移到另一个线程,AppKit 会不认这个“主线程”。
第二阶段是刷新。刷新循环是在go refreshLoop()这个 goroutine 里跑的,不能直接调用[gStatusItem setTitle:]。我在 C 函数里用dispatch_async(dispatch_get_main_queue(), ^{...})把真正的 UI 更新派发回了主线程。这样即使刷新 goroutine 被阻塞,也不会卡住菜单栏渲染。
我一开始犯过一个错误:直接把状态栏更新放在普通 goroutine 里去做,结果菜单栏标题偶尔出现闪烁,而且 AppKit 会打印一堆线程安全的警告。把 UI 更新统一送回主线程之后,这个问题立刻消失。
3.4 内存管理上的小细节
cgo 默认情况下不开 ARC,所以 Objective-C 对象要手动管理内存。在updateTitle里,NSString是由alloc创建的,所以在 block 里执行完setTitle之后必须手动release。
这里可能有人会问:gStatusItem.title = nsText不会持有这个字符串吗?title属性内部会做一次copy,所以release掉nsText之后,状态栏标题依然是有效的。如果不做这个release,程序每 30 秒就泄漏一个字符串,虽然速度很慢,但常驻进程跑一整天之后,内存会有可见的增长,这不是一个好习惯。
另外C.CString的释放也要注意。每次调用updateMenuTitle时,我把 C 字符串用defer C.free释放掉。因为在 C 函数内部已经把字符串内容拷贝成了NSString,释放原内存不会影响已经生成的对象。
4. 组装成 .app 并设置开机自启
4.1 app 目录结构和 Info.plist 关键字段
菜单栏程序没有窗口,本质上仍然是一个 macOS 应用,最好还是打包成标准的.app目录结构。一个最小可用的目录长这样:
ETHPrice.app/ └── Contents/ ├── Info.plist └── MacOS/ └── ethprice先把 Go 编译出来的二进制改名为ethprice,放进Contents/MacOS/,再写好Info.plist。关键的键值如下:
<?xml version="1.0" encoding="UTF-8"?> <plist version="1.0"> <dict> <key>CFBundleExecutable</key> <string>ethprice</string> <key>CFBundleIdentifier</key> <string>local.ethprice.ticker</string> <key>CFBundleName</key> <string>ETHPrice</string> <key>CFBundlePackageType</key> <string>APPL</string> <key>LSMinimumSystemVersion</key> <string>12.0</string> <key>LSUIElement</key> <true/> </dict> </plist>LSUIElement很重要,设置成true之后,这个应用不会出现在 Dock 里,也不会在切换应用时冒出来。配合代码里的NSApplicationActivationPolicyAccessory,效果是一个纯粹的菜单栏常驻工具。
4.2 签名、Gatekeeper 和首次打开
自己打包的应用,如果不做任何签名,首次运行时 macOS 的 Gatekeeper 可能会弹窗提示“无法验证开发者”。这在开发阶段非常常见,不用紧张。
最简单的处理方式有两种:
- 右键点击
.app,选择“打开”,在弹窗里确认打开。 - 用 ad-hoc 签名给应用打一个临时签名:
codesign --force --deep -s - ETHPrice.app。
codesign的-s -表示使用 ad-hoc 签名,不依赖开发者证书。对于个人小工具来说,这个处理很常见。如果以后要分发给别人,再考虑普通的开发者签名和公证流程,这不在个人项目的优先范围里。
4.3 开机自启:手动登录项和 SMAppService
最简单可靠的自启方式,是把.app拖到“系统设置 - 通用 - 登录项”里。macOS 会自动处理启动时机,不需要在代码里为登录项写任何东西。
如果以后想要程序化地管理自启,还可以用SMAppService注册登录项,但那个 API 在 macOS 13 之后才有,而且涉及权限申请、使用SMAppService.mainApp.register()这样的调用。对个人小工具来说,手动添加登录项已经足够,不建议一开始就引入那么多系统权限逻辑。
另外一个实操建议是:把.app放到“应用程序”文件夹里再添加登录项。直接放在桌面或下载目录里虽然也能运行,但登录项有时会因为路径上下文的问题出现莫名失败,从应用程序目录启动的可靠度更高。
5. 我在真机运行中踩过的坑
5.1 菜单栏标题闪烁和“刷新太快”的问题
菜单栏的NSStatusItem对标题更新频率很敏感。我最初把刷新间隔设置成 3 秒,结果菜单栏里那个标题几乎是“跳着变”的,观感很不好。
后来我发现,闪烁往往有两个来源:一个是 UI 更新没有回到主线程,另一个就是更新频率本身太高。解决了线程模型问题之后,把间隔从 3 秒改到 30 秒,闪烁问题基本就消失了。
顺带一提,如果有一天你想做“价格跳变提醒”,不要在状态栏标题里用大量字符改变来制造动画效果。菜单栏不是给你做动画的地方,任何高频的标题更新都会影响整个系统的菜单栏流畅度。想提醒价格变动,走系统通知是更体面的方式。
5.2 深色模式、浅色模式下的显示差异
NSStatusItem.title的文本颜色由系统自动适配,浅色模式下是深色文字,深色模式下是浅色文字,不需要开发者做任何处理。这是我选择纯文字方案的一个重要原因。
反过来,如果你不想显示文字,而是想用一张以太坊图标作为菜单栏图片,那就得注意 macOS 的 template image 规范。菜单栏图标应该用带 alpha 通道的黑色图像,系统会自动把它渲染成当前菜单栏需要的颜色。用彩色图片直接塞进去,经常会出现浅色模式下看不清、深色模式下颜色诡异的问题。
5.3 网络抖动时,不要让标题变成一串错误信息
网络请求总会有失败的时候,比如接口限流、Wi-Fi 断连、DNS 解析超时。如果这些异常直接显示到菜单栏,你看到的就是一串冗长的错误文字。
我处理的办法很简单:异常时统一显示“ETH 暂无报价”。这个文案不管什么错误都能覆盖,而且长度短,不会把菜单栏撑得太宽。你可以在日志里看到具体错误原因,但用户界面上不需要暴露细节。
如果接口连续多次失败,还可以考虑退避策略,比如每次失败后把刷新间隔临时加倍,最大加到 5 分钟。这样既不会给接口服务器压力,也不会让状态栏一直转圈。
5.4 一个常驻进程的资源占用应该控制在什么量级
我对这个工具的预期是“安静地挂在菜单栏上,用户几乎感觉不到它的存在”。实测下来,常驻内存大约在 20 到 40 MB,CPU 也是只在刷新的瞬间跳一下,平时基本为零。这符合我的预期。
如果你的版本内存异常高,先检查是不是循环里积累了未释放的字符串对象,其次检查 HTTP 响应体有没有关闭。defer resp.Body.Close()在这个小工具里特别重要,因为它不是一次性请求,而是每 30 秒一次。响应体不关闭,连接不会复用,文件描述符也会慢慢累积,最后程序会越来越“重”。
我还在循环里加过一次日志输出,结果半天下来日志文件涨到了几十 MB。常驻进程要注意日志量,线上服务可以随便打,这种桌面小工具打日志也要克制,否则用户根本不会去看,反而白白占磁盘空间。
5.5 不同 macOS 版本上的 API 兼容性
NSStatusItem是一个非常老的 API,从 macOS 10.10 到最新系统行为都保持一致,setTitle:这类基础方法没有大的变化。我在这篇里不涉及复杂的系统通知和登录项 API,所以基本不用担心兼容性问题。
主要需要注意的只有两点:一是不要在太老的系统上依赖只在最新系统里出现的新 API;二是打包时LSMinimumSystemVersion按自己实际测试的最低系统写,宁可保守一点,也不要写一个过高版本导致朋友的老 Mac 装不上。
6. 还能怎么扩展这个小工具
6.1 多币种和自定义数据源
目前代码里只请求ethereum这一个币种。如果你想显示bitcoin,只需要把请求里的币种标识改一下,价格字段换成bticoin对应的 key。当然,更通用的方式是把行情接口请求参数抽象成配置:
type Config struct { Symbol string `json:"symbol"` Currency string `json:"currency"` Interval int `json:"interval"` }用一个 JSON 配置文件去控制,就能做到不改代码就换币种、换计价单位、换刷新频率。菜单栏工具的用户界面越少越好,配置化才是真正的扩展方向。
6.2 价格阈值通知:用系统通知代替菜单栏闪烁
如果你不想一直盯菜单栏,而是希望价格突破某个阈值时收到提醒,可以走 macOS 的UNUserNotificationCenter。第一次使用时需要向用户申请通知权限,之后每次价格越过阈值时发一条本地通知。
通知里的价格信息需要实时从数据层计算。数据层可以额外维护一个“上一次价格”的状态,和当前价格对比,只在价格变化超过一定百分比时发通知,避免频繁打扰。这个方向我还没有在文章里展开实现,因为涉及通知权限申请和前台后台的差异处理,比菜单栏显示要复杂一个量级。
6.3 如果不想碰 cgo,也可以试试轻量 GUI 框架的托盘模式
我理解不是所有人都喜欢折腾 cgo 和 Objective-C。Go 生态里有一个成熟的跨平台 GUI 框架,它提供了类似系统托盘的能力,虽然对“菜单栏直接显示文字”这种需求支持得不算理想,但胜在代码非常干净,适合想快速跑通一个带菜单的常驻程序。
用这个框架实现的托盘菜单大概长这样:
package main import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/app" "fyne.io/fyne/v2/driver/desktop" ) func main() { a := app.NewWithID("local.eth.ticker") quit := fyne.NewMenuItem("退出", a.Quit) menu := fyne.NewMenu("ETH 价格", quit) if desk, ok := a.(desktop.App); ok { desk.SetSystemTrayMenu(menu) } a.Run() }这个方案的优势是跨平台、API 统一,适合“先跑通再优化”的快速原型阶段。缺点也很明显:它更擅长图标加菜单的交互,想在菜单栏上直接显示不断变化的文本价格就需要额外把文字渲染成图标,绕了一圈反而没省事。
最后再分享一点我的实际体会
这个小工具的价值很小,无非是开机之后瞄一眼菜单栏,不用再打开任何网页。但写它的过程,把 macOS 菜单栏原生的NSStatusItem、cgo 的线程模型、Go 标准库的 HTTP 与 JSON 解析、以及.app打包和登录项这些平时不会放在一起的知识点,完整串了一遍。
尤其是“UI 更新必须回到主线程”这件事,在桌面开发里是绕不开的基础前提。写过一次这种常驻菜单栏工具之后,以后再遇到任何“无界面但有菜单栏入口”的需求,思路都会清晰很多。如果你也想给自己做一个类似的常驻工具,比如天气、服务器负载、某个网站的价格变动提醒,直接把这里的数据层和 UI 层拆分思路拿过去用就行。