☰
用Golang开发Mac菜单栏以太坊价格监控小工具
2026/10/17 1:49:29 网站建设 项目流程

前阵子一直有个小困扰:在Mac上想随时瞄一眼ETH的价格,试过好几个现成的行情小工具,不是需要常驻一个笨重的窗口,就是菜单栏插件很久没人维护,数据延迟能差出好几分钟。后来想着自己也写过不少Golang小工具,干脆用Golang写一个直接住在Mac通知栏(准确说是菜单栏状态区)的ETH价格显示小工具,平时就显示一行价格,点一下还能手动刷新。这个项目不大,但麻雀虽五脏俱全:要调行情接口、要处理JSON、要做定时刷新、还要碰一下系统托盘API和Mac打包的常见坑。折腾完我觉得挺值得,今天把整个过程拆开讲讲,想自己动手做类似工具的同学可以直接抄作业。

1. 项目背景与方案选型

1.1 为什么做在菜单栏而不是桌面窗口

很多人第一反应是“看个价格至于这么折腾吗?开个网页不就行了”。问题是,如果你真的需要频繁看行情,开网页的成本其实很高:浏览器标签得来回切换,桌面悬浮窗会挡住代码,还容易分心。菜单栏就不一样,它是Mac上永远存在的一条系统级区域,不需要抢焦点,鼠标一划就能看到,而且可以同时显示和叠加其他内容。

这个工具的核心场景是“被动提醒”:不是等你想起来去看价格,而是价格就在那里,一眼扫过去就能知道大概。菜单栏非常适合这种场景,因为它的生命周期跟随系统,用户不需要手动打开窗口。另外,菜单栏应用可以做到非常轻量,不跳Dock栏,也不会在Command+Tab切换时出现,从使用体验上更像一个“系统组件”而不是一个“App”。

对比过其他方案:悬浮球类工具需要自己画界面,处理窗口层级和鼠标穿透很麻烦;原生通知中心小组件需要按F12或两指从右侧滑动呼出,不够即时;浏览器插件需要依赖浏览器常驻。综合下来,菜单栏状态区是成本最低、感知最强的方案。

1.2 为什么选Golang而不是Swift或Electron

Mac原生开发优先想到的往往是Swift + AppKit,但这里有个现实问题:做一个只显示价格的小工具,Swift需要新建Xcode项目、搞storyboard、处理签名和证书,整套流程对“只想写个小脚本”的项目来说太重了。而且Swift的编译产物同样依赖系统框架,跨平台复用几乎为零。

Electron做菜单栏工具也很流行,但它的缺点非常致命:一个几百KB逻辑的程序,打出来的包至少一百多MB,内存随便几百MB起步,还要带一个Chromium进程。如果只是为了显示一个数字,这种资源开销完全不合理。

Golang的优势正好落在中间:编译成单个二进制文件,几MB大小,内存占用通常只有几十MB,启动速度毫秒级。加上标准库自带net/http和encoding/json,请求行情接口、解析JSON完全不需要引入第三方依赖。而且Golang的并发模型非常适合这种“定时刷新+后台循环”的任务,一个goroutine跑定时器,另一个监听退出事件,写起来很清爽。

1.3 整体实现流程

在动手之前,先理清楚整个工具的数据流:

  1. 程序启动,初始化系统菜单栏图标和标题(先显示“ETH: --”)。
  2. 启动一个定时器,每隔5秒触发一次价格刷新。
  3. 每次刷新时,发HTTP请求到公开行情API,拿到ETH/USDT的最新成交价。
  4. 把价格格式化成字符串,例如“ETH: $2,345.67”。
  5. 调用菜单栏库的SetTitle方法,更新菜单栏标题文字。
  6. 如果用户点击菜单项“退出”,则触发优雅退出,关闭定时器并释放系统资源。

这个流程里,最核心的是第3步和第5步。第3步考验API对接的健壮性,第5步考验对系统菜单栏库的掌握。下面按顺序拆开讲。

2. 数据源与API对接

2.1 行情数据源怎么选

行情数据源是整个工具最关键的依赖,选不好就会遇到频繁限流、返回字段不稳定、甚至突然失效的问题。我选了支持公开访问、不需要注册密钥的行情接口,返回格式是JSON,调用简单。实际开发中你可以根据自己的偏好替换成任何公开行情API,核心关注点有三个。

第一是稳定性。最好选大型聚合行情平台,这类服务通常有容灾和负载均衡。第二是返回速度,理想状态在200ms以内,因为每次刷新都依赖这个延迟,如果接口要2秒,那刷新周期就得拉很长。第三是字段清晰,返回的JSON里路径不要嵌套太深,最好一眼就能看到价格字段。

