先说结论:这次是真的回本了——掏了半年多的 Gemini 订阅费,最后换来一个自己写的 macOS 原生客户端,而且是 MIT 协议开源的那种。项目已经推到 GitHub 上,代码量不大但五脏俱全,支持对话、流式输出、多轮上下文、全局快捷键这些基础功能,日常用完全够了。
如果你也订着 Gemini,又天天泡在 Mac 上,大概率能理解我的动机:网页版用着总觉得隔了一层,聊两句就得切窗口,浏览器开几个标签后连对话面板都找不到。断断续续被人安利过各种第三方客户端,但要么是 Electron 套壳,要么功能过于花哨,真正合手的不存在。与其等别人做,不如自己做。这篇文章就是把来龙去脉、选型思考、踩坑记录,以及开源前后的经验一次性聊透,给同样动过"自己写客户端"念头的人一份参考。
1. 回本账本:订阅费花了大半年之后,我算了一笔账
1.1 先算成本:订阅一年到底多少钱
我用的 Google AI Pro 订阅,具体到个人方案价格不同,以我这边为例,折合下来每个月差不多 20 美元,一年就是 240 美元左右。这个钱花得其实不冤枉,毕竟大模型推理有真实成本,API 按量付费的话,重度使用远不止这个数。
但问题出在"订阅制"上。订阅制最大的特点就是细水长流地扣费,每个月扣 20 刀,不痛不痒,真到年底一拉账单才发现两年多没关的人大有人在。我当时的状态就是典型的订阅麻痹:用的时候觉得挺爽,不用的时候也想不起来关,一年下来小两千块人民币就没了。
1.2 网页版的真实痛点:不是不好用,是不够“贴身”
Gemini 网页版本身做得不差,响应速度快、界面干净,但作为每天都在电脑前写代码、查资料、做笔记的人,我对"贴身"这两个字有执念。什么叫贴身?就是不需要跑到浏览器里找标签页,一个快捷键甩出来就能问,问完 Cmd+W 还能继续手头的事。网页版始终做不到这个体验。
另一个痛点是多任务窗口。写代码时 IDE 占一个屏,浏览器占一个屏,Gemini 再占一个屏——27 寸显示器都被切成豆腐块。这时候如果有一个常驻菜单栏、随时弹出随时收起的小客户端,体验完全不一样。
1.3 算开发成本:60 个小时换回来一个长期省钱的入口
开发这个客户端,我前后花了四个完整周末,加上平时晚上零敲碎打的时间,粗算 60 个小时左右。按自由职业时薪算,这 60 小时并不便宜,但要换个算法就不一样了。
第一,这个客户端替代了网页版的日常使用场景,意味着 API 按量计费的情况下,我可以通过自定义上下文和系统提示词大幅压缩 token 浪费,一个月调用量比网页版省不少。
第二,工具变成自己的之后,想加功能随时加,不用等官方更新。第三,开源之后获得的东西,比如社区反馈、别人的 PR、甚至一点点打赏,都属于订阅费之外的额外回报。
所以这标题说的“回本”,其实不是一个记账意义上的回本,而是指这笔订阅费终于从纯粹消费变成了能持续产生价值的东西。下面我详细说这个价值是怎么一步步做出来的。
2. 技术选型:为什么是 Swift + SwiftUI,而不是 Electron 套壳
2.1 原生和跨平台框架的真实差距
做 macOS 客户端,摆在面前的第一条路就是 Electron 或 Tauri。Electron 生态成熟,代码写起来快,但它有个绕不开的问题:内存占用。我自己电脑上开几个 Electron 应用,16GB 内存的机器都会开始卡。作为 AI 聊天客户端这很致命,因为聊天窗口往往需要长时间挂在那里。
Tauri 比 Electron 轻,但前端技术栈加上系统 WebView 的兼容性问题,在某些 macOS 版本上表现不稳定。做工具类应用,稳定性比开发速度重要得多。
所以最后选了 Swift + SwiftUI。SwiftUI 在 macOS 上的状态管理非常舒服,数据绑定、响应式更新、系统组件风格统一,做这种界面不复杂的工具应用足够顺手。另外它是原生编译,内存占用只有 Electron 方案的零头,启动速度毫秒级,作为菜单栏常驻应用再合适不过。
工具选型这件事,我的原则一直很笨:能用系统自带能力解决的,绝不多引一个依赖。SwiftUI 能满足 90% 的界面需求,剩下 10% 用 AppKit 补,最后第三方库我只用了两个,一个做全局快捷键,一个做 JSON 解析增强。依赖少的好处是项目结构容易理解,别人 fork 之后能快速上手。
2.2 应用形态的取舍:菜单栏常驻还是独立窗口
做 macOS AI 客户端,第一步要决定的就是交互形态。市面上很多客户端做成了独立窗口 App,打开后像微信一样挂在 Dock 栏。这种用法的短板很明显:和网页版没有本质区别,该切窗口还是切窗口。
我做的决定是菜单栏常驻 + 悬浮窗口的形态。点击菜单栏图标弹出一个小面板,快捷键也能随时呼出,失去焦点就自动收起,完美契合"随时问一句就走"的使用习惯。这个模式在 macOS 上属于很成熟的交互范式,很多效率工具都这么干,只是 AI 客户端这么做的还不多。
技术上实现也不复杂,核心就是NSStatusItem+NSPopover。NSStatusItem负责菜单栏图标,NSPopover负责弹出一个无边框面板,点击其他区域时自动失焦关闭。
@main struct GeminiBarApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { Settings { EmptyView() } } } class AppDelegate: NSObject, NSApplicationDelegate { private var statusItem: NSStatusItem? private var popover: NSPopover? func applicationDidFinishLaunching(_ notification: Notification) { statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) if let button = statusItem?.button { button.image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: "Gemini") button.action = #selector(togglePopover) } popover = NSPopover() popover?.contentViewController = NSHostingController(rootView: ChatView()) popover?.behavior = .transient } @objc private func togglePopover() { guard let button = statusItem?.button else { return } if let popover, popover.isShown { popover.performClose(nil) } else { popover?.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) } } }这里有个关键点是popover?.behavior = .transient。.transient模式会让 NSPopover 在用户点击面板外部区域时自动关闭,省去手动管理关闭状态的一大堆代码。很多人写菜单栏应用会忘掉这行,结果弹出窗口关不掉,体验非常糟糕。
2.3 尽可能少的依赖:我用了什么,为什么用它
第三方依赖我只保留了两样。一个是全局快捷键库KeyboardShortcuts,用最简单的方式监听组合键,另一个是SwiftSoup,用来在流式渲染时对文本里的链接和代码块做轻量处理。
这俩库的体量都很小,代码加起来的占比不高,主要解决的是自己写又要踩一堆坑的问题。比如全局快捷键,用 AppKit 的registerHotKey需要处理按键映射、修饰键判断、权限申请等一堆细节,用现成库二十行代码就搞定。
提示:macOS 全局快捷键需要辅助功能权限,代码里调用了
AXIsProcessTrusted()检测后自动跳转系统设置引导用户授权。这个细节体验影响很大,不加权限引导的话,用户装上应用按快捷键没反应,十有八九会以为是 Bug。
3. 核心功能拆解:一个最小可用客户端是怎么长出来的
3.1 第一版:先跑通 API,再想界面
我第一个版本做得极其简陋,就是一个单窗口应用,一个 TextEditor 输入,一个 ScrollView 输出,没有流式,没有多轮上下文。基本结构就是发起 API 请求然后把完整响应丢到一个 TextView 里。
import Foundation struct GeminiMessage: Codable { let role: String let parts: [GeminiPart] } struct GeminiPart: Codable { let text: String } struct GeminiRequest: Codable { let systemInstruction: GeminiSystemInstruction? let contents: [GeminiMessage] } struct GeminiSystemInstruction: Codable { let parts: [GeminiPart] } struct GeminiResponse: Codable { let candidates: [GeminiCandidate] } struct GeminiCandidate: Codable { let content: GeminiMessage }请求调用核心也不复杂:
var request = URLRequest(url: apiURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.httpBody = try JSONEncoder().encode(body) let (data, _) = try await URLSession.shared.data(for: request) let response = try JSONDecoder().decode(GeminiResponse.self, from: data) let replyText = response.candidates.first?.content.parts.map(\.text).joined() ?? ""这里有个容易踩的坑:Gemini API 的返回结构里,parts是一个数组,一个candidate里可能有多个part,如果只取第一个part.text就会丢内容。正确做法是把所有part.text拼起来,我一开始就因此丢过代码块文本。
3.2 流式输出改造:从“转圈等十秒”到“一个字一个字蹦出来”
第一个版本跑通后,我用了一下午就受不了了。聚焦在聊天这种事情上,等待简直是折磨——提问之后盯着空白界面转圈,几秒钟后突然整块文字弹出来,阅读节奏完全被打乱。必须改成流式输出,让模型边想边吐字。
Gemini API 支持stream: true参数,配合SSE(Server-Sent Events)格式返回数据。改造核心是:请求体加stream: true,然后用URLSession.bytes(for:)异步读取字节流,逐行解析以data:开头的内容。
var body = GeminiRequest(...) body.stream = true // 关键 var request = URLRequest(url: apiURL) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.httpBody = try JSONEncoder().encode(body) let (bytes, response) = try await URLSession.shared.bytes(for: request) var fullText = "" for try await line in bytes.lines { guard line.hasPrefix("data: "), line.count > 6 else { continue } let jsonData = line.dropFirst(6).data(using: .utf8)! if let candidate = try? JSONDecoder().decode(GeminiResponse.self, from: jsonData), let text = candidate.candidates.first?.content.parts.map(\.text).joined() { fullText += text // 回调主线程更新 UI await MainActor.run { viewModel.appendToken(text) } } }SSE 解析这块有个坑是跨行 JSON。一般流式接口每个data:行是一个完整的 JSON 切片,但偶尔有粘连情况,用上面的解析逻辑就会漏数据。补丁方案是加一个 buffer,把没有data:前缀的行缓存起来留到下一次一起解析,这个我在第三个版本才修掉。
3.3 多轮上下文管理:不能只做“单次问答”
网页版 Gemini 最方便的地方就是可以连续上下文对话,客户端如果只做单轮问答,价值直接砍掉一半。所以我把多轮上下文管理做成了核心模块。
Gemini API 多轮对话的机制是把整段对话历史都作为contents数组传上去,服务端基于完整上下文生成回复。所以我这边维护一个消息数组:
enum GeminiRole: String, Codable { case user case model } struct ConversationManager { private(set) var messages: [GeminiMessage] = [] mutating func append(role: GeminiRole, text: String) { messages.append(GeminiMessage(role: role.rawValue, parts: [GeminiPart(text: text)])) } mutating func reset() { messages.removeAll() } }每次发请求时,把整个messages数组带上,拿到回复后再把模型回答追加进去。这里面藏着一个重要问题,就是上下文长度管理。
以 Gemini 2.0 Flash 这档模型为例,上下文窗口虽然不小,但对话一长,contents体积变大,API 延迟和费用都会明显增加。所以我加了两个策略:一是超过一定轮数后自动截断最早的对话记录,二是把 systemInstruction 单独设置,用系统提示词把"扮演的角色"固化下来,避免每次都要在对话里重新解释一遍。
注意systemInstruction和普通消息不一样,它放在请求体顶层而不是contents数组里,这是 Gemini API 的规范,很多人会搞错。
3.4 全局快捷键与快速唤起:决定“上手率”的一个小功能
菜单栏图标点击已经比网页版方便了,但我还想更进一步——直接不碰鼠标,从编辑器里组合键呼出。
用KeyboardShortcuts库,注册一个Cmd+Shift+G的快捷键:
import KeyboardShortcuts extension KeyboardShortcuts.Name { static let toggleChat = Self("toggleChat") } // 启动时注册 KeyboardShortcuts.onKeyUp(for: .toggleChat) { NotificationCenter.default.post(name: .toggleChatWindow, object: nil) }这里有一个从个人经验出发的小建议:快捷键设置默认值非常重要。用户打开应用看到设置里已经配好了一个默认快捷键,会觉得这个应用"完整"、值得信赖;如果让用户自己去配置,很多人根本不会走到这一步,功能等于白做。
3.5 流式渲染的细节:代码块和链接的处理
聊天窗口输出的内容,点击即可复制,代码块必须可用鼠标选中复制,链接必须可以直接点击跳转。这里我用AttributedString把文本里的 `` 代码块和http(s)链接渲染成带样式的富文本,同时保证流式输出过程中逐步追加不会卡顿。
实现上要注意一点:SwiftUI 的Text视图在流式更新时,如果频繁重建整个视图树,长文本会出现明显的渲染卡顿。优化手段是限制更新频率——只保留最近 200 个字符的增量更新,历史内容用LazyVStack分块渲染,UI 线程就不会成为瓶颈。
4. 开源之前要做的事:许可证、密钥安全与构建产物
4.1 许可证选型:为什么闭源拿到手里没有“回本”感
项目做完能不能用,和项目愿不愿意给别人用,是两回事。我一开始想闭源自用,后来灵机一动改成开源,也是反复权衡过的。
给项目选许可证时没有纠结太久,直接用了 MIT。MIT 的规则很简单:任何拿到代码的人都可以自由使用、修改、分发甚至商用,只要你保留版权声明。做工具类项目,MIT 能换来的是最大的传播自由度,别人改一版拿去商用,我也没什么损失,反而能把项目影响力扩散出去。
Apache-2.0 比 MIT 多了一条专利授权条款,对大型商业化项目有意义,但个人小项目不太需要。GPL 我完全没考虑,它的强传染性会把很多想借鉴代码的人挡在门外。
4.2 API Key 安全问题:比泄露更可怕的是以为没泄露
这是开源项目最关键的一个环节。代码一旦到 GitHub 上,任何把 API Key 硬编码进代码库的行为都是灾难级的。
我的处理方案:
- API Key 不放进代码,从 macOS 钥匙串读取,默认用
Keychain Access存储,应用在内存里用完即释放 .gitignore里明确排除.env、Config.plist这类文件- 仓库创建后,用
git log和git grep扫一遍历史提交,确认没有明文密码 - README 里写清楚 API Key 获取和配置路径,让使用者自己添加
这里要特别提醒一件事:**很多人只关注当前版本有没有泄露,忘了历史提交里的痕迹。**只要某个 commit 里出现过明文 key,把这个 commit 从分支上删掉是不够的,它还在 object database 里躺着。必须用git filter-repo或类似工具全量重写历史。我发布的仓库从来没有把 key 放进代码,所以不需要走这一步,但这个坑是我真实见过别人踩过的,写出来供参考。
4.3 从能跑到能发布:签名、公证和 Gatekeeper
macOS 应用发布相比 Windows 有一个绕不过去的坎:签名公证。未签名的应用在用户机器上双击会弹"无法打开,因为无法验证开发者身份",体验极差。
这个流程包括几个环节:
- 注册 Apple Developer 账号(个人版一年 99 美元,和订阅费比算是小头)
- 生成 Developer ID Application 证书
- 用
codesign对应用签名 - 用
notarytool提交公证 - 公证通过后给应用打上 staple
签名的命令行大概是这样:
codesign --force --options runtime --sign "Developer ID Application: Your Name (TEAMID)" \ --entitlements MyApp.entitlements \ "build/Release/GeminiBar.app" xcrun notarytool submit "build/Release/GeminiBar.app" \ --apple-id "your@email.com" \ --team-id "TEAMID" \ --password "app-specific-password" \ --wait这个环节我在项目里做了相对轻量的处理:没有做自动签名流水线,而是定期手动签名发布。如果项目复杂度上去,完全可以上 GitHub Actions 配合xcodebuild做自动构建分发,只是现阶段没必要把维护成本抬得太高。
4.4 发布长什么样:README 是第一生产力
开源项目的门面,一个是代码本身,另一个就是 README。我见过太多好项目因为 README 太敷衍而无人问津。我的 README 结构固定为五块:项目简介(一句话讲清楚干什么)、效果截图、功能特性列表、快速开始(从安装到配置 API Key 到跑起来,20 分钟以内能走完全程)、技术栈说明。
最关键的是效果截图,我特意在真实的使用场景下截了几张图,包括菜单栏状态、流式输出过程、暗色模式下的样子。用户决定是不是点开你的仓库,往往就是看这几张图。
5. 开源之后的那些事:Star、Issue 和“隐性回本”
5.1 真实的数据:放了半个月,哪些东西让我意外
项目开源大概半个月后,拿到了两百多个 Star。对大佬项目来说不值一提,但作为个人小工具,我已经很满意了。真正让我意外的不是 Star 数量,而是收到的 Issue 和 PR 内容。
有人提交了一个关于暗色模式下配色对比度不足的改进,用了系统的dynamicProvider做颜色自适应,这个想法我原先偷懒用固定色值绕过了,代码质量确实比我的方案好。
有人建议把流式模式下的文本渲染从Text换成AttributedText实现,这样链接和代码块的解析不用每次增量全量重建富文本——我试了下,响应速度确实提升了一个档次。
这些来自陌生人的改动,比任何打赏和 Star 都更接近我理解的"回本"。订阅费买到的是模型能力,开源换来的是同行脑力。后者才是真正不可替代的价值。
5.2 收到最有价值的一条反馈:上下文管理需要“可视化”
有一个用户提的需求让我印象很深:他想在对话过程中手动指定"保留前几轮上下文",而不是等我自动截断。这确实是我没考虑过的场景——有人喜欢长对话的连贯性,哪怕慢一点也要完整历史;有人只关心最后一轮答案,历史越短响应越快。
现在这个需求变成了一个设置项,用户可以在偏好设置里自己调整上下文保留轮数。这类反馈属于那种"不做之前觉得没必要,做了之后觉得设计本该如此"的改进,也是开源最迷人的地方:你的个人决策会在真实用户手中得到校验。
5.3 别再纠结回本:订阅工具的开发本身就是一种收益
回到标题"回本"这个话题。算完开发时间、订阅费用、开源后的反馈之后,我其实不再纠结回本这件事了。订阅 Gemini 本质上是在购买生产力,开发客户端是把这份生产力内化成自己的工具能力。工具能力一旦形成,就不会随订阅取消而消失,这才是它和纯消费的本质区别。
所以如果你也在用 Gemini 这类服务,而且经常用 Mac 处理文字和代码工作,试试自己写个客户端是很值得的。项目开源在 GitHub 上,搜索 GeminiBar 就能找到,代码结构刻意保持了简洁,想参考的可以直接 fork,想动手改造的也可以随意修改。我自己常用的后续计划是再加语音输入和本地摘要功能,已经列在计划里了。
最后分享一个小经验:这种工具类开源项目,起步阶段最容易犯的错误是一上来就想实现一堆功能。我的做法始终是先写出一个只有输入框和输出文本的最小版本,然后用它替代网页版开始日常使用,把每一次"用着不舒服"变成需求清单,再逐个迭代。对自己真实的使用场景保持敏感,比任何产品规划都管用。