看到标题你们可能以为我开玩笑,但我真的很认真算过这笔账:过去半年我每个月都在给 Gemini 交订阅费,累计下来已经是一台入门级 iPad 的钱。为了让这笔钱不再打水漂,我动手写了一个 macOS 原生客户端,把日常高频用的对话、翻译、代码审查全部塞进系统菜单栏,现在项目已经在 GitHub 上开源。这篇文章就把我为什么做、怎么做的、踩了哪些坑完整复盘一遍,给同样在用 Gemini 却觉得网页版不够顺手的人一个参考。
先交代一下我的使用场景。白天大部分时间在写代码,浏览器里开着 Gemini 网页版,经常是聊两句就切到编辑器改代码,改完回来发现页面刷新了,上下文全丢。更尴尬的是,有时候上个厕所回来,标签页被自动回收,刚才讨论到一半的方案直接蒸发。月底打开订阅账单,我盯着那个固定的美元数字,意识到一个残酷的现实:我每天实际使用 Gemini 的时间可能不到十分钟,但每个月的钱一分不少地扣。这已经不是工具问题,是纯粹的财务亏损。
于是我开始研究市面上的替代方案。通用的 AI 客户端试了一圈,Chatbox 这类工具确实能用,但依然觉得别扭。它们大多是跨平台套壳,在 macOS 上内存占用感人,而且对 Gemini 的适配停留在"能对话"层面,模型切换、系统分享菜单、菜单栏快速唤起这些细节完全谈不上。最重要的是,我想要的不是一个聊天框,而是一个能随时呼出、能翻历史、能把 API Key 安全存在本地的工具。既然现成的没有,那就自己写。这篇文章是我整个开发过程的完整记录,包括项目结构、API 对接、原生交互设计、四个最折磨人的 Bug 排查,以及开源之后收到反馈的复盘。
1. 为什么一个被订阅账单"逼"出来的客户端选择原生开发
1.1 先算账:Gemini 的订阅费到底值不值
"回本"这两个字,是我做这个项目最初的动机。每个月的订阅费是固定成本,如果我把使用频率从每天十分钟提升到每天一小时,摊到每次使用上的成本就会大幅下降。但要提升使用频率,光靠"下定决心多用"没用,工具本身必须足够顺滑。
我分析了一下自己为什么用得少。第一,网页版在浏览器里,而我的工作环境有七八个标签页常驻,Gemini 的标签页经常被挤到角落里;第二,页面每次重新加载都要重新建立上下文,感觉像每次都和一个失忆的人聊天;第三,聊天记录散落在浏览器历史里,想翻一个月前的某段代码几乎不可能。这些问题的本质是:Gemini 的对话能力很强,但网页版的产品形态和我的工作流不匹配。
1.2 通用客户端为什么治不了我的痛点
我也认真用过几款通用 AI 客户端,包括 Chatbox 和一些基于开源项目自建的方案。它们解决了一部分问题,比如把多模型 API 集中到一个界面,但依然有几个绕不过去的坎。
- 内存占用过高,Electron 那套东西在 M 系列芯片上虽然能跑,但动不动 500MB 起步,我开着重度项目的时候不想再养一个内存大户。
- 对 macOS 系统集成基本为零,无法用 Spotlight 风格唤起,无法在菜单栏常驻,无法接收系统分享面板的文本。
- 多轮对话的上下文管理很粗暴,很多客户端直接把全部历史往 API 请求里塞,聊久了必报错。
通用客户端追求的是"覆盖所有人",但我的需求是"在 macOS 上把 Gemini 用到极致"。这两个目标天然冲突,所以自己写一个反而是最合理的选择。
1.3 决定自研之后,我给自己定的三个目标
项目的定位从一开始就很清晰,不是做一个 Gemini 的完整替代品,而是做一个高效的前端工具。三个硬性目标:
- 原生体验:SwiftUI 编写,启动快、内存占用低、支持系统快捷键和菜单栏。
- 数据本地可控:API Key 存 Keychain,聊天记录存本地 SQLite,不依赖任何第三方服务。
- 核心场景顺手:对话、翻译、代码片段生成、历史记录搜索,四个功能高频可用。
有了这三条,整个项目的技术选型和架构设计就都有了锚点。后面所有代码层面的决策,都是在回答"这三个目标到底怎么落地"这个问题。
2. SwiftUI + Gemini API:技术选型和工程结构
2.1 跨平台框架和原生方案的实际对比
每个用 macOS 的开发者写工具类应用时,都会在 Electron、Tauri、SwiftUI 之间纠结一遍。我可以直接说结论:如果你的核心诉求是"在 macOS 上把体验做到极致",别犹豫,SwiftUI 是现在最合适的选择。
| 方案 | 内存占用 | 系统集成能力 | 开发成本 | 适合场景 |
|---|---|---|---|---|
| Electron | 高,常驻 400-600MB | 弱,需桥接 | 低 | 快速跨平台 |
| Tauri | 中,约 100-200MB | 中,可调用系统 API | 中 | 轻量跨平台 + Web 前端 |
| SwiftUI | 低,常驻 50-100MB | 强,原生支持菜单栏、快捷键、分享 | 中高 | macOS 专属工具 |
我用 SwiftUI 还有一层考虑:项目后期想加"系统分享菜单直接发送文本到 Gemini"这种功能,用 Electron 得写一堆 Node 桥接,在 SwiftUI 里就是一个ShareLink或NSSharingService的事。既然目标用户就是 macOS 上的我本人,原生是性价比最高的路径。
2.2 工程目录结构:把网络、数据、视图严格分层
项目一开始我就把目录拆得很清楚,避免写成一个巨石 SwiftUI 文件。最终的结构大概是这样:
GeminiMac/ ├── Services/ │ ├── GeminiAPIClient.swift // API 请求、流式解析、错误映射 │ ├── KeychainStore.swift // API Key 安全管理 │ └── TokenEstimator.swift // 上下文长度粗估 ├── Models/ │ ├── ChatMessage.swift // 单条消息模型 │ ├── ChatSession.swift // 会话模型 │ └── AIModel.swift // 模型配置枚举 ├── Stores/ │ ├── ChatStore.swift // 会话与消息状态管理 │ └── SettingsStore.swift // 用户偏好 ├── Views/ │ ├── ChatListView.swift │ ├── ChatDetailView.swift │ ├── StreamingMessageView.swift │ ├── MenuBarPopoverView.swift │ └── SettingsView.swift └── Utilities/ ├── MarkdownRenderer.swift └── DateHelpers.swift在这个结构里,Services层不碰任何 SwiftUI 代码,Stores层负责把网络层的数据转换成 UI 可观察的状态,Views层只负责渲染。这样做的直接好处是:后来我踩到流式输出导致 UI 卡顿的坑时,可以只改ChatStore和StreamingMessageView,完全不用动 API 层。
2.3 为什么 SwiftUI 的成熟度足以支撑这个项目
很多人的刻板印象是 SwiftUI 在 macOS 上还不够成熟。以前确实如此,比如列表性能、滚动控制、多窗口管理都有问题。但 macOS 13 之后,NavigationSplitView、Grid、ScrollViewReader这些组件已经足够稳定,再加上menuBarExtra这个专门给菜单栏应用准备的控件,做一个对话类工具完全没有障碍。
我在项目里还把最低系统版本定在 macOS 13,就是为了用上menuBarExtra和AttributedString(markdown:)这些 API。如果你还在犹豫要不要学 SwiftUI 写 macOS 应用,可以拿这个小项目当参考,它的功能复杂度刚好够覆盖大部分桌面工具的场景。
3. 接入 Gemini 流式对话的完整拆解
3.1 API 请求的基础骨架
Gemini 的接口和 OpenAI 那套不完全一样。最核心的差异在于:Gemini 的内容组织方式是contents数组,每个元素有role和parts,其中role只能是user或model,系统指令单独放在systemInstruction字段里。我在客户端里的封装大概是这样的:
let url = URL(string: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key") struct GeminiRequest: Codable { let contents: [Message] let systemInstruction: SystemInstruction? let generationConfig: GenerationConfig? } struct Message: Codable { let role: String let parts: [Part] } struct Part: Codable { let text: String }认证方式用x-goog-api-key请求头,而不是把 Key 拼在 URL query 上。这样在抓包工具或系统日志里,Key 不会被直接暴露在 URL 中,安全等级高一些。
3.2 流式响应:逐行读 SSE 事件
Gemini 的流式接口走的是 Server-Sent Events(SSE)协议,数据以data:前缀的 JSON 行返回。Swift 里最自然的做法是用URLSession.shared.bytes(for:)拿到一个异步字节序列,然后自己拼行、解析、分发给 UI:
let (bytes, response) = try await URLSession.shared.bytes(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw GeminiError.invalidResponse } var buffer = "" for try await byte in bytes { buffer += String(decoding: [byte], as: UTF8.self) if buffer.hasSuffix("\n") { parseSSELine(buffer) buffer = "" } }parseSSELine里做的事情是:去掉行首的data:,把剩下的 JSON 字符串解析成字典,再从candidates[0].content.parts[0].text取出增量文本。这里有个细节值得注意:流式返回的每个事件里的text只是"新增的那一小段字符",不是完整回复。所以客户端要做的不是替换整个消息内容,而是追加。
func parseSSELine(_ line: String) { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.hasPrefix("data:") else { return } let jsonString = String(trimmed.dropFirst(5)) guard let data = jsonString.data(using: .utf8), let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let candidates = obj["candidates"] as? [[String: Any]], let content = candidates.first?["content"] as? [String: Any], let parts = content["parts"] as? [[String: Any]], let text = parts.first?["text"] as? String else { return } Task { @MainActor in store.appendDelta(text) } }以dispatch的方式把文本增量送到主线程更新界面,这是保证流式输出不卡 UI 的关键一步。
3.3 多轮对话的上下文组织
聊天必然涉及多轮上下文。Gemini 要求按顺序传入user和model交替的contents数组。比如用户问"写一个冒泡排序",模型答了,用户又说"改成从大到小",那第三次请求的 contents 应该是:
[ { role: user, parts: [{ text: "写一个冒泡排序" }] }, { role: model, parts: [{ text: "这是你的冒泡排序代码..." }] }, { role: user, parts: [{ text: "改成从大到小" }] } ]这个逻辑看起来简单,但有一个容易忽略的点:如果上一轮模型回复还在流式输出中,用户就按了"停止"按钮,那这一轮的部分回复不应该进入下一轮请求的上下文。我在客户端里专门做了一层保护,只有"完整跑完且没有被用户中断"的消息才会被标记为可进入上下文。
3.4 上下文超限的粗粒度保护机制
聊到中后段,很常见的问题是上下文爆炸。Gemini 的窗口有上限,超过之后 API 会返回 400 错误,提示内容长度超限。我一开始图省事,把全部历史都塞进请求,然后就被这个错误教育了。
后来我在客户端里加了一个"Token 估算器",没有引入额外的分词库,用的是最简单实用的规则:中文字符和标点按 1 个 token 算,英文按 4 个字符约 1 个 token 算。当估算值超过窗口的 80% 时,就自动把最早的几条消息合并成摘要,用一次单轮请求让模型提炼要点,然后把摘要放进systemInstruction,历史消息里保留最近几轮完整内容。
这个策略的准确率肯定不如专用分词器,但胜在零依赖、性能好。实际用下来,一百轮以内的对话基本不会触顶,触顶时也能平滑过渡到摘要模式。
3.5 错误处理:不是只有网络错误才算错误
流式请求过程中,Gemini 可能在任何一段 SSE 数据里返回错误码。比如 API Key 无效时,HTTP 状态码是 400 或 403;触发限流时是 429;内容触发安全过滤时,candidates里会有finishReason而不是正常的文本。
我的处理方式是先检查 HTTP 状态码,再检查正文里的error字段,最后在流式解析中单独监听finishReason。客户端把这一层统一封装成带中文描述的GeminiError,这样 UI 层不用关心细节,直接展示给用户就行。这一步看起来不起眼,但实际体验差异很大:错误的可读性直接决定了用户是不是觉得这个工具"靠谱"。
4. 那些只有原生客户端才做得到的交互细节
4.1 API Key 必须进 Keychain,而不是 UserDefaults
这是安全习惯问题,也是很多第一次写这类工具的人容易踩的坑。把 API Key 存进UserDefaults看起来方便,但UserDefaults在 macOS 上是以明文 plist 形式存储在用户目录下的,任何能读取该目录的进程都可以把它拷走。更不用说备份到 iCloud 时可能存在同步风险。
我的做法是用系统Security.framework的 Keychain 接口,在 Swift 里封装了一个十几行的KeychainStore:
func save(key: String, value: String) -> Bool { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, kSecValueData as String: value.data(using: .utf8)! ] SecItemDelete(query as CFDictionary) let status = SecItemAdd(query as CFDictionary, nil) return status == errSecSuccess }Keychain 里的数据会由系统负责加密,应用卸载后依然可以选择保留,重新安装时 Key 还在,体验很好。唯一需要注意的是 Keychain Access Group 的设置,如果你打算做沙盒版本,要在 entitlements 里配好keychain-access-groups,否则不同签名版本之间读不到对方的 Key。
4.2 对话历史:SQLite 而不是 JSON 文件
聊天记录这个需求,一开始我用 JSON 文件存,每条消息追加写,简单粗暴。但很快发现问题:历史记录搜索功能需要全量扫描文件,消息一多就卡;边写边崩溃时 JSON 文件可能损坏,恢复成本高。后来我换成了 SQLite,在 Swift 里用 GRDB 做封装,代码量没有增加太多,但搜索性能和稳定性都上来了。
表结构非常简单:
sessions(id, title, created_at, updated_at)messages(id, session_id, role, content, token_count, created_at)
token_count在写入时顺手估算,后面做上下文压缩和会话列表的 token 统计都会用到。搜索走 SQLite 的LIKE查询,千条消息毫秒级返回,体验和当初的 JSON 方案完全不在一个层次。
4.3 菜单栏常驻:用 macOS 13 的 menuBarExtra 做快速入口
"上班摸鱼神器"这个说法虽然调侃,但菜单栏入口确实是提升使用频率的关键设计。macOS 13 之后,SwiftUI 提供了menuBarExtra,可以放一个图标,点击弹出一个小窗口或菜单。我选择弹出的是一个迷你对话窗格,和主窗口共享同一个ChatStore。
这个窗格里可以直接输入问题、回车发送、实时看到流式回复。窗口不抢焦点,输入完回车就能隐藏,完全符合"随手用一下"的场景。实际用下来,我的使用频率从每天几次提升到几十次,回本焦虑基本消失。
4.4 全局快捷键和系统分享菜单的扩展空间
原生客户端的另一个优势是可以注册全局快捷键。我在系统设置里绑定了Command + Shift + G,任何状态下都能唤出菜单栏窗格。这种"随时随地召唤"的感觉,是浏览器标签页永远给不了的。
至于系统分享菜单,目前还是一个规划中的功能。技术上可行,把NSSharingService接上,任何应用里选中文本右键分享到 Gemini 就能实现。我准备在下一个版本里加上,把"翻译选中文字""解释这段报错"变成系统级操作。
5. 踩坑实录:四个让人抓狂的问题和完整排查链路
5.1 流式输出卡成 PPT:问题出在视图更新策略
现象:对话回复很长时,文字出现一卡一卡的效果,CPU 占用飙到 100% 以上,风扇开始起飞。
我的初步怀疑是 SSE 解析太慢,于是给parseSSELine加了时间打点,结果发现解析一行 JSON 只花零点几毫秒,完全不是瓶颈。然后用 Instruments 的 Time Profiler 一看,热点全在 SwiftUI 的视图重算上。
根因找到了:我的ChatStore把整个消息数组做成了@Published,每收到一段增量文本就appendDelta,数组整体发生变化,导致ChatDetailView里所有消息行全部重新计算。回复越长,重算的行越多,卡顿雪上加霜。
修复方案是拆分视图粒度。把正在流式输出的条目标记为isStreaming,专属走StreamingMessageView,这条消息内部的TextView单独绑定一个@State字符串,其他历史消息不参与重绘。同时给增量追加加了一层节流:每 100 毫秒批量提交一次文本变化,而不是一收到字节就刷新。改完之后,长回复的滚动流畅度肉眼可见地提升,CPU 占用降到了 20% 左右。
5.2 连续对话四轮之后,模型突然"失忆"
现象:聊到后面几轮,模型对前面提到过的信息开始含糊其辞,甚至直接说"在我的知识范围内没有提过"。
一开始我以为是模型本身的上下文窗口问题,后来把客户端发出的请求体完整打印出来,发现 contents 数组里确实包含了全部历史消息,数量和顺序都对。但然后我又打印了自己加的 token 估算值,发现早就超过了窗口的 80% 阈值。
真相是:Gemini 在超限时并没有直接报错,而是悄悄截断了部分历史上下文,导致模型"看起来"还在对话,实际已经丢失了早期信息。这种静默截断比直接报错更坑,因为你很难察觉什么时候开始丢的。
修复:实现前面提到的摘要压缩机制。在 token 估算超过阈值后,我把最早的消息用一条单轮请求压缩成摘要,作为systemInstruction注入,然后把最近十轮以内的完整消息继续当上下文。这个方案让长对话的连续性明显改善,而且因为摘要本身就带着关键信息,模型的回答质量反而比硬塞全部历史更高。
5.3 滚轮一翻,回复就把你拽回底部
现象:用户正在往上翻看之前的消息,新回复一进来,ScrollView就自动跳到底部,非常烦人。
排查过程很直接:我在ScrollViewReader里写了proxy.scrollTo(bottomMessageID, anchor: .bottom),放在新消息内容的onChange里,本意是让对话自动跟随最新的回复。但没有判断用户当前是否在阅读历史内容,导致无论用户在哪个位置都会强制滚动。
修复方案是加一个"是否吸附底部"的状态判断。监听scrollPosition,如果当前滚动位置距离底部超过 200 点,就不再自动滚动,直到用户自己滚完历史再看新回复。这个小改动很基础,但也是流式对话应用里逃不开的体验细节。
5.4 内存悄悄涨到 1GB:流式请求的 Task 泄漏
现象:应用跑一两个小时,内存占用从 100MB 慢慢涨到 1GB,明显不正常。
用 Instruments 的 Leaks 检查没有发现传统意义上的内存泄漏,但在 Allocations 里发现大量URLSessionDataTask和DispatchQueue对象残留。顺着这些对象往回查,发现是切换会话时犯的错。
根因:每个会话发起流式请求时,我创建了一个Task { ... }来执行整段请求逻辑。用户切换会话时,旧会话的Task并没有被取消,依然在后台跑着,而且持有旧会话的ChatStore引用,导致整个对象树都无法释放。会话切得越多,残留任务越多,内存自然越来越高。
修复:在会话模型加了一个generationTask: Task<Void, Never>?,发起请求时先oldTask?.cancel(),切换会话和销毁会话时同样取消。在 API 客户端里对CancellationError单独处理,保证取消后不会把残留数据写入当前会话。修复后内存曲线平稳,跑一晚上也就稳定在 80MB 左右。
6. 开源后的真实反馈,以及"回本"到底怎么算
6.1 开源项目的 License 和发布准备
既然决定开源,就不能随手丢一个仓库上去。我选了 MIT License,简单直接,允许别人自由使用和修改,只要你保留版权声明。如果你也在考虑开源一个类似工具,我的建议是:先用 MIT 或 Apache-2.0,别一上来就选 GPL 这种传染性强的协议,除非你明确想让所有衍生项目也必须开源。
发布之前我还做了三件事:
- README 写清楚功能截图、安装方式、API Key 申请入口、FAQ。
- 用 GitHub Actions 自动打包 dmg,每次打 Tag 就触发构建。
- 把项目的代码分层重新整理了一遍,去掉本地调试用的硬编码路径。
这些工作看起来琐碎,但直接决定了开源项目能不能被陌生人用起来。一个打不开、装不上、没说明的仓库,再牛的技术也会被淹没。
6.2 用户反馈里最有价值的几个 Issue
开源之后收到的反馈里,有几条对我启发很大。第一个用户提的问题是"为什么应用提示无法打开",排查之后发现是未签名应用被 Gatekeeper 拦截。解决方案有两个方向:一是完善签名和公证流程,二是指导用户体验"右键 - 打开"或者执行xattr -cr清除隔离属性。开源应用的常见宿命,但至少 README 里要有说明。
第二个有价值的反馈是"希望支持更多 Gemini 模型"。我最初只适配了 gemini-2.0-flash 一个模型,用户在设置界面里选了其他型号直接报错。修复方式是公开模型枚举列表,并让每个模型配置独立支持温度、topP 等参数。这个改动同时把代码的结构也逼得更清晰了。
第三个反馈是"希望导出对话记录为 Markdown"。这个功能不算难,但之前完全没进我的优先级,因为我自己只会用搜索。用户提出来之后我才发现,很多人把对话记录当作知识库,有导出需求。现在已经实现了,一条会话一键导出成一个.md文件。
6.3 回本的真正含义:使用频率才是王道
回到标题里的"回本",我现在的看法已经变了。通过这个项目,我认识到的真正问题是:一个工具的价值,不是由订阅费决定的,而是由使用频率决定的。每月 20 美元的工具,如果每天打开三十次,每次省下三五分钟,那它不仅是回本,是在赚钱。
而这个 macOS 原生客户端,恰恰把使用成本降到了最低。菜单栏一点就能问,历史记录秒搜,多轮上下文不乱丢。这些体验改进叠加起来,让 Gemini 从"偶尔打开的网页"变成了"随时在线的副驾驶"。对我来说,这笔账已经算得很清楚了。
最后分享一个实际使用的小技巧:我会在客户端里维护一组常用的"会话预设",比如周报生成、代码 Review、会议纪要摘要。每个预设对应一个独立会话,系统指令提前写好,快捷键一键唤起。这比临时打一段 Prompt 靠谱得多,也是我把这个项目当主力工具之后摸索出来的最有效率的一种用法。