我用的是类似https://api.example-price.io/v1/ticker?symbol=ETHUSDT这样的接口,返回内容长这样:

{ "symbol": "ETHUSDT", "price": "2345.67", "timestamp": 1710000000 }

注意price是一个字符串,而不是浮点数。很多行情API为了保持精度,都会用字符串表示价格,解析时如果直接当成float64处理,可能会丢失精度。虽然显示用两位小数足够,但解析时最好先保留字符串,通过strconv.ParseFloat转换后再格式化,避免中间被科学计数法干扰。

我不建议把刷新间隔设得太短。公开接口一般有每秒请求限制,如果5秒请求一次,一分钟才12次,非常安全。如果是1秒一次,很可能触发限流导致封IP。做小工具“够用”就好,价格实时性要求不需要到秒级。

2.2 请求接口与JSON解析

用Golang请求接口非常简单,标准库net/http就够了。但有几个细节一定要处理好,否则工具会越跑越不稳定。

首先配置HTTP客户端超时时间。我习惯用http.Client{Timeout: 5 * time.Second},这样如果接口卡死,最多等5秒就会放弃,不会让goroutine一直挂着导致资源泄漏。

其次要检查HTTP状态码。不是所有返回200的响应都是合法的,有些代理或网关会返回200但内容其实是错误页。最稳妥的方式是限定resp.StatusCode == http.StatusOK,之后读body,再用json.Unmarshal解析。

这里给出一段核心的获取价格函数:

