☰
SwiftUI 自建 macOS 原生 Gemini 客户端:流式输出与全局唤起
2026/10/8 8:58:58 网站建设 项目流程

做这事纯属是为了两个字:回本。我每个月往 Gemini API 里充的钱不少,工作里查资料、写文案、改代码注释都靠它,但算下来真正“用满”的没几天。网页版是方便,可开一堆标签来回切实在有点割裂,临时想拖张截图进去做分析又总觉得笨重。后来我想通了,与其绕着网页转,不如自己写一个 macOS 原生客户端,把常用动作全部收拢到本地,能够随时唤起、随手拖图、聊天记录我自己管。折腾了几周,这客户端已经跑上了日常主力位置,代码也推到 GitHub 开源了,算是把每个月的 API 额度真正榨到一滴不剩。

这篇文章主要分享这个客户端从想法到落地的全过程:为什么放着网页版不用偏偏自造轮子、SwiftUI 原生方案比 Electron 套壳好在哪、以及流式输出、图片入参、全局快捷键这些核心功能是怎么一步步实现的。里面所有踩过的坑、排查过的报错,我都会原样写出来。不管是打算自己写一个 AI 客户端,还是单纯想看看原生 API 接入到底什么体验,这篇都能给你一个可落地的参考。后面如果不想看原理,可以直接跳到第 4 节对着代码改,仓库名就叫gemini-desk,GitHub 上搜一下就能找到。

1. 为什么放着网页版不用,非要自造轮子

1.1 网页版那三个我忍了很久的痛点

先说网页版到底哪里不顺手。第一个问题是上下文割裂。我白天的工作流是:浏览器开着 Gemini 对话页,旁边再开 IDE、笔记软件和几个文档,需要把报错信息、日志片段贴过去问。这个流程本身没什么问题,但来回切换标签、复制粘贴,一天重复几十次以后,体感就很疲劳。而且只要标签页一多,浏览器内存占用就噌噌往上走,我那个 16GB 内存的机器,经常被十几个标签页拖到风扇狂转。

第二个痛点是图片分析能力。网页版虽然支持拖图,但整个交互太“重”了。我想把一张截图直接拖进对话里,还要先切到浏览器、找到对应标签、等输入框响应,有时候拖的位置不对还会误打开图片文件。我都已经为 API 付费了,为什么不在本地做一个更像“桌面软件”的入口,把拖拽、粘贴、快捷键这些系统能力全部用上?

第三个问题,说到底还是数据所有权。网页版聊天记录全部存在云端,我想本地归档、全文检索、备份导出都没有办法。我本身有本地笔记习惯,希望对话是“我的资产”而不是平台里的临时内容。综合这三点,我自己写一个客户端的冲动就变得很强烈了。

1.2 “回本”的账是怎么算的

“回本”听起来像句玩笑,其实是一笔可以认真算的账。Gemini API 是计费的,但不同模型有对应的免费额度层,特别是像gemini-2.0-flash这类轻量模型,每天有一定次数的免费调用,只要你连续使用、不过度集中,就足够日常高强度聊天。我之前一直是浏览器白嫖免费额度,可一到要解析长文档、连续多轮对话时,就会触碰速率限制,体验非常糟糕。

后来我换了思路:把免费额度和付费额度统筹到同一个自己控制的环境中,写客户端统一调度、统一管理上下文,同一段对话里尽量复用contents数组,减少重复输入 token 的浪费。这样做之后,同样的月费,我的实际可用 token 量是原来的好几倍——“回本”说的就是这件事:不是省了钱,而是同样的钱换到了更多真正被用起来的输出。

1.3 为什么不直接用 Electron 套壳

决定自研之后,第一个摆在面前的问题就是用什么样的壳。市面上一堆 AI 客户端,大部分都是 Electron 套壳,调用的是同一个 API,逻辑上没毛病,但我不太想走这条路。Electron 本质上是把一个小型 Chromium 和 Node.js 一起打包,内存占用轻松上 300MB,启动也要一两秒。我做的这个工具定位是“随时唤起、即开即用”,如果每次唤起都要等一个浏览器实例起来,那还不如继续用网页版。

