ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

用SwiftUI打造Mac菜单栏LLM用量监视器:从0到1完整教程

用SwiftUI打造Mac菜单栏LLM用量监视器:从0到1完整教程 前阵子接了一个内部工具需求让工程师在 Mac 上随时看到当前 LLM 账号的 Token 消耗和预估花费。最初想到的是浏览器扩展但实际用下来发现体验不够直接——浏览器不开、页面不打开就看不到数据。最后改成了一款常驻菜单栏的小组件一个小胶囊显示用量数字点击后弹出详细面板既不影响工作流又能随时掌握成本。这篇文章把从 0 到 1 的实现过程整理成完整教程涉及 SwiftUI 的MenuBarExtra、NSStatusItem、网络请求、本地缓存、设置面板等知识点。不管你是 macOS 开发初学者还是已经在做 LLM 工具链的开发者都可以照着这篇文章做出一个属于自己的“Mac 菜单栏 LLM 用量监视器”。1. 为什么要在 Mac 菜单栏显示 LLM 用量1.1 场景与痛点随着日常开发和业务中越来越多地接入大语言模型 APIToken 消耗和费用已经变成实际需要关注的问题。调用次数多了之后账单往往不是立刻可见的等到月底看到费用数字才意识到某些场景的调用量远超预期。这种场景下最理想的状态是用一个非常轻量、不会打断当前工作的小窗口实时展示当前账号的 Token 使用量、调用次数、预估花费等信息。用户扫一眼就能知道“今天用量是否正常”“是不是某个定时任务跑超了”。浏览器扩展的问题在于依赖浏览器常开而且很多浏览器扩展只能拿到页面级数据无法直接展示系统级状态。命令行工具虽然强大但需要主动打开终端去执行不够直观。于是“菜单栏扩展”成为最优解它占用面积极小不会遮挡代码编辑器却能像一个小仪表盘一样常驻在屏幕顶部。1.2 方案选型浏览器扩展还是菜单栏扩展先理清一个概念这里说的 extension 不是 VS Code 扩展也不是 Chrome 扩展而是 macOS 系统上的菜单栏扩展组件。它的本质是一个常驻状态栏的 App 或 Agent最常见的实现方式是 SwiftUI 的MenuBarExtra或者 AppKit 的NSStatusItem。对比浏览器扩展菜单栏扩展的主要优势是不依赖浏览器即使浏览器没有启动菜单栏组件也能工作。系统级常驻开机启动后自动出现在菜单栏无需手动打开。信息密度可控菜单栏里显示一句话摘要点击后才展开详细数据。与系统交互更自然支持快捷键、支持Settings场景、支持通知提醒。典型的应用场景包括 API 成本监控、Token 剩余量提醒、服务健康状态展示、定时任务执行结果提示等。本文实现的组件本质上就是一个“LLM 用量仪表盘”。1.3 本文要实现的最终形态项目标题里的 panel / pill / nub分别对应三种 UI 形态pill菜单栏上的小胶囊按钮显示类似LLM 12.3K这样的简短文字表示当前 Token 总量。panel点击菜单栏按钮后弹出的详细面板展示输入 Token、输出 Token、预估花费、刷新时间等数据。nub菜单栏上的小圆点指示器可以理解为连接状态或数据状态灯。最终效果是菜单栏出现一个小胶囊打开后是一个面板面板中展示详细的 LLM 用量信息。整体交互非常简单但背后涉及的数据链路、缓存策略、网络请求、配置管理都是实际工程中需要认真处理的环节。2. 环境准备与项目初始化2.1 开发环境与版本说明本文示例基于 macOS 13 及以上系统版本。如果系统版本较低SwiftUI 的MenuBarExtraAPI 可能不可用建议改用 AppKit 的NSStatusItem方案实现后文会单独说明。开发工具方面需要准备 Xcode 和 Swift。项目本身不依赖第三方包使用系统自带的 SwiftUI、AppKit、Foundation 框架即可。版本需要根据你的项目实际情况调整。如果当前 Xcode 版本较新Swift 编译器的并发检查会更严格本文代码采用了相对保守的写法尽量兼容不同版本。2.2 创建 SwiftUI 项目打开 Xcode选择File - New - Project选择macOS - App界面语言选择 Swift生命周期选择 SwiftUI App。项目名称可以取为LLMBuddy这样后续代码中的 module 名称和目录结构都一致。创建完成后默认生成的文件结构如下LLMBuddy/ ├── LLMBuddyApp.swift ├── ContentView.swift └── Assets.xcassets本文的示例会在根目录下继续增加 Models、Services、Views 等目录方便代码分层。2.3 开启网络权限与沙盒配置如果项目启用了 App Sandbox需要在 Xcode 的Signing Capabilities中开启Outgoing Connections (Client)否则网络请求会被沙盒拦截出现“请求失败”或“无网络权限”的错误。如果目标是从 App Store 分发沙盒是必选项如果只是自用或企业内部工具也需要在开发阶段确认网络权限已开启。开启方式如下选中 target选择Signing Capabilities点击 Capability添加App Sandbox勾选Network - Outgoing Connections (Client)如果用量查询接口是 HTTP 明文地址还需要在Info.plist中配置 App Transport Security 例外。实际生产环境建议统一使用 HTTPS。3. 核心原理拆解菜单栏组件的三种形态3.1 panel点开后的用量详情面板panel 是用户点击菜单栏按钮后看到的弹层通常是一个小窗口或者 Popover。在 SwiftUI 中可以通过MenuBarExtra的menuBarExtraStyle(.window)实现。MenuBarExtra可以理解成一个专门为菜单栏开发的 SwiftUI Scene。它自带两种展示样式.menu点击后展示一个菜单适合操作型入口。.window点击后展示一个自定义视图适合内容型面板。对于显示用量数据这种场景应该选择.window因为 panel 中需要展示多行数据、状态文字以及可能的操作按钮单一的菜单项不够灵活。在实现中要把弹层视图和菜单栏按钮视图分开。菜单栏按钮是一个很薄的视图只负责显示摘要点击后的 panel 是一个内容更丰富的视图负责承载详细信息。3.2 pill菜单栏上的胶囊按钮pill 是菜单栏上的按钮外观形态。macOS 菜单栏高度有限通常不会像浏览器扩展那样展示大块内容而是用一个小胶囊来承载最基本的信息。一个典型的菜单栏 pill 由以下部分组成图标系统 SF Symbol 或者自定义小图标。简短文本例如12.3K、$2.34、LLM。状态点一个绿色、黄色或红色的小圆点表示数据是否新鲜。可以用 SwiftUI 的Capsule形状配合背景色实现胶囊效果。需要特别注意的是菜单栏的空间非常有限文本不能太长。如果用量数字过大建议格式化显示例如 12345 显示为12.3K。如果同时显示费用和 Token可以只保留一个核心指标其余放在点击后的 panel 中。3.3 nub状态指示灯与数据入口nub 是组件中的“小凸块”或“状态点”它的作用不只是装饰而是快速传达状态信息。可以在菜单栏胶囊中嵌入一个小圆点当数据成功拉取时显示绿色当请求失败或数据过期时显示黄色或红色。用户不需要点击面板只看状态点颜色就能判断“数据是否正常”。这种状态指示机制在运维工具中很常用。菜单栏组件空间有限颜色是传递状态的最经济方式之一。下面这个简单的 SwiftUI 视图可以把图标、状态点、文字组合成一个菜单栏按钮// 文件路径LLMBuddy/Views/StatusBarLabel.swift import SwiftUI struct StatusBarLabel: View { let usage: LLMUsage? let hasError: Bool var body: some View { HStack(spacing: 5) { Circle() .fill(color) .frame(width: 7, height: 7) if let usage usage { Text(usage.displayText) } else { Text(LLM) } } .font(.system(size: 11, weight: .semibold, design: .rounded)) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Capsule().fill(Color.accentColor.opacity(0.12))) .overlay( Capsule().stroke(Color.accentColor.opacity(0.25), lineWidth: 0.5) ) .clipShape(Capsule()) .fixedSize() } private var color: Color { if hasError { return .orange } return usage nil ? .gray : .green } }这段代码中Circle就是 nubCapsule包裹整体形成 pill点击后的详细内容则是 panel。三者组合起来就完成了项目标题中描述的交互形态。4. 完整实战实现 LLM 用量菜单栏小部件下面进入核心实操环节。我们会逐步实现一个可以运行的 macOS 菜单栏 LLM 用量监视器。4.1 项目结构在刚才创建的 Xcode 工程中继续增加以下文件LLMBuddy/ ├── LLMBuddyApp.swift ├── Models/ │ ├── LLMUsage.swift │ └── LLMProviderConfig.swift ├── Services/ │ ├── LLMUsageService.swift │ └── UsageCache.swift ├── Views/ │ ├── StatusBarLabel.swift │ ├── MenuBarView.swift │ └── SettingsView.swift └── Info.plist这个结构把数据模型、网络服务、缓存、视图分离后续即使接入不同的 LLM Provider也只需要替换 Model 和 ServiceUI 层不受影响。4.2 数据模型与配置读取先定义LLMUsage数据模型它表示一次用量查询的结果。为了让代码更通用这里采用了一个假设的 JSON 结构实际项目需要根据自己的服务端或第三方平台的返回格式调整字段。// 文件路径LLMBuddy/Models/LLMUsage.swift import Foundation struct LLMUsage: Codable, Equatable { var inputTokens: Int var outputTokens: Int var totalTokens: Int var costUSD: Double var updatedAt: Date var displayText: String { if totalTokens 1000 { return String(format: %.1fK, Double(totalTokens) / 1000.0) } return \(totalTokens) } } struct LLMUsageResponse: Codable { let inputTokens: Int let outputTokens: Int let totalTokens: Int let costUsd: Double? func toModel() - LLMUsage { LLMUsage( inputTokens: inputTokens, outputTokens: outputTokens, totalTokens: totalTokens, costUSD: costUsd ?? 0, updatedAt: Date() ) } }这里定义了两个类型。LLMUsageResponse是网络层返回的原始结构配合JSONDecoder的keyDecodingStrategy可以直接解析 snake_case 字段LLMUsage是界面展示和缓存使用的统一模型。接下来定义配置读取。API Key、接口地址、刷新间隔这些配置简单场景下可以先存到UserDefaults后面的最佳实践部分会说明更安全的做法。// 文件路径LLMBuddy/Models/LLMProviderConfig.swift import Foundation struct LLMProviderConfig { var apiKey: String var endpoint: URL var refreshInterval: TimeInterval static let defaultEndpoint URL(string: https://api.example.com/v1/usage)! static func loadFromUserDefaults() - LLMProviderConfig { let defaults UserDefaults.standard let apiKey defaults.string(forKey: apiKey) ?? let endpointString defaults.string(forKey: endpoint) ?? let endpoint URL(string: endpointString) ?? defaultEndpoint let rawInterval defaults.double(forKey: refreshInterval) let refreshInterval rawInterval 60 ? rawInterval : 300 return LLMProviderConfig( apiKey: apiKey, endpoint: endpoint, refreshInterval: refreshInterval ) } }注意这里的api.example.com只是示例地址你需要替换成实际的用量查询接口。不同平台的接口路径、鉴权方式、返回结构不一样这一层正是为了屏蔽差异而存在的。4.3 网络请求与用量刷新核心类是LLMUsageService它负责读取配置。发起网络请求。将结果写入缓存。定时刷新。向 SwiftUI 视图发布状态变化。// 文件路径LLMBuddy/Services/LLMUsageService.swift import Foundation import Combine final class LLMUsageService: ObservableObject { Published var usage: LLMUsage? Published var isRefreshing false Published var lastError: String? private var timer: Timer? private let usageCache UsageCache() init() { if let cached usageCache.load() { self.usage cached } let config LLMProviderConfig.loadFromUserDefaults() startAutoRefresh(interval: config.refreshInterval) } func startAutoRefresh(interval: TimeInterval 300) { refresh() timer?.invalidate() let newTimer Timer(timeInterval: interval, repeats: true) { [weak self] _ in Task { MainActor [weak self] in self?.refresh() } } RunLoop.main.add(newTimer, forMode: .common) timer newTimer } func refresh() { guard !isRefreshing else { return } isRefreshing true lastError nil Task { MainActor [weak self] in guard let self else { return } do { let usage try await self.fetchUsage() self.usage usage self.usageCache.save(usage) } catch { self.lastError error.localizedDescription } self.isRefreshing false } } private func fetchUsage() async throws - LLMUsage { let config LLMProviderConfig.loadFromUserDefaults() guard !config.apiKey.isEmpty else { throw LLMUsageError.missingAPIKey } var request URLRequest(url: config.endpoint) request.httpMethod GET request.setValue(Bearer \(config.apiKey), forHTTPHeaderField: Authorization) request.timeoutInterval 15 let (data, response) try await URLSession.shared.data(for: request) guard let http response as? HTTPURLResponse, (200..300).contains(http.statusCode) else { throw LLMUsageError.serverError } let decoder JSONDecoder() decoder.keyDecodingStrategy .convertFromSnakeCase let result try decoder.decode(LLMUsageResponse.self, from: data) return result.toModel() } } enum LLMUsageError: LocalizedError { case missingAPIKey case serverError case invalidResponse var errorDescription: String? { switch self { case .missingAPIKey: return 请在设置中填写 API Key case .serverError: return 服务端返回异常状态码 case .invalidResponse: return 响应格式不正确 } } }这段代码中有几个关键点Published属性用于向 SwiftUI 视图发布变化。startAutoRefresh用一个Timer定时触发刷新RunLoop.main.add时指定了.common模式避免菜单栏被按住时定时器暂停。fetchUsage使用async/await发起请求返回值是统一的LLMUsage模型。解析 JSON 时使用.convertFromSnakeCase这样服务端返回input_tokens时会自动映射为inputTokens。4.4 实现菜单栏视图与弹出面板菜单栏入口使用 SwiftUI 的MenuBarExtra它同时承担 pill 和 panel 的职责。// 文件路径LLMBuddy/LLMBuddyApp.swift import SwiftUI main struct LLMBuddyApp: App { StateObject private var service LLMUsageService() var body: some Scene { MenuBarExtra { MenuBarView() .environmentObject(service) } label: { StatusBarLabel( usage: service.usage, hasError: service.lastError ! nil ) } .menuBarExtraStyle(.window) Settings { SettingsView() .environmentObject(service) } } }这里的关键配置是.menuBarExtraStyle(.window)它让点击菜单栏按钮后显示一个自定义窗口也就是 panel。接下来是MenuBarView也就是点击菜单栏按钮后展示的详细面板// 文件路径LLMBuddy/Views/MenuBarView.swift import SwiftUI import AppKit struct MenuBarView: View { EnvironmentObject private var service: LLMUsageService var body: some View { VStack(alignment: .leading, spacing: 12) { Text(LLM 用量) .font(.headline) if let usage service.usage { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { GridRow { Text(Token 总量) Text(usage.totalTokens.formatted()) .gridColumnAlignment(.trailing) } GridRow { Text(输入 Token) Text(usage.inputTokens.formatted()) .gridColumnAlignment(.trailing) } GridRow { Text(输出 Token) Text(usage.outputTokens.formatted()) .gridColumnAlignment(.trailing) } if usage.costUSD 0 { GridRow { Text(预估花费) Text(String(format: $%.4f, usage.costUSD)) .gridColumnAlignment(.trailing) } } GridRow { Text(更新时间) Text(usage.updatedAt.formatted(date: .omitted, time: .standard)) .gridColumnAlignment(.trailing) } } .font(.system(.body, design: .monospaced)) } else { Text(暂无数据请检查设置或网络) .foregroundStyle(.secondary) } if let error service.lastError { Text(error) .font(.caption) .foregroundStyle(.red) } HStack { Spacer() Button(退出) { NSApplication.shared.terminate(nil) } } } .padding(16) .frame(width: 320) } }Grid是 macOS 13 引入的布局组件非常适合这种对齐要求高的数据展示。如果项目最低版本是 macOS 12可以用HStack或LazyVGrid替代。4.5 设置面板设置面板主要负责配置 API Key、接口地址和刷新间隔。这里先用AppStorage简化实际上线前建议改成 Keychain 保存密钥。// 文件路径LLMBuddy/Views/SettingsView.swift import SwiftUI struct SettingsView: View { AppStorage(apiKey) private var apiKey AppStorage(endpoint) private var endpoint https://api.example.com/v1/usage AppStorage(refreshInterval) private var refreshInterval 300.0 EnvironmentObject private var service: LLMUsageService var body: some View { Form { Section(LLM 服务配置) { TextField(API Key, text: $apiKey) TextField(用量查询接口, text: $endpoint) Stepper(value: $refreshInterval, in: 60...3600, step: 60) { Text(自动刷新间隔\(Int(refreshInterval)) 秒) } } Section(说明) { Text(当前为演示版本API Key 存储在 UserDefaults。正式使用请改为 Keychain 保存。) } Section { Button(立即刷新) { service.refresh() } } } .formStyle(.grouped) .padding(20) .frame(width: 460) } }设置面板中的字段和LLMProviderConfig.loadFromUserDefaults()中的 key 一一对应。这样修改完成后点击立即刷新LLMUsageService会重新读取配置并拉取数据。4.6 运行与验证完成以上代码后在 Xcode 中直接点击 Run。预期效果如下菜单栏出现一个小胶囊左侧有一个小圆点右侧显示LLM或缓存中的用量摘要。首次启动且没有配置 API Key 时点击胶囊后弹出面板显示“暂无数据”以及“请在设置中填写 API Key”。通过Cmd ,打开设置面板填写 API Key 和用量查询接口地址点击立即刷新。请求成功后菜单栏文本更新为类似12.3K的数字面板中显示 Token 明细和更新时间。如果网络请求失败可以在 Xcode Console 中查看lastError的打印内容或者直接看面板中的红色错误提示。5. 常见问题与排查思路菜单栏组件看似简单实际运行时仍然会遇到不少问题。下面整理了几个高频场景。问题现象常见原因解决思路菜单栏没有图标项目没有运行到MenuBarExtra场景或 Xcode 缓存异常确认 target 正确清理 DerivedData 后重新运行点击图标没反应menuBarExtraStyle设置不合适或面板视图崩溃改为.window或.menu测试检查面板视图是否有异常代码一直显示“暂无数据”API Key 未配置、接口地址不对、响应格式不匹配先到设置面板确认配置再用 curl 测试接口网络请求失败沙盒未开启网络权限或 ATS 拦截 HTTP 地址开启Outgoing Connections生产环境使用 HTTPS数据长时间不更新Timer被系统调度延后或刷新逻辑在主线程被阻塞将 Timer 加入.common模式确认请求没有超时返回API Key 明文暴露使用UserDefaults存储密钥改用 Keychain 保存至少也应对密钥做混淆处理菜单栏文案太长被截断胶囊内容超出菜单栏可用空间使用抽象数字格式例如12.3K减少文本宽度如果遇到报错可以参考下面的排查顺序查看 Console 日志中是否有LLMUsageError相关输出。用 curl 直接请求用量接口确认接口本身可访问并观察返回的 JSON 字段。检查 Xcode 的沙盒配置是否开启了网络客户端权限。在MenuBarView中临时加一行Text(service.lastError ?? )方便看到真实错误信息。如果接口返回的字段和模型不匹配优先检查JSONDecoder的keyDecodingStrategy字段名映射。6. 最佳实践与工程建议6.1 密钥管理从 UserDefaults 到 Keychain作为演示前文使用了AppStorage保存 API Key。这个做法开发调试没有问题但真实项目中非常危险。UserDefaults是明文存储任何能读取应用沙盒数据的进程或脚本都有可能拿到密钥。macOS 环境下推荐使用 Keychain 保存密钥。最简单的方式是使用系统自带的Security框架封装一个 KeychainStore。核心思路是写入时使用SecItemAdd或SecItemUpdate。读取时使用SecItemCopyMatching。删除时使用SecItemDelete。虽然代码上比UserDefaults复杂一些但这是保护 API Key 的底线。如果团队成员较多还应该考虑对 Key 做访问控制例如设置为“仅在当前用户登录后可用”。如果暂时不想引入 Keychain最低限度也要避免在日志中打印 API Key。很多请求失败日志会把URLRequest或httpBody打出密钥很容易随日志泄露。6.2 缓存、刷新频率与系统调度菜单栏组件的一个重要使用场景是“开机即显示”。如果每次启动都先发起网络请求用户会看到一段时间的空白状态。更好的做法是启动时先从本地缓存读取上一次的用量数据立即展示。然后异步发起网络请求成功后更新缓存和界面。请求失败时保留旧数据并显示一条淡化的错误提示。缓存文件可以放在Application Support目录下。为了保证缓存的数据结构稳定建议在UsageCache中单独管理编码和解码逻辑。刷新频率也要克制。LLM 用量不是实时计费数据通常 5 到 15 分钟刷新一次足够。不要设置成每秒请求一次否则既浪费服务端资源也容易触发风控限制。另外macOS 在低电量模式下可能会延迟 Timer这是系统行为不要认为程序有 Bug。6.3 多 Provider
返回列表