
看到标题你们可能以为我开玩笑但我真的很认真算过这笔账过去半年我每个月都在给 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?altsse)! 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 EventsSSE协议数据以data:前缀的 JSON 行返回。Swift 里最自然的做法是用URLSession.shared.bytes(for:)拿到一个异步字节序列然后自己拼行、解析、分发给 UIlet (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 里封装了一个十几行的KeychainStorefunc 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: TaskVoid, 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 靠谱得多也是我把这个项目当主力工具之后摸索出来的最有效率的一种用法。