另外一个关键原因是系统集成。原生 App 可以直接调用 macOS 的菜单栏、全局快捷键、文件拖拽、通知中心,甚至辅助功能权限。Electron 做这些事不是说不行,但要多包一层桥接、配置权限更绕。加上我现在每天写 Swift 的时间本来就不少,顺手用 SwiftUI 写界面,用 Swift 并发处理流式请求,整个开发体验反而是最顺的。

2. 技术选型与整体架构:SwiftUI 原生里藏着哪些甜的细节

2.1 原生 vs 跨平台的账面差别,实测差距有多大

我不是做“原生赛高”的信仰党,选 SwiftUI 纯属结果导向。先看一组实测数据:我开发的gemini-desk处于空闲状态时,活动监视器显示内存占用在 120MB 左右,而同类 Electron 套壳产品随便就是 400MB 起步。启动速度就更明显了,原生程序点开 Dock 图标到窗口可用基本在 400ms 内,Electron 冷启动普遍要 1.5 秒以上。单看绝对值,这些数字差距也许不致命,但如果把“全局快捷键唤起对话”这种高频操作放在里面,响应快慢直接决定你愿不愿意用它。

系统集成这边差距更大。原生 App 可以注册NSEvent全局监听,在后台无窗口状态下捕获快捷键;可以接受fileURLs拖拽事件,直接把图片转成 base64 塞进请求;可以调用NSSpeechSynthesizer做本地朗读,不依赖云端 TTS。Electron 生态里每一个能力都有对应模块,但模块更多、中间层更厚,出问题的概率也就更高。你自己维护,不会希望排查成本摊到这么多层里。

2.2 Swift 并发模型怎么适配流式响应

流式对话的体验核心是“打字机效果”——token 一个接一个蹦出来,这个过程天然适合异步序列模型。Swift 的AsyncSequence和AsyncStream正好完美匹配。我用URLSession.shared.bytes(for:)拿到的返回体是一个异步字节流,配合自己封装的 SSE 解析器,可以做到边收边解析边刷新 UI,中间不需要额外的轮询或回调嵌套。

这里最舒服的一点是MainActor隔离。UI 更新必须发生在主线程,而网络回调通常在后台线程。以前用回调闭包,你要手动DispatchQueue.main.async,一旦漏掉一次就 crash。Swift 并发下,我可以在解析函数里直接写await MainActor.run { ... },编译器保证线程切换的正确性。这个语言层面的优势,让整个流式输出的代码量比预期少了一半,也顺带把“更新 UI 时崩溃”这类低级 bug 从根源上消灭了。

2.3 项目三层架构:会话、服务、视图

整个项目我分了三个模块,互相之间靠协议通信,测试起来非常省心。第一层是模型层,定义Conversation、ChatMessage、Part这些数据结构,实现Codable协议,既用于本地持久化,也用于构造 API 请求体。第二层是服务层,核心是一个GeminiService,负责组装请求、解析 SSE、维护上下文数组,提供send(text:)、send(image:)、streamResponse(for:)等接口。第三层才是 SwiftUI 视图层,负责窗口、侧边栏、对话气泡、输入框。

分层之后有一个特别直观的好处:我可以脱离 UI 直接单元测试服务层。把URLProtocol换成 Mock 返回写死的 SSE 数据,就能验证解析逻辑是否健壮。这个测试习惯帮我抓到了好几个跨 data 边界截断 JSON 的 bug,如果全是手工点界面,大概率测不出来。

3. 核心功能实现:每一个细节都藏着一道坑

3.1 流式输出:SSE 解析的正确姿势

Gemini API 的流式响应走的是标准 SSE(Server-Sent Events)格式,大致长这样:

event: message data: {"candidates":[{...}]}

注意这里的data:行是单行的 JSON,但也不绝对——实际返回中偶尔会有很长的行,甚至一个 JSON 对象会被 TCP 分包拆成多段。如果只按行切割字符串,很容易出现“解析到残缺 JSON”直接丢数据的情况。我最后采用的是“逐行 + 缓冲拼接”的双保险策略:

  1. 用bytes.lines按行读取原始字节流。
  2. 不直接处理行内容,先把所有data:前缀的内容追加到一个buffer。
  3. 统一从buffer里尝试解析 JSON,解析成功就消费掉,解析失败说明数据还没完整,留到下一轮继续。