package main import ( "encoding/json" "fmt" "io" "net/http" "strconv" "time" ) type TickerResponse struct { Symbol string `json:"symbol"` Price string `json:"price"` } func fetchETHPrice() (float64, error) { apiURL := "https://api.example-price.io/v1/ticker?symbol=ETHUSDT" client := &http.Client{Timeout: 5 * time.Second} resp, err := client.Get(apiURL) if err != nil { return 0, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return 0, fmt.Errorf("unexpected status: %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return 0, fmt.Errorf("read body failed: %w", err) } var ticker TickerResponse if err := json.Unmarshal(body, &ticker); err != nil { return 0, fmt.Errorf("json unmarshal failed: %w", err) } price, err := strconv.ParseFloat(ticker.Price, 64) if err != nil { return 0, fmt.Errorf("parse price failed: %w", err) } return price, nil }

这段代码把“发请求、读响应、解析JSON、转浮点数”分成四步,每一层都有明确的错误返回。实际处理中,不要把这些错误全部忽略掉,至少要在logger里留下一行记录。这样当菜单栏价格长时间不更新时,你能知道是接口超时还是解析失败。

2.3 价格刷新策略:定时器与上下文控制

刷新逻辑最初我用的是for { time.Sleep(5 * time.Second) },后来发现有个问题:如果某次请求因为网络原因耗时超过了5秒,那么下一次刷新实际上会变成5秒+耗时,长期运行下来刷新周期会漂移。

更好用的方案是time.Ticker:

ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for range ticker.C { price, err := fetchETHPrice() if err != nil { // 保留上一次价格,或者显示"--" continue } // 更新菜单栏标题 }

Ticker会在固定的时间点触发,不会因为上一次请求耗时而累积延迟。但这里有个并发上的问题:更新菜单栏UI必须在主线程或者与systray库兼容的goroutine中进行,直接在上面的循环里调用SetTitle很可能会造成界面不刷新或偶发崩溃。这个问题在下一部分会细说。

上下文控制方面,我会在初始化时创建一个context.Context,通知定时器退出时通过cancel()取消。不过如果你只是监听系统信号或菜单栏退出事件,也可以用一个简单的quitchannel替代,不一定非要上context。小工具优先保证代码可读性。

3. 菜单栏状态栏开发实战

3.1 托盘图标库的选择

在Mac上创建菜单栏应用,可以直接调用系统的NSStatusBar,但用Cocoa API写起来相当繁琐,还要考虑内存管理和事件循环。Golang生态里已经有不少开源的system tray库,其中最常用的一个叫systray,它封装了不同平台的系统托盘操作,你只需要调用几个函数:

  • systray.Run(onReady, onExit):启动菜单栏应用。
  • systray.SetTitle("ETH: $2,345.67"):设置菜单栏标题文字。
  • systray.SetTooltip("行情工具"):设置鼠标悬停时的提示。
  • systray.AddMenuItem("刷新", "点击立即刷新价格"):添加菜单项。
  • systray.Quit():退出菜单栏应用。

使用前先通过go get把库拉下来,然后注册一个导入路径。本文示例代码用github.com/example/systray代替,你实际使用时,要替换成你选定的库的完整导入路径。

不过要提醒一点:这类库一般有平台相关的实现,编译时需要确保在Mac上执行,交叉编译到其他平台需要处理CGO和依赖。好在我们只做Mac版,这个限制不算问题。

3.2 初始化与更新菜单栏的线程规则

systray库的核心运行机制是Run(onReady, onExit),调用后会进入该库的事件循环,直到Quit()被调用。onReady回调里适合做初始化工作:设置菜单栏图标、创建菜单项、启动刷新goroutine。

最关键的一点:更新菜单栏标题需要在systray运行期间调用,而且不能在onReady之外随机调用。我自己实际测试发现,当你在一个独立的goroutine里刷新价格并直接调用SetTitle时,某些版本的systray库会出现偶发panic,错误信息通常是call to SetTitle before Run或者死锁。

解决办法是:在onReady里启动一个刷新循环,并确保这个循环的生命周期受systray控制。如果实在需要从其他goroutine触发更新,可以借助一个channel把更新请求发给主刷新循环,而不是直接跨goroutine调用UI API。简单做法就是在goAfterReady函数里统一处理:

func onReady() { systray.SetTitle("ETH: --") systray.SetTooltip("ETH行情工具") mRefresh := systray.AddMenuItem("立即刷新", "手动刷新价格") mQuit := systray.AddMenuItem("退出", "退出程序") go func() { for { select { case <-mRefresh.ClickedCh: updatePrice() case <-mQuit.ClickedCh: systray.Quit() return } } }() go loopUpdatePrice() }

updatePrice()里负责请求价格并调用SetTitle。loopUpdatePrice()里则是Ticker循环。两个goroutine都调用updatePrice,但都不会和systray的UI线程冲突,因为systray库本身对SetTitle的调用有一定线程保护。即便这样,还是建议所有UI更新都集中在少数几个函数中,方便控制。

3.3 完整代码示例与说明

下面是一份可运行的骨架示例,把API请求、菜单栏初始化、定时刷新整合到了一起。为了篇幅,省略了一些错误细节,但核心流程是完整的。

package main import ( "fmt" "runtime" "strconv" "time" "github.com/example/systray" ) func main() { // 在Mac上,系统菜单栏操作最好锁定线程 runtime.LockOSThread() systray.Run(onReady, onExit) } func onReady() { systray.SetTitle("ETH: --") systray.SetTooltip("ETH行情小工具") mRefresh := systray.AddMenuItem("立即刷新", "手动刷新价格") mQuit := systray.AddMenuItem("退出", "退出程序") go func() { for { select { case <-mRefresh.ClickedCh: updatePrice() case <-mQuit.ClickedCh: systray.Quit() return } } }() go loopUpdatePrice() } func onExit() { // 资源清理,退出前的一些收尾工作 } func loopUpdatePrice() { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for range ticker.C { updatePrice() } } func updatePrice() { price, err := fetchETHPrice() if err != nil { // 出错时不要清空标题,避免用户看到空白 systray.SetTitle("ETH: --") return } text := fmt.Sprintf("ETH: $%s", formatPrice(price)) systray.SetTitle(text) } func formatPrice(price float64) string { // 保留两位小数,并添加千分位分隔符(可选) return strconv.FormatFloat(price, 'f', 2, 64) }

这份代码里,runtime.LockOSThread()是很多Mac下GUI库的一个隐含要求,它能确保后续的UI操作始终在同一个系统线程内执行。我在一开始没加这行,结果出现过菜单栏标题无法更新的情况,加上之后问题就消失了。

4. 编译打包与开机自启

4.1 生成Mac可执行文件与App目录

开发完成后,最简单的方式是直接编译:

go build -o eth-price main.go

这样会生成一个二进制文件,双击可以在终端里运行?不,实际上直接双击就可能在Finder中打开但没有窗口,因为程序没有创建窗口。但由于没有App包装,macOS可能会把纯二进制当成命令行工具,双击后会有终端窗口闪一下,体验不太好。要让它变成真正能常驻菜单栏的App,需要手动做一个ETHPrice.app目录。

创建一个ETHPrice.app/Contents/MacOS/目录,把编译出来的二进制文件放进去,命名为ETHPrice。再创建ETHPrice.app/Contents/Info.plist,内容大致是:

<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleName</key> <string>ETHPrice</string> <key>CFBundleDisplayName</key> <string>ETHPrice</string> <key>CFBundleIdentifier</key> <string>com.example.ethprice</string> <key>CFBundleExecutable</key> <string>ETHPrice</string> <key>CFBundlePackageType</key> <string>APPL</string> <key>LSMinimumSystemVersion</key> <string>12.0</string> </dict> </plist>

然后把整个ETHPrice.app拖到“应用程序”目录中即可。注意,我这里用的com.example.ethprice只是占位,实际可以换成你自己的标识,不要求唯一,因为是自己用的工具,签名都不是必须的。

4.2 开机自启与资源占用

开机自启不需要自己写复杂的launchd服务,在系统设置里找到“登录项”,把ETHPrice.app添加进去就行。这样每次登录后就会自动启动,菜单栏常驻。

如果你更喜欢用命令行管理,也可以使用launchd,但对于这样一个轻量工具,图形界面设置已经足够。需要注意,添加登录项后,App会以普通用户权限运行,不影响读取公开API。

资源占用方面,Golang编译的二进制文件大约5-8MB,运行时内存占用大约15-30MB,CPU占用基本为0,只有每次刷新时会有一瞬间的波动。相比Electron动不动几百MB的内存,这点资源开销完全可以忽略。

4.3 功能扩展:多币种与涨跌方向

这个项目后续很容易扩展,我这里列几个我想到的方向。

多币种:在菜单里增加“BTC”“SOL”等子菜单,点击后切换当前显示的币种。实现方式是在点击事件里改变一个全局变量,刷新循环里根据变量请求不同接口。唯一需要注意的是切换后要立刻刷新一次,不要等下一个5秒周期。

涨跌方向:系统菜单栏的Title不支持富文本,但可以加箭头字符。在格式化价格前,先和上一次价格做比较,如果大于上次,加一个“↑”,小于则加“↓”。不过注意,上升和下降箭头在菜单栏显示时可能因为字体原因宽度不同,导致文字轻微跳动。另一种方案是生成彩色图标,但需要操作图片,复杂度会上一个台阶。

价格预警:可以在菜单设置里输入目标价格,后台goroutine每秒判断一次,如果触发条件就调用osascript弹通知。这个扩展需要小心通知频率,避免重复弹窗。

5. 我踩过的坑与排查方法

5.1 菜单栏文字不刷新

这个坑我一开始反复遇到。程序能启动,菜单栏有图标,updatePrice()里的日志也打出来了,但标题文字就是不变。后来发现是两个原因叠加造成的。

第一,我没有调用runtime.LockOSThread(),导致系统线程和systray的预期不一致。Mac的AppKit要求UI操作必须在主线程进行,而runtime.LockOSThread可以保证主函数所在的goroutine锁定在当前线程,systray库会利用这个特性。第二,我在多个goroutine里同时调用SetTitle,造成某种竞态,偶尔更新有效,偶尔无效。解决办法就是统一到一个刷新逻辑中,避免并发调用UI API。

5.2 接口超时和异常返回

有一次我把刷新间隔改成了1秒,结果没多久API返回401,怎么检查代码都没问题,后来才发现是触发了限流。公开接口不是无限免费资源,1秒1次太激进。把刷新间隔改回5秒后恢复正常。

另外,很多行情接口在价格波动剧烈时会返回"price":"0"这种情况,如果不对0值做特殊处理,菜单栏就会显示一个离谱的价格。我在updatePrice里加了一个过滤:如果价格小于等于0,视为无效数据,沿用上一次价格。

5.3 打包后双击提示“已损坏”

Golang编译出来的二进制没有经过Apple签名,在macOS 10.15及以上版本,首次打开可能会被Gatekeeper拦截,提示“无法打开,因为来自身份不明的开发者”或者“已损坏”。这个“已损坏”其实不是文件损坏,而是没有签名。

解决方案有两种。一种是右键点击App,选择“打开”,然后在弹出的窗口里再次点击“打开”。另一种是在终端执行:

xattr -dr com.apple.quarantine /Applications/ETHPrice.app

这个命令移除quarantine属性,告诉系统这个文件是本地生成的,不需要安全审查。注意,自用工具这么处理没问题,但如果要分发给别人,还是要花点功夫做开发者签名和公证。

5.4 常见问题速查

现象原因解决办法
菜单栏只有图标,没有文字SetTitle没有被调用,或库版本不支持确认onReady里调用,并检查参数是否为空
价格一直不变接口返回缓存数据,或刷新goroutine已退出用curl单独测试接口,确认返回新数据
点击菜单栏图标没反应菜单项监听未启动检查ClickedCh的select分支是否在goroutine里运行
程序退出但图标残留未调用systray.Quit(),进程还在后台在退出菜单项里调用Quit,并识别所有子goroutine
编译报undefined: systray.Run导入库不完整或版本过旧更新库到最新版,确认平台支持Mac

最后再分享一个小技巧:如果你希望菜单栏文字能显示更多信息,比如同时显示“ETH $2345 7d +3.2%”,可以用一个很短的刷新周期,但只在每次价格变化超过一定阈值时才更新标题,比如0.5美元。这样既能保证价格新鲜,又不会因为频繁重绘造成视觉疲劳。这个阈值可以在代码里用全局变量控制,后续改成可配置也比较容易。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询