ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SwiftUI 自建 macOS 原生 Gemini 客户端:流式输出与全局唤起

SwiftUI 自建 macOS 原生 Gemini 客户端:流式输出与全局唤起 做这事纯属是为了两个字回本。我每个月往 Gemini API 里充的钱不少工作里查资料、写文案、改代码注释都靠它但算下来真正“用满”的没几天。网页版是方便可开一堆标签来回切实在有点割裂临时想拖张截图进去做分析又总觉得笨重。后来我想通了与其绕着网页转不如自己写一个 macOS 原生客户端把常用动作全部收拢到本地能够随时唤起、随手拖图、聊天记录我自己管。折腾了几周这客户端已经跑上了日常主力位置代码也推到 GitHub 开源了算是把每个月的 API 额度真正榨到一滴不剩。这篇文章主要分享这个客户端从想法到落地的全过程为什么放着网页版不用偏偏自造轮子、SwiftUI 原生方案比 Electron 套壳好在哪、以及流式输出、图片入参、全局快捷键这些核心功能是怎么一步步实现的。里面所有踩过的坑、排查过的报错我都会原样写出来。不管是打算自己写一个 AI 客户端还是单纯想看看原生 API 接入到底什么体验这篇都能给你一个可落地的参考。后面如果不想看原理可以直接跳到第 4 节对着代码改仓库名就叫gemini-deskGitHub 上搜一下就能找到。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 的流式响应走的是标准 SSEServer-Sent Events格式大致长这样event: message data: {candidates:[{...}]}注意这里的data:行是单行的 JSON但也不绝对——实际返回中偶尔会有很长的行甚至一个 JSON 对象会被 TCP 分包拆成多段。如果只按行切割字符串很容易出现“解析到残缺 JSON”直接丢数据的情况。我最后采用的是“逐行 缓冲拼接”的双保险策略用bytes.lines按行读取原始字节流。不直接处理行内容先把所有data:前缀的内容追加到一个buffer。统一从buffer里尝试解析 JSON解析成功就消费掉解析失败说明数据还没完整留到下一轮继续。func streamChat(contents: [Content]) async throws - AsyncThrowingStreamString, 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?altssekey\(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里的ATSApp 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 youexample.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/jpegAPI 很可能直接拒绝三是请求体超过大小限制长截图、高分辨率大图很容易触发 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 数增长的主要推手之一。开源项目的价值不只在代码一段干净准确的排错指南对陌生人的帮助可能比代码本身还大。
返回列表