1. 从“网页标签页里吃灰”到桌面原生:我为什么非要自己写一个 Gemini 客户端
我订阅 Gemini 大概有半年多,说实话,前几个月用得很勤,后面就慢慢变成“想起来才点开”。原因不复杂——我日常写代码、查文档、整理笔记都在 macOS 上,而 Gemini 的网页版始终是浏览器里的一个标签页。标签页这东西,开着占内存,关掉就忘,切来切去还容易和一堆调试页面混在一起。我算过一笔账:订阅费按月扣,但真正高频使用的天数可能连一半都不到,这钱花得有点冤。
后来我动了念头:既然每天都在 macOS 上干活,为什么不做一个原生客户端,把它固定在我的工作流里?于是就有了这个开源项目——一个用 SwiftUI 和 AppKit 搭起来的 macOS 原生 Gemini 客户端。它不是简单的网页套壳,而是真正调用系统能力、贴合 macOS 交互习惯的桌面应用。写完之后我自己的使用频率明显上来了,算是把这笔订阅费“用回本”了。
这篇文章我会把整个开发过程拆开讲:为什么选 SwiftUI 而不是 Electron、原生客户端到底解决了哪些网页版解决不了的痛点、开发中踩了哪些坑、以及如果你想自己动手或者直接拿来用,需要注意什么。适合两类人看:一是想给自己常用的在线服务做个桌面客户端的开发者,二是对 macOS 原生开发感兴趣、想找个真实项目练手的人。哪怕你只是想知道“原生客户端和网页版到底差在哪”,也能从里面找到答案。
2. 原生客户端到底赢在哪:网页版解决不了的四个真实痛点
2.1 全局快捷键与随时唤起,才是高频使用的关键
网页版最大的问题不是功能弱,而是“够不着”。你得先切到浏览器,再找到那个标签页,有时候标签页太多还得翻。原生客户端可以直接注册全局快捷键,比如我设的是Option + Space,无论当前在哪个应用里,按一下就能唤出输入框,问完问题按 Esc 收起,整个过程不打断思路。
这个体验差异是质变的。网页版是“我要去用它”,原生客户端是“它随时在我手边”。我实测下来,同样的功能,有全局快捷键之后我的日均使用次数大概翻了一倍。这不是玄学,是交互路径从“三步”压缩到“一步”带来的自然结果。
2.2 系统级集成:剪贴板、通知、菜单栏一个都不能少
macOS 原生应用能拿到很多网页拿不到的能力。举几个我实际用上的:
- 剪贴板直读:复制一段代码或报错信息,唤出客户端时自动填入输入框,省去手动粘贴。
- 系统通知:长回答生成完毕时发一条通知,我可以先去干别的,好了再回来看。
- 菜单栏常驻:把图标放在菜单栏,点一下就是输入框,不占 Dock 空间。
- 深色模式跟随:自动跟随系统外观,不用手动切换。
这些能力单看都不起眼,但叠在一起,就是“原生感”的来源。网页版受限于浏览器沙箱,这些基本都做不了,或者做得很别扭。
2.3 性能与资源占用:Electron 套壳和真原生的差距
很多人做桌面客户端第一反应是 Electron,毕竟前端技术栈熟悉。但我一开始就排除了这条路,原因很简单:我打开这个客户端是为了少开一个浏览器标签页,结果你给我塞一个内置 Chromium,内存占用比标签页还高,这不是本末倒置吗?
我用 SwiftUI + AppKit 做出来之后,空载内存占用大概在几十 MB 量级,冷启动基本是秒开。而同等功能的 Electron 应用,空载轻松上百 MB,启动还要等运行时初始化。对于我这种会把它常驻在后台的人来说,这个差距是实打实的。
2.4 数据留在本地:会话记录的掌控感
网页版的会话记录存在服务端,你没法方便地导出、备份、搜索。原生客户端可以把会话历史存在本地,用 SQLite 或者直接写文件都行。我现在的做法是本地存一份,支持全文搜索,想找之前问过的某个方案,直接搜关键词就行,不用在网页里一条条翻。
提示:本地存储会话时要注意敏感信息的处理。如果你会把客户端分享给别人用,最好默认不落盘,或者提供加密选项,别把 API Key 和对话内容明文写进文件。
3. 技术选型:为什么是 SwiftUI + AppKit,而不是 Electron 或 Tauri
3.1 SwiftUI 负责界面,AppKit 补足系统能力
纯 SwiftUI 做 macOS 应用,日常界面完全够用,声明式写法开发效率很高。但有些系统级能力 SwiftUI 还没覆盖到,比如全局快捷键、菜单栏额外控制、更细粒度的窗口行为,这时候就得下沉到 AppKit。我的做法是:主界面和大部分交互用 SwiftUI,遇到 SwiftUI 搞不定的地方,用NSViewRepresentable或NSHostingView把 AppKit 组件桥接进来。
这种混合方案的好处是既享受了 SwiftUI 的开发效率,又不被它的能力边界卡住。代价是要理解两套框架的桥接方式,下面我会具体讲。
3.2 和 Electron、Tauri 的横向对比
| 方案 | 内存占用 | 启动速度 | 系统集成 | 开发门槛 | 包体积 |
|---|---|---|---|---|---|
| SwiftUI + AppKit | 低 | 快 | 最完整 | 需 Swift 基础 | 小 |
| Electron | 高 | 慢 | 一般 | 前端即可 | 大 |
| Tauri | 中 | 中 | 较好 | 前端 + Rust | 中 |
选 SwiftUI 的核心理由是:我要的就是“原生”这两个字。如果只是想要个桌面壳,那用什么都行;但既然目标是贴合 macOS 体验,那用系统原生框架是最直接的路。Tauri 其实是个不错的折中,包体积和内存都比 Electron 好,但它的系统集成深度还是不如原生,而且引入 Rust 又增加了一层学习成本。
3.3 网络层与流式响应的处理思路
Gemini 的接口返回是流式的,也就是回答一个字一个字往外蹦。网页版天然支持这种体验,原生客户端要自己处理。我用的是URLSession的bytes异步序列,逐块读取返回数据,解析出增量文本后更新界面。
这里有个关键点:流式更新必须回到主线程刷新 UI,否则会崩。Swift 的@MainActor和async/await配合起来处理这个很顺手。另外要注意 SSE(Server-Sent Events)格式的解析,返回的每一块数据可能包含多个事件,也可能一个事件被拆成多块,得做好缓冲和边界处理,不能假设“一次读取就是一个完整事件”。
4. 动手实现:从零搭起一个能跑的原生客户端
4.1 项目骨架与依赖管理
我用 Xcode 建的是 macOS App 模板,语言选 Swift,界面选 SwiftUI。依赖管理用 Swift Package Manager,没有引入第三方 UI 库,保持干净。整个项目结构大致是:
GeminiClient/ ├── App/ │ ├── GeminiClientApp.swift // 入口 │ └── AppDelegate.swift // 处理全局快捷键等 ├── Views/ │ ├── ChatView.swift // 主对话界面 │ ├── InputBar.swift // 输入框 │ └── MessageBubble.swift // 消息气泡 ├── Services/ │ ├── GeminiService.swift // 网络请求与流式解析 │ └── StorageService.swift // 本地会话存储 ├── Models/ │ └── Message.swift // 数据模型 └── Utils/ └── KeychainHelper.swift // 安全存储 API Key这个结构不复杂,但分层清晰:界面、服务、模型、工具各管各的,后面加功能不会乱。
4.2 全局快捷键的注册与冲突处理
全局快捷键用 AppKit 的NSEvent.addGlobalMonitorForEvents或者更底层的 Carbon 热键 API。我实际用的是后者,因为addGlobalMonitor只能监听,不能拦截,而且有些场景下不触发。Carbon 的RegisterEventHotKey更可靠,但它是 C API,在 Swift 里调用需要处理一些指针细节。
import Carbon.HIToolbox func registerHotKey() { var hotKeyRef: EventHotKeyRef? let hotKeyID = EventHotKeyID(signature: OSType(0x47454D49), id: 1) // "GEMI" let modifiers: UInt32 = UInt32(optionKey) let keyCode: UInt32 = UInt32(kVK_Space) RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef) }踩过的坑:快捷键一定要做冲突检测。我一开始设的Option + Space在某些输入法下会被占用,导致按了没反应。后来加了个设置界面让用户自己改,并且注册失败时给出提示,而不是默默失效。
4.3 流式响应的解析与界面刷新
网络请求部分,核心是构造正确的请求体,然后处理流式返回。Gemini 的接口用的是streamGenerateContent这类端点,返回的是分块数据。我用URLSession.shared.bytes(for:)拿到异步字节序列,逐行读取:
let (bytes, response) = try await URLSession.shared.bytes(for: request) for try await line in bytes.lines { guard line.hasPrefix("data: ") else { continue } let jsonString = String(line.dropFirst(6)) if let data = jsonString.data(using: .utf8), let chunk = try? JSONDecoder().decode(Chunk.self, from: data) { await MainActor.run { self.appendText(chunk.textDelta) } } }这里有几个细节值得说。第一,bytes.lines会按行切分,但 SSE 的数据可能一行里包含多个字段,得按协议解析。第二,空行是事件分隔符,不能忽略。第三,网络中断要有重试和错误提示,不能让界面卡在“正在输入”状态。
4.4 API Key 的安全存储:别写进代码里
API Key 绝对不能硬编码在源码里,尤其是开源项目。我用的是系统 Keychain,通过Security框架读写。这样即使别人拿到你的代码,也拿不到你的 Key。用户第一次打开时引导输入,存进 Keychain,之后从 Keychain 读取。
func saveAPIKey(_ key: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "gemini_api_key", kSecValueData as String: key.data(using: .utf8)! ] SecItemDelete(query as CFDictionary) SecItemAdd(query as CFDictionary, nil) }注意:开源前一定要检查提交历史里有没有误提交过 Key。我见过不少项目在
.gitignore加上配置文件之前,已经把带 Key 的文件提交上去了,后面删掉文件也没用,历史里还在。用git filter-repo之类的工具清理,或者干脆重新初始化仓库。
5. 开发中踩过的坑:那些文档里不会写的细节
5.1 SwiftUI 和 AppKit 桥接时的线程问题
SwiftUI 的视图更新默认在主线程,但如果你从后台线程直接改@Published属性,可能会遇到“Publishing changes from background threads is not allowed”的警告,严重时界面不刷新甚至崩溃。我的做法是所有网络回调统一用@MainActor标注,或者显式DispatchQueue.main.async包一层。
桥接 AppKit 组件时还有个坑:NSViewRepresentable的makeNSView和updateNSView调用时机和你想象的不一样。updateNSView可能在每次状态变化时都被调用,如果你在里面做了重操作,性能会很难看。我的经验是尽量让 AppKit 组件自己管理状态,SwiftUI 只负责传初始值和接收回调。
5.2 窗口管理与菜单栏应用的取舍
我一开始想做纯菜单栏应用,不占 Dock。但实际用下来发现,纯菜单栏应用在需要看长回答时很别扭,窗口太小。后来改成“主窗口 + 菜单栏快捷入口”的混合模式:平时收在菜单栏,需要完整对话时打开主窗口。这样兼顾了随手唤起和完整阅读两个场景。
窗口管理还有个细节:主窗口关闭后,应用不应该退出(macOS 惯例),而是回到菜单栏。这需要在AppDelegate里处理applicationShouldTerminateAfterLastWindowClosed返回false。
5.3 流式输出时的滚动抖动
流式输出时,文本不断追加,如果每次都自动滚到底部,用户想往上翻看历史就会被强行拉回来,体验很差。我的解决方案是:只有当用户当前就在底部附近时才自动滚动,一旦用户手动往上滚,就暂停自动滚动,并在底部显示一个“回到底部”的按钮。
判断“是否在底部”需要拿到NSScrollView的contentView.bounds和documentView的高度做比较。这个逻辑在 SwiftUI 的ScrollView里不太好做,我最后是用ScrollViewReader配合一个状态标记实现的,虽然不完美,但够用。
5.4 打包、签名与分发的现实问题
自己用的话,Xcode 直接跑就行。但要分发给别人,就绕不开签名和公证。没有开发者账号的话,别人下载后会被系统拦截,需要手动在“安全性与隐私”里放行。我开源的是源码,大家自己编译,就绕过了这个问题。如果你要分发二进制,建议老老实实走签名流程,否则用户安装体验会很差。
另外,开源许可证要选清楚。我选的是 MIT,宽松、简单,别人拿去改也好、商用也好,都没负担。如果你希望衍生作品也开源,那就选 GPL 系。这个在项目根目录放个LICENSE文件就行,别省这一步。
6. 开源之后:项目维护与后续可以怎么玩
6.1 开源项目的结构整理与文档
开源不是把代码传上去就完事。我花了不少时间写 README,把“这是什么”“怎么编译”“怎么配置 API Key”“有哪些已知问题”讲清楚。还放了截图和动图,让人一眼知道这东西长什么样。Issue 模板和 PR 模板也配上了,省得后面沟通成本太高。
文档这块我的原则是:假设读者完全不懂 macOS 开发,也能照着步骤跑起来。编译命令、Xcode 版本要求、依赖安装,全部写明白。很多开源项目卡在“跑不起来”这一步,就是因为文档默认读者什么都会。
6.2 可以继续扩展的方向
这个客户端目前是能用的状态,但可扩展的地方还很多。我列几个自己打算做的:
- 多模型切换:现在只接了 Gemini,后面可以抽象一层,支持切换不同服务。
- 会话导出:支持导出成 Markdown 或 PDF,方便归档。
- 提示词模板:常用提问存成模板,一键调用。
- 本地知识库:把常用文档喂进去,做检索增强。
这些都不难,关键是抽象好接口,别把代码写死。我现在的GeminiService就是按协议设计的,后面加新服务只要实现同一个协议就行。
6.3 给想自己动手的人几点实在建议
如果你看完也想给自己常用的服务做个原生客户端,我的建议是:先做最小可用版本,别一上来就追求功能齐全。我第一版只有“输入框 + 发请求 + 显示回答”三个功能,跑通之后再慢慢加全局快捷键、本地存储、菜单栏这些。这样每一步都有正反馈,不容易半途而废。
还有就是,别怕用 AppKit。很多人一听 AppKit 就觉得老、复杂,其实你只需要用到它的几个能力,不用系统学。遇到问题查文档、搜例子,边做边学,比先啃完一本教程再动手效率高得多。
最后分享一个我自己的小习惯:我会把开发过程中遇到的每个坑都记在一个 Markdown 文件里,包括现象、原因、解决办法。这个文件后来直接变成了项目的 FAQ 文档,一举两得。踩坑不可怕,踩完不记才可惜。