func streamChat(contents: [Content]) async throws -> AsyncThrowingStream<String, Error> { AsyncThrowingStream { continuation in let task = Task { var buffer = Data() let url = URL(string: "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse&key=\(apiKey)")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(RequestPayload(contents: contents)) let (bytes, _) = try await URLSession.shared.bytes(for: request) for try await line in bytes.lines { guard line.hasPrefix("data:") else { continue } let payload = line.dropFirst(5).trimmingCharacters(in: .whitespaces) buffer.append(Data(payload.utf8)) if let json = try? JSONSerialization.jsonObject(with: buffer) as? [String: Any], let candidates = json["candidates"] as? [[String: Any]], let content = candidates.first?["content"] as? [String: Any], let parts = content["parts"] as? [[String: Any]] { let texts = parts.compactMap { $0["text"] as? String } continuation.yield(texts.joined()) buffer.removeAll(keepingCapacity: true) } } continuation.finish() } continuation.onTermination = { _ in task.cancel() } } }

这里有个特别容易被忽略的点:parts是一个数组,模型可能在一次增量里返回多段文本,所以别只取parts.first,要把所有text字段合起来。实战里这不太常见,但一旦出现,漏一段就表现为“生成内容莫名变少”,极难排查。

3.2 多模态输入:拖一张图进来就能聊

macOS 原生对拖拽事件的支持非常顺手,在 SwiftUI 里用.dropDestination(for: URL.self)就能拿到用户拖进来的文件路径。图片的处理链路是这样的:把 Image 转成 JPEG/PNG 数据,再 base64 编码,塞进请求体的inline_data字段。

struct Part: Encodable { let text: String? let inlineData: InlineData? struct InlineData: Encodable { let mimeType: String let data: String } } static func imagePart(url: URL) throws -> Part { let data = try Data(contentsOf: url) let ext = url.pathExtension.lowercased() let mimeType = ext == "png" ? "image/png" : "image/jpeg" return Part(text: nil, inlineData: .init(mimeType: mimeType, data: data.base64EncodedString())) }

图片规格有一个官方建议容易踩坑:Gemini 视觉输入对图片边长有限制,超出阈值后 API 会自动做缩小处理,但压缩比过高时小字会糊掉。我当时拖了一张很长的网页长截图进去,API 返回了 400 错误,排查半天才发现是长宽比太极端。后来我在客户端里加了一步预处理:当图片最长边超过 1024 像素时,先用CGImageSource做等比缩放再提交,不仅避开了报错,响应速度也明显更快。

3.3 会话管理与本地存储:数据是我自己的

会话管理我做得比较朴素,但足够稳。模型层用Conversation和ChatMessage两个Codable结构,每次发送消息时把整个列表序列化成 JSON,写入Application Support目录下的文件。主要数据格式:

struct Conversation: Identifiable, Codable { let id: UUID var title: String var messages: [ChatMessage] var createdAt: Date var updatedAt: Date } struct ChatMessage: Codable, Identifiable { let id: UUID let role: String let parts: [Part] let timestamp: Date }

为什么不放UserDefaults?因为UserDefaults适合存轻量配置,不适合频繁读写大对象,会话多起来性能会明显下降。文件方案的好处是可以直接用mds做 Spotlight 索引,将来想给聊天记录加全文搜索就非常方便。核心 API Key 我放在系统钥匙串里,用SecItemAdd写入,读取时用SecItemCopyMatching。这里提醒一句:千万别把 Key 硬编码进源码或者直接存到普通文件里,只要你的开发机同步过 iCloud,密钥泄露风险就不可控。

3.4 全局快捷键和菜单栏:让工具随叫随到

一个常驻菜单栏的 App,用户体验全看唤起速度。我在菜单栏放了状态项(MenuBarExtra),点击展开一个迷你对话窗口;同时注册了一个全局快捷键⌥Space来唤出主对话窗口。全局快捷键的实现用到了NSEvent.addGlobalMonitorForEvents(matching: .keyDown),监听系统级按键事件。注意全局监听要求 App 具有辅助功能权限(Accessibility),否则拿不到其他应用的前台按键事件,第一次启动时需要在系统设置里手动授权。

