看到这个标题我有点感慨,你们可能不知道,这个客户端最初只是我每天早上“打开Chrome、点开Gemini网页、复制一串文字再贴回编辑器”这种蠢操作逼出来的东西。来回切了几个月窗口,我终于没忍住,动手写了个macOS原生客户端,现在已经在GitHub上开源了。整个过程并不复杂,但确实有一些选型和实现细节值得聊聊,特别是那些在官方文档里看不到、只有自己写完一遍才有感觉的地方。
这个项目解决的问题很直接:把Gemini从浏览器标签页里解放出来,变成一个原生App该有的样子——有独立的窗口、有全局快捷键、能保存历史记录、不用每次打开都重新加载一遍上下文。适合两类人看:一是自己也动了这个念头、正在纠结技术方案的开发者,二是对SwiftUI和Gemini API感兴趣、想找个中型项目练手的朋友。下面我把整个项目的设计思路、核心实现和踩过的坑从头到尾捋一遍。
1. 为什么搁置了一年多的想法突然落地了
其实想做Gemini客户端的念头很早就有了,但每次一想到“无非是套个WebView”,就失去了动手的欲望。真正让人下定决心的,是几个细节体验反复戳到我。
1.1 网页版和系统级集成的差距,比你想象的大
浏览器里用Gemini最大的问题不是功能,而是“融入度”。举个例子:我在Xcode里写代码遇到报错,想问问Gemini怎么处理,正常路径是把报错信息复制下来、切到浏览器、粘贴、回车,还得等网页把整个对话重新渲染出来。这套流程一天重复十次,每重复一次我就多一分烦躁。
网页版还有两个被大多数人忽略的小毛病:一是长时间挂着之后,页面会悄悄丢失上下文,会话历史一刷新就没了;二是系统的Command+Tab切换、菜单栏图标、快捷键这些macOS原生交互,网页版一个都享受不到。你明明用的是一台Mac,却得用浏览器那一套低效的交互方式去使用一个本该很好用的工具。
1.2 市面上现成的选择,总是差那么一点
动手之前我也把现成的方案都试了一圈,给大家排个雷:
- Electron套壳客户端:能跑,但包体动不动100MB以上,内存占用夸张,开着它等于养了个Chrome在后台。
- Alfred/Raycast插件:呼出很快,但本质上还是个输入框,聊不了几句就得弹回浏览器看完整内容,复杂的多轮对话用起来非常别扭。
- 直接命令行CLI:适合脚本场景,不适合日常交互,输出内容一长就难以阅读,更别提带上下文的多轮对话。
试完这些我反而放心了——这个需求值得写一个真正的原生App。gemini在Mac上的体验之所以一直差了那么一口气,不是没有理由:大家都默认“做个网页就行”,但macOS用户值得更好的东西。
1.3 开源的初心:与其等,不如自己上
既然市面上没有趁手的,那就自己做一个。我给自己定的目标很具体:用Swift原生生写一个真正原生的Gemini客户端,不挂WebView,不依赖Electron,纯SwiftUI + 原生网络请求。第一版从画窗口到能对话大概花了一个周末,后面又花了几个晚上补齐历史记录、快捷键和细节打磨,最终决定直接把整个项目开源。既然这个循环我自己已经受够了,就把它一起解决掉。
2. 技术选型:我为什么坚持用原生Swift,而不是图省事套壳
这是整个项目里最核心的一个决策。很多人会说“macOS开发嘛,不熟悉Swift也能用Flutter/Electron/Tauri”,但从实际体验来看,后果就是从小体变成大胖,处处违和。
2.1 原生和套壳,差距不在视觉,在系统级体验
拿我实测的数据做一个对比(同样是实现一个带历史记录的AI对话窗口):
| 技术方案 | 包体大小 | 空闲内存占用 | 快捷键/全局唤起 | 系统API调用便利度 |
|---|---|---|---|---|
| Electron | 100MB+ | 150MB左右 | 需桥接且不稳定 | 受限较大 |
| Tauri | 包体小 | 60MB左右 | 可桥接但受外壳限制 | 一般 |
| Swift原生 | 10MB左右 | 30MB左右 | 原生NSEvent,顺畅 | 完全开放 |
这不是说我排斥跨平台方案,跨平台适合“一套代码到处跑”的产品,但个人工具客户端最重要的就是顺滑和低占用。原生SwiftUI加上简化后的网络层,启动速度几乎感知不到,这才像一个Mac App,而不是一个“跑在Mac上的网页”。
另一个容易被忽略的点是打包和分发。Electron/Tauri的包体大,签名、公证(Notarization)虽然也能过,但跑起来的感觉始终有点迟钝。Swift原生App做Universal二进制也就十几MB,配合Sparkle更新框架,整个体验接近正规商业App。
2.2 SwiftUI + 原生网络栈,这个组合为什么顺
SwiftUI最大的优势不是写UI快,而是状态管理天然和视图绑定。AI对话这个场景,只需要维护一个消息数组,再用List或ScrollView把消息渲染出来,新增一条消息时SwiftUI会自己去处理视图更新的差异对比。这一点放在UIKit时代需要手写一堆数据源和行高计算。
而和Gemini这样的大模型服务通信,我选择了最直接的URLSession + Async/Await,没有引入Alamofire这类第三方网络库。核心原因是:Gemini API本身就是标准REST接口,请求体是JSON,响应也是JSON流。与其引入一个几百KB的第三方框架,不如直接用系统的网络能力。而且Async/Await写出来可读性强,流式响应也好处理,后面讲API接入的部分你们会看到代码多么清爽。
2.3 让我决定原生开发的另外一个理由:菜单栏
很多人做macOS客户端只关注主窗口,忽略了这个平台独有的“菜单栏常驻工具”生态。我第二版加入菜单栏图标后,整个使用效率上了一级——点一下图标就能快速输入提问,相当于给Gemini配了一个系统级呼出入口。这个功能用Electron实现起来会麻烦很多,而原生App只需要几行NSStatusItem的代码。
3. 核心功能拆解:从对话到记忆,我是怎么设计的
一个AI客户端,听起来功能很简单,但实际拆解之后会发现,需要同时处理好网络通信、数据存储、UI状态管理、系统服务调用这几个层面。我给它划分成了三个核心模块。
3.1 窗口设计:从一个干净的对话界面说起
主窗口没有做得很花哨,就三条区域:左侧是会话列表,中间是消息流,底部是输入框。会话列表把每天的记录按时间归档,支持重命名和删除。输入框支持Command+Enter发送,Enter换行,Shift+Enter也行。界面上唯一的“修饰”是每条消息下面有一个小的复制按钮,方便把回答复制到别处——这是我日常使用中最高频的动作。
UI细节上我最满意的是输入框的自动高度。SwiftUI里的TextField默认是单行,我封装了一个自定义的多行输入视图,根据文本内容自适应高度,最多支持到6行。
3.2 流式输出:让回答一个字一个字蹦出来
用过网页版Gemini的朋友都知道,回答是流式生成的,用户体验会比等待一整段返回自然得多。macOS客户端如果做成“转圈等完再显示”,我觉得就没法用了。我在客户端里用URLSession的异步字节流处理SSE(Server-Sent Events),每收到一个数据块就解析出增量文本,追加到当前正在生成的消息后面。
关键代码如下面这样,我做了简化:
let request = URLRequest(url: endpoint) let (bytes, response) = try await URLSession.shared.bytes(for: request) for try await line in bytes.lines { guard line.hasPrefix("data:") else { continue } let jsonData = line.dropFirst(5).data(using: .utf8) guard let jsonData = jsonData else { continue } // 解析 candidates[0].delta.text 增量文本,追加到当前输出 }这段代码的妙处在于完全不需要自己管理分块状态和连接生命周期,系统帮我把增量数据一行一行送进来,我只要处理每一行的JSON解析。而SwiftUI这边,我只需要维护一个@Published的字符串,每来一段增量就更新这个变量,界面会自动刷新。
3.3 历史记录与本地存储:数据必须留在本机
很多类似的客户端要么不做历史记录,要么把数据扔到云端。我认为AI对话内容属于高隐私数据,全部留在本地更稳妥。存储方案我选的是SwiftData,它底层是Core Data,但API设计现代得多,非常适合SwiftUI项目。
每条消息我只存四个字段:会话ID、角色(user/model)、文本内容、时间戳。查询时按会话ID过滤,按时间排序,一次性加载当前会话的所有消息。考虑到一个会话最多也就几十条,根本不需要分页。
不过有个细节要提醒一下:SwiftData在macOS 14及以上才完整可用,如果你们想兼容旧系统,用SQLite自己管理是更稳妥的路径。我这个项目的最低系统版本直接写的是macOS 14。
4. 接入Gemini API过程中的几个关键决策
这部分是技术含量最高的一块,也是最容易踩坑的地方。Gemini的API体系我已经用了近两年,但把它放进一个桌面客户端里,仍然有几个坑恢复了半天才填平。
4.1 用官方SDK还是自己封装REST请求
Google官方给Swift提供了GenerativeAI SDK,写一个基本的对话请求确实很快。但我在项目里最终选择了自己封装REST请求,理由有三个:
- 官方SDK在流式响应的回调方式上比较折腾,不如直接用Async/Await的bytes流来得清爽。
- 我需要精细控制请求头和连接参数,官方SDK封装得太高级了,反而不好调。
- 少一个依赖,开源项目的构建流程简单很多,别人clone下来跑起来也快。
自己封装也没多复杂,Gemini的接口是标准的POST请求,body长这样:
{ "contents": [ { "role": "user", "parts": [{ "text": "你好" }] } ], "systemInstruction": { "parts": [{ "text": "你是一个助手" }] } }我写了一个GeminiAPI类,统一负责组装请求、配置模型参数、解析响应。整个文件不到200行,阅读和维护都非常轻松。
4.2 模型参数到底开了哪些
Gemini提供多个模型,我在客户端里把它们做了个清晰的区分:
| 模型 | 定位 | 我这里的使用场景 |
|---|---|---|
| gemini-2.0-flash | 快速通用 | 默认对话模型,响应快、日用够 |
| gemini-2.0-flash-thinking | 深度思考(免费额度内可用) | 需要推理、分析问题的场景 |
| gemini-2.0-pro | 高能力大模型 | 长文本/复杂任务,响应较慢 |
默认走flash,界面上留一个下拉框随时切换。参数上我固定了temperature=0.7,这个值在创造力和稳定性之间比较均衡。另外把safetySettings关到了最低档,避免一些正常的代码讨论被过滤——这个也是核心需求。
还有一个细节:Gemini API的上下文窗口是有上限的,连续对话超过一定轮次后最老的记录会被截断。我在客户端做了个机制——当本会话的消息数量超过20条时,自动把更早的消息合并成摘要,以“简要历史”的形式塞进请求里,这样既保留了上下文,又不会顶爆窗口。这个方案虽然不是最优解,但在个人工具场景下已经非常够用。
4.3 API Key的存储与安全边界
这是开源项目里最敏感的一环。几乎所有开发者都会犯的错是:把API Key硬编码在源码里,或写在配置文件里。不管是哪一种,只要你上传到GitHub,Key就等于公开了。
我这里的处理方案是:API Key写入macOS钥匙串(Keychain)。App第一次运行时弹出输入框,Key被安全保存到系统钥匙串中,之后所有请求都从钥匙串动态读取。代码里不出现Key、仓库里不留Key、日志里不打印Key。开源仓库里的README我会明确提醒大家:申请完Key后请妥善保管,不要作为字符串存储在任何文本文件中。
实现时用到了Security框架的SecItemAdd/SecItemCopyMatching这两组API,虽然接口有点老,但胜在稳定可靠,系统级的加密存储能力比任何自定义方案都强。
5. 开源发布前后的感受:那些文档没告诉你的事
代码写完之后,开源本身也有一段路要走。别以为push到GitHub就完事了,实际发布的过程中我遇到了好几个坑,这里逐一说一说。
5.1 代码签名、公证和“已损坏”的诅咒
第一次把编译出来的App发给朋友安装,对方打开时系统弹窗提示“xxx已损坏,无法打开”。这个“损坏”不是真的损坏,而是我没有做App签名和公证(Notarization)。
macOS从Catalina开始强制要求所有App经过签名和公证才能正常打开。本地开发时Xcode会自动签名,但命令行构建的是“ad-hoc签名”,发布到别的机器上就会触发Gatekeeper拦截。
解决方式是在项目里做三件事:
- 在Apple Developer后台创建Developer ID Application证书。
- 用
codesign对App进行签名。 - 用
notarytool提交公证,等待Apple的扫描结果,再把公证票据“钉”到App上。
有一说一,这三步已经比我早期搞开发时省心很多了,notarytool一条命令就能搞定提交。唯一的代价是得花99美元一年注册开发者账号——但这个钱躲不掉,除非你想逼着每个用户去右键“打开”并关掉Gatekeeper,或者永远只在自己机器上用。
5.2 仓库结构:让代码真正可被构建
一个开源项目能不能被别人跑起来,很大程度上取决于README和项目结构。写代码时,我给自己定了几个规矩:
- 根目录放一份构建指南,从Xcode版本到macOS最低版本,全部写清楚。
- Release页提供编译好的dmg包,并对源码构建和直接下载这两种方式做了清晰的说明。
- 不锁依赖——Swfit Package Manager管理依赖,但整个项目除了系统框架几乎不依赖第三方。
- 提供示例配置文件,但把一切内部细节注释得明明白白。
另外,我还配套做了一个简单的自动构建脚本,支持命令行xcodebuild编译和打包。有人提交代码后,直接用脚本构建就能验证有没有改坏。
5.3 关于“不要做什么”的几个声明
开源之后,我收到最多的提问不是“怎么实现”,而是“能不能加某某功能”。这里我想给同样准备开源个人项目的朋友一个建议:一开始就想好项目的边界。我的边界画得很清楚——这是一个本地优先的个人AI客户端,不做账号体系、不做云同步、不做多人协作、不做付费订阅。这个边界感帮我挡掉了大量无意义的需求,也让项目保持了轻量、易维护的特点。
代码库本身我也不希望它膨胀。整个项目控制在6000行左右,每个模块职责单一,任何人打开代码仓库都能一眼看懂结构。
6. 最后想聊的一个坑:菜单栏App的内存管理
写到这里,如果只说选型和大框架,对不起“实操”这两个字。我想把最后一个踩得最久的坑拆开讲,也许能帮到一批做macOS菜单栏工具的人。
6.1 表现:挂机一晚上,内存涨到让人不敢信
客户端放在菜单栏之后,我用了一周,某天无意中看了下活动监视器,发现内存占用从启动时的40MB一路涨到280MB。我第一反应是“Gemini那几十条历史消息不至于这样”,但调试下来发现,问题出在SwiftUI的一个隐蔽行为上:菜单栏图标和主窗口共用同一个进程,只要主窗口曾经被打开过,SwiftUI就会把整个视图状态长期保留在内存里,特别是那些动态构建的文本、图标和列表内容。
6.2 排查和修复:到底谁在吃内存
我用Instruments的Allocations工具逐步排查,最后锁定三个方面:
- 会话列表里每条消息的AttributedString缓存一直留在内存中。
- ScrollView在滚动过大量文本后,系统不会自动释放已经离屏的视图缓存。
- 菜单栏图标的点击弹窗(NSPopover)每次创建新内容时,旧内容没有及时释放。
修法比较朴实:给消息渲染层加了显式复用机制,限制同时存在于内存中的消息视图数量;再在App进入纯菜单栏状态时,主动清理主窗口中离屏的视图缓存。修完之后,内存稳定在50MB上下。
这个问题的启发是:原生并不意味着自动省内存。SwiftUI帮你做了很多自动管理,你以为“用不到了系统自然会回收”,实际上不一定。个人工具可以放任,但既然是拿来日常高频使用的,还是要把“长时运行不膨胀”当成第一优先级。
6.3 一个细节:输入框的焦点管理
再补一个体验层面的提醒。菜单栏工具的弹窗输入框,最容易忽略的是焦点处理。如果按快捷键弹出后输入框没有自动聚焦,用户要抬手去点一下才能打字,体验瞬间拉胯。
我做了一个处理:弹窗出现后用DispatchQueue.main.async包裹一行becomeFirstResponder(),同时把手动设置@FocusState的值在下一帧置为true。只有把这两者结合,才能保证弹出后直接开打,而不会让焦点还停留在上一个窗口里。这种小细节属于“不做永远发现不了,做了不会再想起来”的典型。
最后说一句真实体会:给自己写工具这件事,最值钱的地方不是省下了那点时间,而是在这个过程中对macOS开发、对Gemini API、对SwiftUI特性的掌握,都上了一个台阶。现在再让我回头用网页版,我已经回不去了。项目已经开源,源码在GitHub上可以直接找到,欢迎clone下来自己跑一跑,或者提个PR改进你觉得不够顺手的地方。