首页 / 资讯中心 / 文章详情

不打开浏览器看ETH价格:Golang+AppKit实现macOS菜单栏常驻工具

不打开浏览器看ETH价格:Golang+AppKit实现macOS菜单栏常驻工具 ★ FEATURED ARTICLE
我最近一直在琢磨一个不用打开浏览器就能看 ETH 价格的办法。需求听起来特别简单但真正放到 macOS 顶部那块“常驻菜单栏”时我先后试了脚本方案、试了几个现成封装最后干脆用 Golang 直接调 AppKit写了一个核心代码只有 100 行左右的小工具。这篇文章不是讨论行情本身而是把从“想法”到“跑在菜单栏上”的完整路径拆给你看包括为什么选 Golang、为什么没有走更省事的路线、以及那几个不踩一次就不会注意的坑。如果你也需要一个类似的菜单栏常驻小工具顺着这条思路往下走应该能比较快地跑起来。1. 为什么这个场景适合用 Golang而不是脚本或 Electron1.1 需求本身很简单但我为什么没有走捷径先把这个工具的实际需求说清楚一个常驻进程每隔一段时间拉一次 ETH 价格刷新到菜单栏标题上点击菜单可以退出仅此而已。不需要图表不需要K线不需要复杂的 UI 交互。按理说这样的需求用 Python 脚本或者 AppleScript 都可以做我一开始也确实往“最容易实现”的方向想。但稍微列了一下要求就发现其实是给“个人长期使用的小工具”定的边界编译后最好是一个独立的二进制文件拷到任何一台 Mac 上都能运行不依赖解释器和一堆第三方依赖。常驻时的内存占用和 CPU 占用尽量低不能为了显示一个价格占用几百 MB 内存。网络请求、JSON 解析这些基础能力最好由标准库直接提供不要为了一个小功能引入大框架。这几点 Golang 都正好对上标准库自带net/http和encoding/json交叉编译简单编译出的二进制体积小常驻内存也能控制在几十 MB 以内。对我这种“工具型程序”的使用场景来说工程收益很高。1.2 三条路线对比我最终为什么选择原生绑定我把可能的方案理了一遍列成一张简单的对比表方案菜单栏文本常驻依赖体积开发难度跨平台Python 脚本 状态栏封装支持中等低仅 macOSElectron / Tauri支持大中全平台Go 直接调 AppKit支持极小偏高仅 macOSElectron 对个人小工具来说太重了一个价格显示程序动不动占用两三百 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%svs_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.21func 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?idsethereumvs_currenciesusd 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%svs_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 秒刷新一次。命令行参数-currencycny可以切换成人民币显示。要注意的是代码里的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 version1.0 encodingUTF-8? plist version1.0 dict keyCFBundleExecutable/key stringethprice/string keyCFBundleIdentifier/key stringlocal.ethprice.ticker/string keyCFBundleName/key stringETHPrice/string keyCFBundlePackageType/key stringAPPL/string keyLSMinimumSystemVersion/key string12.0/string keyLSUIElement/key true/ /dict /plistLSUIElement很重要设置成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 MBCPU 也是只在刷新的瞬间跳一下平时基本为零。这符合我的预期。如果你的版本内存异常高先检查是不是循环里积累了未释放的字符串对象其次检查 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 层拆分思路拿过去用就行。
阅读完成 · 觉得有帮助?
咨询建站