这里有个小坑:一旦注册了全局监听,你就要自己处理按键冲突。比如系统输入法切换、其他 App 的快捷键会和你抢⌥Space,这我在第 5 节会细说怎么处理。

4. 从零到开源:完整实操流程记录

4.1 申请 API Key 与模型选择

动手写代码之前,先去 Google AI Studio 控制台申请一个 API Key。里面创建令牌的过程很简单:创建项目、选择要用的模型、生成密钥,几分钟就能搞定。模型我选的是gemini-2.0-flash,理由有两个:一是它速度快,首字延迟明显低于 Pro 系列;二是免费额度更宽裕,日常使用基本不需要额外付费。如果对生成质量要求很高、又有预算,可以在服务层预留一个模型切换接口,压力测试之后再切到 Pro。

申请完 Key,我做的第一件事不是写 UI,而是用curl验证一下整个请求链路。

curl -X POST \ "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${API_KEY}" \ -H "Content-Type: application/json" \ -d '{ "contents": [{ "parts": [{ "text": "用一句话介绍你自己" }] }] }'

这一步能快速定位问题:如果返回 401,说明 Key 本身无效;如果返回 400,多数是请求体内字段拼错了。链路通了再写客户端,后面每段代码都有明确的预期结果,调试成本至少减一半。

4.2 项目骨架:Xcode 工程里的那些基础配置

Xcode 新建一个 macOS App 项目,生命周期选 SwiftUI App,然后做几件必要配置。第一,把Info.plist里的ATS(App Transport Security)设置加上,允许本地 HTTP 访问,因为我调试阶段会用本地 Mock 服务模拟 SSE 响应。第二,App Sandbox默认是开启的,如果你计划让用户拖入任意路径的图片,需要开启User Selected File读权限,否则沙箱会拦截所有外部文件访问。第三,也是容易被忽略的一项,要在Signing & Capabilities里打开App Groups或者至少设置一个App Sandbox容器标识,方便后续把会话数据存储到正确目录。

这些配置看起来琐碎,但每一条背后都是一个真实事故。我第一版没开文件访问权限,打包出来自己用没问题,换一台机器测试就发现所有拖图都静默失败,日志里只有一句 “Operation not permitted”,排查了一小时才想到沙箱。所以开局花两分钟把权限理顺,后期省下的时间十倍都不止。

4.3 发布与开源:签名公证和 GitHub Release

本地跑通只是第一步,真正让这个客户端成为“产品”的是打包分发。macOS 要求所有分发到其他机器的 App 执行签名和公证,否则对方首次运行时会看到“已损坏,无法打开”的提示。签名用codesign,公证用notarytool,前者给 App 加上你的开发者身份,后者把应用上传到 Apple 的公证服务做安全检查。如果你没有 99 美元一年的开发者账号,也有替代方案:在工程设置里把签名改成Sign to Run Locally,然后在自己的机器上右键打开,但仍然会有 Gatekeeper 的弹窗提醒。开源项目的用户需要自行选择信任该应用。

# 签名 codesign --force --deep --sign "Developer ID Application: Your Name (TEAMID)" \ --options runtime gemini-desk.app # 打包 zip 并提交公证 ditto -c -k --sequesterRsrc --keepParent gemini-desk.app gemini-desk.zip xcrun notarytool submit gemini-desk.zip --apple-id "you@example.com" \ --team-id TEAMID --password "app-password" --wait # 公证书贴到 App 上 xcrun stapler staple gemini-desk.app

开源发布我放在 GitHub 仓库,名叫gemini-desk。仓库里除了源码,我还写了一份README,里面包含功能截图、安装步骤、API Key 申请入口,以及一条注意事项:请把 Key 当作自己的密码保管,不要直接分享给别人。发布之后就陆续有人提 PR,有加本地历史检索的,有补充多语言界面的,这是最初写代码时完全没想到的额外回报。

5. 常见问题与排查技巧实录

