用Swift打造macOS菜单栏工具:实时显示Hacker News Karma值
写一个菜单栏工具看着自己 Hacker News 的 Karma 数字一点点涨起来是很多 HN 深度用户的小乐趣。KarmaBar 这一类项目的思路并不复杂一个常驻 macOS 菜单栏的小程序定时请求 Hacker News 的公开 API把当前用户的 Karma 值直接显示在状态栏上。本文围绕这一需求完整拆解从原理、环境准备、Swift 代码实现到常见问题的全过程。无论是 macOS 开发初学者还是想快速做一个桌面小工具的人都可以照着实现自己的版本。1. 背景与核心概念1.1 Hacker News 的 Karma 到底是什么Hacker News 简称 HN是 Y Combinator 旗下的技术社区也是很多程序员每天必看的信息源。用户在 HN 上可以提交链接、发表评论其他用户可以对内容进行 upvote点赞或 downvote点踩。你的内容得到的点赞数会汇总成一个数值这个数值就是 Karma。Karma 在 HN 里不只是一个虚荣指标。Karma 达到一定阈值后你会获得一些功能权限比如 downvote 别人的评论、创建投票帖等。所以对于长期泡 HN 的人来说Karma 既是活跃度的证明也直接影响账号的使用体验。常规做法是打开浏览器访问 news.ycombinator.com点击右上角用户名然后查看个人主页里的 Karma 字段。这个操作本身并不复杂但如果你一天要看三次又不想总是打开浏览器标签页就会觉得冗余。于是就有开发者做了 KarmaBar 这类菜单栏工具把 Karma 数值直接放到 macOS 菜单栏里一眼就能看到。1.2 KarmaBar 解决什么问题KarmaBar 的定位非常明确把 Hacker News 的 Karma 数据从网页中抽离出来变成一个常驻菜单栏的数字。这种工具的好处是显而易见的打开电脑就能看到不需要额外操作。信息密度高菜单栏上显示一个数字不占屏幕空间。可以定时自动刷新保持数据最新。点击状态栏图标可以快速跳转到 Hacker News 或 HN 个人主页。从技术实现角度看KarmaBar 的复杂度也不高。它本质上是一个 macOS 菜单栏应用Menu Bar App核心能力只有两块一是调用 HN 的公开 API 获取用户信息二是把获取到的数据渲染到状态栏上。这也让 KarmaBar 成为学习 macOS 桌面开发非常适合的练手项目。1.3 这类小工具的开发思路在动手写代码之前可以先梳理清楚整体思路。KarmaBar 的核心流程可以用下面这几步概括启动应用注册一个 NSStatusItem创建一个状态栏按钮。在按钮上显示一个默认占位文案比如“HN --”。调用 Hacker News 的 Firebase API传入用户名获取 JSON 数据。解析 JSON提取 karma 字段。回到主线程把 Karma 数值更新到状态栏按钮上。设置一个定时器每隔一段时间比如 10 分钟自动重复上面的请求。这个流程里菜单栏交互部分由 Cocoa/AppKit 框架完成网络请求部分走 URLSession数据解析用 JSONDecoder定时刷新用 Timer。整个项目不涉及复杂的 UI 层级也没有数据库和状态管理非常适合作为 Swift 开发入门项目来实践。2. 环境准备与版本说明2.1 开发环境要求编写 KarmaBar 这类 macOS 菜单栏应用需要一台装有 macOS 的电脑以及 Xcode 开发环境。具体的版本要求并不绝对但为了顺利编译运行建议满足以下条件操作系统macOS 12 或更高版本。Xcode14 或更高版本。Swift 版本Swift 5.7 或更高版本。部署目标macOS 12.0 及以上。需要注意的是上面的版本要求只是推荐值。如果你的环境是旧版本的 Xcode 或 macOS语法上大部分代码仍然兼容只是个别 API 可能略有差异。版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。2.2 项目结构设计为了让代码逻辑清晰我们可以把 KarmaBar 拆分成两个文件KarmaBar/ ├── KarmaBar.xcodeproj └── KarmaBar/ ├── AppDelegate.swift ├── KarmaService.swift ├── main.swift └── Info.plistmain.swift程序入口创建 NSApplication 并设置 delegate。AppDelegate.swift管理状态栏按钮、菜单和定时刷新逻辑。KarmaService.swift负责请求 HN API、解析 JSON、返回 Karma 数值。Info.plist声明应用配置特别是 LSUIElement 参数。采用这种拆分方式后界面层和数据层不会混在一起。后续如果想换成其他数据源或者增加窗口界面只需要在对应模块中修改即可。2.3 准备测试账号在编写代码之前先准备一个有效的 Hacker News 用户名。如果你还没有账号可以去 Hacker News 注册一个。测试时可以把用户名写成常量或者从 UserDefaults 中读取这里先以常量占位为例。需要注意HN 的公开 API 没有身份验证只能查询公开用户信息。Karma 本身是公开数据所以这种读取方式是安全的不需要密码或 Token。3. 核心原理拆解3.1 菜单栏应用的工作原理macOS 的菜单栏应用直观感受是程序运行后不显示 Dock 图标只在顶部菜单栏右侧出现一个小图标或文字。它依赖两个核心类NSStatusBar表示系统全局状态栏通常调用NSStatusBar.system获取单例。NSStatusItem状态栏上的一个具体条目可以设置按钮标题、图片和附加菜单。创建状态栏条目的代码非常简单let statusItem NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem.button?.title HN --variableLength表示条目的宽度由内容自动决定。你也可以使用NSStatusItem.squareLength配合固定尺寸的图标但本文为了显示 Karma 数字使用可变长度更合适。设置好statusItem后可以给它指定一个NSMenu。这样用户点击状态栏图标时会弹出一个自定义菜单常见的菜单项包括“立即刷新”“打开 Hacker News”“退出”等。操作系统的运行机制决定了状态栏应用即使不在前台也会常驻运行。它的生命周期由NSApplication控制无论有没有打开窗口主运行循环都在跑。这意味着定时器、网络请求等任务可以持续执行。3.2 如何让应用只显示在菜单栏普通的 macOS App 启动时会显示一个主窗口并且在 Dock 上出现图标。KarmaBar 这类工具不需要这些我们需要在Info.plist中声明一个关键参数keyLSUIElement/key true/当LSUIElement为true时应用会被标记为Agent (UIElement)类型系统不会在 Dock 栏中显示应用图标也不会主动弹出窗口。这种方式就是菜单栏小工具的标准做法。需要注意的是一旦设置为LSUIElementtrue应用就失去了普通窗口应用的部分交互逻辑。比如没有主菜单没有 Dock 右键菜单所以在AppDelegate中必须自己处理状态栏菜单的构建。3.3 常见菜单栏 App 的技术方案对比实现菜单栏应用不只是 Swift 一种方式。常见的方案还有下面这几种方案技术栈优缺点Swift AppKit原生开发体验最好API 完整代码可读性强需要掌握 AppKit 基础Python rumps脚本开发上手快适合原型打包后体积较大菜单栏表现力有限ElectronWeb 技术界面灵活生态成熟内存占用高不适合轻量小工具SwiftUI MenuBarExtra新框架代码更简洁但需要 macOS 13 支持API 仍在演进对于 KarmaBar 这种轻量工具原生 AppKit 仍然是性价比最高的方案。它不仅依赖少、启动快而且能精确控制状态栏的行为。3.4 HN 公开 API 的数据结构HN 官方提供了一个基于 Firebase 的公开 API不需要注册即可使用。获取用户信息的接口地址如下https://hacker-news.firebaseio.com/v0/user/{username}.json把{username}替换成真实的 HN 用户名访问后返回 JSON 数据。一个典型的响应结构如下{ id: your_username, created: 1434844283, karma: 782, about: , submitted: [123456, 7891011, ...] }其中id用户名。created账号创建时间的时间戳。karma当前 Karma 值这是 KarmaBar 最关心的字段。about个人简介可能为空。submitted用户提交过的内容 ID 列表通常很长。我们只需要在代码中定义一个包含id和karma字段的结构体用JSONDecoder解析即可。其他字段可以忽略。这个接口没有复杂的鉴权逻辑也没有签名参数所以网络请求部分非常简单。但要注意的是接口属于第三方公共服务请求频率不宜太高。KarmaBar 每 10 到 15 分钟刷新一次是比较合理的频率既不会给服务端造成压力也能保证数据基本新鲜。3.5 定时刷新的机制在 AppKit 应用中最常用的定时器是Timer。它有两种典型用法通过Timer.scheduledTimer创建并自动加入当前 RunLoop 的默认模式。手动创建Timer再通过RunLoop.main.add(_:forMode:)添加到指定模式。KarmaBar 只需要一个简单的重复定时器Timer.scheduledTimer(withTimeInterval:repeats:block:)就能满足需求。需要注意一个细节如果用户正在拖拽菜单栏或打开系统菜单RunLoop 会进入事件追踪模式eventTracking默认模式下的定时器可能不触发。对于 KarmaBar 这种非实时刷新工具这个差异影响很小。如果要更稳妥可以把 Timer 添加到.common模式中。4. 完整实战案例手写一个 KarmaBar接下来进入实战环节。我们从头创建一个 macOS 菜单栏应用实现 Karma 追踪功能。本文给出的代码是完整的核心实现你可以直接复制到 Xcode 工程中运行。4.1 创建 Xcode 工程打开 Xcode选择File → New → Project在 macOS 分类下选择App点击 Next。随后配置项目信息Product NameKarmaBarInterfaceSwiftUI 或 Storyboard 都可以LanguageSwift勾选“Use Core Data”不需要取消勾选“Include Tests”创建完成后Xcode 会默认生成一组模板代码。由于我们打算手动管理程序入口所以需要删除模板中的ViewController等文件只保留AppDelegate.swift和Info.plist并新建main.swift。4.2 编写 main.swift在 Swift 中main.swift是程序的入口文件。通过下面的代码手动创建NSApplication并指定 delegate// 文件路径KarmaBar/main.swift import Cocoa let app NSApplication.shared let delegate AppDelegate() app.delegate delegate app.run()这段代码手动创建了应用对象并把AppDelegate设置为委托对象。和常见的main写法相比这种方式更适合菜单栏应用因为它不依赖 Storyboard也不会主动加载主窗口。4.3 实现 AppDelegateAppDelegate是 KarmaBar 的核心负责状态栏按钮、菜单、刷新逻辑和定时器管理。// 文件路径KarmaBar/AppDelegate.swift import Cocoa final class AppDelegate: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem? private var refreshTimer: Timer? // 这里替换成你自己的 HN 用户名 private let hackerNewsUsername your_hn_username func applicationDidFinishLaunching(_ notification: Notification) { setupStatusItem() setupMenu() refreshKarma() startAutoRefresh() } // MARK: - 初始化状态栏 private func setupStatusItem() { statusItem NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem?.button?.title HN -- } private func setupMenu() { let menu NSMenu() let refreshItem NSMenuItem(title: 立即刷新, action: #selector(refreshKarma), keyEquivalent: r) refreshItem.target self menu.addItem(refreshItem) let openItem NSMenuItem(title: 打开 Hacker News, action: #selector(openHackerNews), keyEquivalent: h) openItem.target self menu.addItem(openItem) menu.addItem(NSMenuItem.separator()) let quitItem NSMenuItem(title: 退出, action: #selector(NSApplication.terminate(_:)), keyEquivalent: q) quitItem.target NSApplication.shared menu.addItem(quitItem) statusItem?.menu menu } // MARK: - 刷新与定时器 objc private func refreshKarma() { KarmaService.fetchKarma(for: hackerNewsUsername) { [weak self] result in DispatchQueue.main.async { switch result { case .success(let karma): self?.statusItem?.button?.title HN \(karma) case .failure(let error): self?.statusItem?.button?.title HN Error print(Karma 刷新失败\(error.localizedDescription)) } } } } objc private func openHackerNews() { if let url URL(string: https://news.ycombinator.com) { NSWorkspace.shared.open(url) } } private func startAutoRefresh() { refreshTimer Timer.scheduledTimer( withTimeInterval: 600, repeats: true ) { [weak self] _ in self?.refreshKarma() } } }这段代码里有几个地方值得解释。setupStatusItem创建了状态栏条目初始文案是 “HN --”。setupMenu给状态栏条目附加了一个菜单用户点击后可以看到“立即刷新”“打开 Hacker News”“退出”三个功能。在refreshKarma中我们通过KarmaService.fetchKarma发起网络请求。请求完成后回到主线程更新状态栏文案。为什么需要DispatchQueue.main.async因为 URLSession 的完成回调是在后台线程执行的而 UI 更新必须发生在主线程否则可能出现奇异表现或崩溃。startAutoRefresh创建了一个每 600 秒触发一次的重复定时器也就是每 10 分钟自动刷新一次。这里使用了Timer.scheduledTimer它会自动把定时器加到当前 RunLoop不需要手动管理。需要留意的是NSMenuItem创建时必须设置target。默认情况下菜单项的 action 会沿着响应链查找目标如果 target 为 nilaction 可能无法触发。上面代码中刷新和打开链接的菜单项都把 target 设置为self退出菜单项则把 target 设为NSApplication.shared这样三个菜单项才能正常工作。4.4 编写网络请求层网络请求单独抽成一个KarmaService类这样 AppDelegate 中不直接接触 URLSession 和 JSONDecoder后续如果接口变化只需要改动一个文件。// 文件路径KarmaBar/KarmaService.swift import Foundation enum KarmaServiceError: LocalizedError { case invalidURL case emptyData case invalidResponse var errorDescription: String? { switch self { case .invalidURL: return 用户信息地址无效 case .emptyData: return 接口未返回数据 case .invalidResponse: return 接口响应格式不正确 } } } struct HNUserInfo: Decodable { let id: String let karma: Int } final class KarmaService { static func fetchKarma( for username: String, completion: escaping (ResultInt, Error) - Void ) { let urlString https://hacker-news.firebaseio.com/v0/user/\(username).json guard let url URL(string: urlString) else { completion(.failure(KarmaServiceError.invalidURL)) return } let task URLSession.shared.dataTask(with: url) { data, response, error in if let error error { completion(.failure(error)) return } guard let data data else { completion(.failure(KarmaServiceError.emptyData)) return } do { let userInfo try JSONDecoder().decode(HNUserInfo.self, from: data) completion(.success(userInfo.karma)) } catch { completion(.failure(error)) } } task.resume() } }KarmaService.fetchKarma是一个静态方法逻辑很直白拼接 URL发起请求校验数据解码 JSON最后通过completion回调返回 Karma 值。这里把 JSON 解析失败归类为通用错误。如果用户名不存在HN 的 Firebase API 通常会返回null此时JSONDecoder会抛错回调会返回失败。对 KarmaBar 来说这种情况表现为状态栏显示 “HN Error”用户可以通过菜单里的“打开 Hacker News”去确认用户名是否正确。4.5 配置 Info.plist为了保证应用只显示在菜单栏不出现 Dock 图标和主窗口需要修改 Info.plist。如果你使用的是 Xcode 的 Info 面板可以在 Custom macOS Application Target Properties 中增加Application is agent (UIElement)值设为YES。如果你直接编辑 Info.plist 源码可以看到类似这样的片段?xml version1.0 encodingUTF-8? !DOCTYPE plist PUBLIC -//Apple//DTD PLIST 1.0//EN http://www.apple.com/DTDs/PropertyList-1.0.dtd plist version1.0 dict keyLSUIElement/key true/ /dict /plist这个配置生效后应用不会出现在 Dock 中也不会有默认菜单栏。如果不设置LSUIElement应用启动后依然会显示 Dock 图标虽然状态栏按钮也会出现但体验不够纯粹。在开发调试阶段你可以先不设置这个参数方便从 Dock 强制退出程序等代码稳定后再加上。4.6 编译运行在 Xcode 中选择当前 Scheme点击 Run 或者使用快捷键 Command R。编译成功后应用会启动菜单栏右侧会显示类似HN --的文字。正常情况下刷新成功后文案会变成类似HN 782的格式数字就是当前账号的 Karma 值。点击状态栏文字可以看到下拉菜单。点击“打开 Hacker News”系统默认浏览器会打开新闻首页。如果在编译阶段提示找不到AppDelegate检查一下main.swift中import Cocoa是否已引入以及main.swift是否属于当前 target。Swift 工程中main.swift的位置会影响编译通常放在KarmaBar目录下即可。5. 常见问题与排查思路在开发这类菜单栏应用时有几个问题出现频率很高。下面整理成一张排查表方便读者快速定位。问题现象常见原因解决思路状态栏没有任何图标NSStatusItem未创建成功或应用未启动检查applicationDidFinishLaunching是否执行确认main.swift是否正确启动应用状态栏文字一直是 “HN --”网络请求失败或用户名不正确打开系统日志查看 print 输出确认能访问 HN API确认用户名拼写正确显示 “HN Error”URLSession 请求失败或 JSON 解码失败先确认网络环境再用浏览器访问接口地址检查返回数据菜单点击没有反应菜单项 target 未设置给每个菜单项设置target注意退出菜单项 target 为NSApplication.shared定时器不触发Timer 所在 RunLoop 模式不对或定时器被提前释放用Timer.scheduledTimer创建并把 timer 保存为属性UI 更新卡顿/异常在后台线程更新 UI使用DispatchQueue.main.async包裹 UI 更新代码Dock 中出现图标与预期不符未设置 LSUIElement在 Info.plist 中把LSUIElement设为true5.1 状态栏图标不出现先确认应用是否真的启动了。因为设置了LSUIElement后没有 Dock 图标也不会有窗口看起来就像没有任何反应。可以在applicationDidFinishLaunching中加一行print(App did launch)看控制台是否输出。如果 Xcode 没有显示控制台打开 Window → Devices and Simulators 查看日志或者直接在 Xcode 中观察 Debug Area。5.2 网络请求失败HN API 需要通过 URL 拼接用户名访问。建议先在浏览器中打开下面的地址确认 JSON 能正常返回。https://hacker-news.firebaseio.com/v0/user/{你的用户名}.json如果浏览器能返回 JSON 但应用请求失败可能是网络环境对 App 的请求有限制也可能是证书或代理配置问题。排查时可以先用curl命令测试接口连通性curl https://hacker-news.firebaseio.com/v0/user/your_hn_username.json确保本机网络能访问该接口后再观察代码中的错误回调。为了便于排查建议在 error 分支中输出更详细的描述case .failure(let error): print(刷新失败\(error.localizedDescription))5.3 定时器不触发的陷阱很多初学者把 Timer 创建在局部变量里然后发现它从不执行。原因很简单局部变量方法执行完毕后被释放定时器也被释放了。正确做法是把 Timer 保存为 AppDelegate 的属性并在applicationDidFinishLaunching中创建。这样可以保证定时器与 App 生命周期一致。另外Timer.scheduledTimer默认会添加到当前 RunLoop 的 default 模式。如果应用一直在处理高频率的事件可能导致定时器不准。KarmaBar 的刷新间隔是 10 分钟对精度要求不高不需要额外优化。6. 最佳实践与工程建议6.1 用户名的配置管理在示例代码中Hacker News 的用户名是写死在代码里的。这种方式只适合自己用。如果要把工具发给别人用更好的做法是支持从启动参数或 UserDefaults 读取用户名。例如在 AppDelegate 中读取let username UserDefaults.standard.string(forKey: HNUsername) ?? default_name再配合菜单中的“设置用户名”功能通过一个简单的 NSTextField 输入框保存用户名。这样 KarmaBar 就从一个个人脚本升级成了一个小而完整的工具。6.2 错误处理与用户提示KarmaBar 的状态栏空间很小无法展示完整错误信息。所以错误处理要遵循“状态栏简洁呈现 日志详细记录”的原则。状态栏只显示一个简短标识比如HN Error。出现错误时通过print或 OSLog 输出详细原因。你也可以在菜单中增加一个“最近错误”菜单项点击后展示最后一次失败的原因。无论采用哪种方案都应避免把大段错误文本直接塞进状态栏否则会挤压其他系统图标的空间。6.3 刷新频率与资源消耗菜单栏小工具追求的是低调和稳定。KarmaBar 的刷新频率应控制在合理范围。默认 10 分钟一次即可没有必要缩短到几秒钟。频繁请求还会带来一个实际问题如果你的工具同时被多个用户使用服务器压力会成倍增加。对于公共 API礼貌的做法是控制频率并在代码中增加简单的失败退避逻辑。比如连续失败 3 次后把间隔扩大到 30 分钟直到成功后再恢复正常频率。6.4 菜单栏显示格式状态栏文字建议保持简短。HN 782这种格式是合适的。如果 Karma 数值变大还可以进一步优化例如超过 1000 时显示为1.2k。数值格式化代码很简单func formatKarma(_ karma: Int) - String { if karma 1000 { return String(format: %.1fk, Double(karma) / 1000) } return \(karma) }格式化后状态栏可以显示HN 1.8k看起来更紧凑。6.5 内存管理与生命周期AppDelegate 持有 statusItem 和 refreshTimer引用关系比较清晰。但要注意网络请求回调用到了[weak self]避免闭包对 AppDelegate 造成强引用循环。Timer.scheduledTimer的闭包同样使用[weak self]这样即使后续要提前关闭定时器也不会导致内存泄漏。在应用退出前如果需要主动清理可以添加applicationWillTerminate方法在里面调用refreshTimer?.invalidate()。6.6 打包与分发的注意点如果你打算把 KarmaBar 分享给别人不要直接把Debug模式的产物发出去。更稳妥的做法是在 Xcode 中把 Scheme 切换为 Release。在 Product 菜单下选择 Archive。从 Organizer 中导出应用。对本机用户直接压缩.app发给对方即可。如果要发布到商店或分发给更广泛的用户需要考虑代码签名和公证Notarization。签名和公证需要 Apple Developer 账号步骤较多。如果是个人工具或者项目群里的分享本地构建的.app已经足够用。需要提醒的是未签名应用在别的 Mac 上首次打开时系统可能会提示“无法验证开发者”这是 macOS 的正常安全机制可以引导对方在“系统设置 → 隐私与安全性”中手动允许打开。6.7 单元测试的切入点虽然 KarmaBar 很小但也值得为网络解析部分写单测。最容易测试的代码是 JSON 解码逻辑因为你不必真的发起网络请求只需要准备一段 JSON 数据直接交给JSONDecoder解码。下面是 HNUserInfo 解码的单元测试思路import XCTest final class HNUserInfoTests: XCTestCase { func testDecodeUserInfo() throws { let json { id: test_user, created: 1434844283, karma: 128 } .data(using: .utf8)! let userInfo try JSONDecoder().decode(HNUserInfo.self, from: json) XCTAssertEqual(userInfo.id, test_user) XCTAssertEqual(userInfo.karma, 128) } }给 JSON 解码逻辑加单测成本很低但可以防止后续修改模型字段时不小心破坏了解析逻辑。7. 拓展方向从 KarmaBar 到更多菜单栏小工具KarmaBar 做完之后你可以把同样的模式复制到其他场景中。菜单栏工具的骨架是通用的状态栏条目、菜单、定时刷新、网络请求把这四块组合起来就能实现很多实用功能。例如定时获取某个公开 API 的行情数据在状态栏显示比特币价格或天气温度。监听本机某个文件夹的变化在菜单栏提示新增文件。定时检查某个服务的接口状态在状态栏用不同文字区分正常与异常。读取剪贴板历史通过菜单栏快速选择并复制。KarmaBar 实现中使用的NSStatusItem、Timer、URLSession这些组件在以上场景里都能复用。唯一不同的是数据来源和展示方式。如果你想让菜单栏更美观可以进一步学习如何给NSStatusItem设置图标模板图片Template Image。模板图片会自动适配深色和浅色模式但只能显示单色适合作为小工具图标。要显示数字或文字仍然可以直接操作button.title。如果你想把 KarmaBar 升级成 SwiftUI 版本可以研究 macOS 13 推出的MenuBarExtra场景。它允许用 SwiftUI 声明式地描述菜单栏内容代码更简洁但最低系统版本要求更高。对于自己使用的小工具两种方式都可以接受选择你更顺手的一种即可。写这类工具真正重要的是理解菜单栏应用的运行模型它没有窗口但依然是一个完整的 App 生命周期它显示在系统 UI 上但更新 UI 必须回到主线程它常驻内存所以网络请求频率要克制。把握住这三点你就能把 KarmaBar 改成任意你想要的状态栏小助手。