5.1 流式输出偶尔断线,首 token 迟迟不出

表现有两种:第一种是网络请求发出去之后,界面一直停在“思考中”转菊花,很久不出第一个字;第二种是生成到一半突然停住,finishReason还没到就断流了。前者大概率是请求体太大或者网络超时设置不对。URLSession默认没有请求超时,但你可以显式设置:

let config = URLSessionConfiguration.default config.timeoutIntervalForRequest = 30 config.timeoutIntervalForResource = 300

后者多是并发任务被取消。我踩过的具体坑是在视图销毁时,Task的onTermination触发了整个请求的 cancel,切窗口的瞬间对话就断了。解决方式是:把流式请求抽到服务层独立执行,视图层的生命周期最多控制 UI 更新,不要直接终结网络任务。

5.2 图片传上去返回 400 Bad Request

这个报错我遇到的次数最多,基本都出在图片预处理环节。三种常见原因:一是 base64 字符串没拼对,inline_data.data必须是原始图片数据的 base64,不能带data:image/png;base64,前缀;二是 MIME 类型不匹配,PNG 图片写成了image/jpeg,API 很可能直接拒绝;三是请求体超过大小限制,长截图、高分辨率大图很容易触发 20MB 上限。我在这块的排查思路很直接:先用curl单独构造一个只含单张图片的请求,验证成功后再回到客户端排查,大概率能快速定位到是哪一步出的问题。

5.3 全局快捷键在部分应用里不生效

全局监听看上去是系统级的,但实际体验和焦点应用有很大关系。某些应用,比如游戏、虚拟机、视频播放器,会独占键盘事件,全局监听也拿不到。我自己遇到的情况是:在 Visual Studio Code 里按⌥Space偶尔会被它自己的快捷键拦截。我最后的处理方式是,把监听逻辑改成“按下后延迟 80ms 再判断”,给其他应用一个优先响应机会,然后如果触发失败,还可以用菜单栏图标点击兜底,不至于彻底失灵。辅助功能权限也有一个隐藏坑:升级 macOS 之后,权限会被重置,需要重新去系统设置里打开,建议在设置页提供一个状态检测按钮,指引用户直接跳转到对应设置栏。

5.4 用户下载后提示“无法打开,因为来自身份不明的开发者”

这是所有 macOS 开源项目发布者都会遇到的问题。解决靠公证和签名,但如果你自签名给自己的测试机用,别人拿到后依然要手动在“系统设置 -> 隐私与安全性”里点“仍要打开”。我不可能替每个用户做这一步,只能在 README 里写清楚安装流程。后来我还做了另一件事:额外发布一份dmg镜像,并在镜像里附带安装说明,把首次打开的弹窗截图放到文档里,这个细节直接减少了很多安装相关的 issue。

5.5 免费额度怎么分配才能“回本”

最后的实操问题,怎么让 API 额度真正被榨干。我的经验是把工作流分三类:长文总结、代码调试、日常问答分配到不同的会话里。日常问答用gemini-2.0-flash免费额度,长文总结单独开付费 Pro 用量;每个会话尽量把上下文压缩到刚够用的长度,而不是无限堆砌历史,既省 token 又降低延迟。这样算下来,一个月总调用次数比纯网页版至少多两倍,每月的 API 账单反而没有涨,这就是“回本”的真面目。

最后再说几句实在话

做这个客户端最意外的收获不是“省了多少钱”,而是把一个依赖网页的工具真正变成了自己桌面的原生公民。拖图进去就能聊、按快捷键就能唤起、聊天记录全在本地,这种掌控感是纯网页流程给不了的。如果你也每天高频使用大模型,我建议不要只停留在“用别人工具”这一步,哪怕只是照着开源项目改一改快捷键、加一个自己习惯的提示词预设,都会让你对“工具”这两个字有完全不同的认知。

最后分享一个小技巧:维护这个项目的过程中,我养成了一个习惯——每修完一个 bug,顺手把它写进 README 的“常见问题”一节。别小看这几行文档,它后来成了这个仓库 star 数增长的主要推手之一。开源项目的价值不只在代码,一段干净准确的排错指南,对陌生人的帮助可能比代码本身还大。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询