用SwiftUI构建原生Gemini客户端:macOS上的AI对话新体验 看到这个标题我有点感慨你们可能不知道这个客户端最初只是我每天早上“打开Chrome、点开Gemini网页、复制一串文字再贴回编辑器”这种蠢操作逼出来的东西。来回切了几个月窗口我终于没忍住动手写了个macOS原生客户端现在已经在GitHub上开源了。整个过程并不复杂但确实有一些选型和实现细节值得聊聊特别是那些在官方文档里看不到、只有自己写完一遍才有感觉的地方。这个项目解决的问题很直接把Gemini从浏览器标签页里解放出来变成一个原生App该有的样子——有独立的窗口、有全局快捷键、能保存历史记录、不用每次打开都重新加载一遍上下文。适合两类人看一是自己也动了这个念头、正在纠结技术方案的开发者二是对SwiftUI和Gemini API感兴趣、想找个中型项目练手的朋友。下面我把整个项目的设计思路、核心实现和踩过的坑从头到尾捋一遍。1. 为什么搁置了一年多的想法突然落地了其实想做Gemini客户端的念头很早就有了但每次一想到“无非是套个WebView”就失去了动手的欲望。真正让人下定决心的是几个细节体验反复戳到我。1.1 网页版和系统级集成的差距比你想象的大浏览器里用Gemini最大的问题不是功能而是“融入度”。举个例子我在Xcode里写代码遇到报错想问问Gemini怎么处理正常路径是把报错信息复制下来、切到浏览器、粘贴、回车还得等网页把整个对话重新渲染出来。这套流程一天重复十次每重复一次我就多一分烦躁。网页版还有两个被大多数人忽略的小毛病一是长时间挂着之后页面会悄悄丢失上下文会话历史一刷新就没了二是系统的CommandTab切换、菜单栏图标、快捷键这些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调用便利度Electron100MB150MB左右需桥接且不稳定受限较大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 窗口设计从一个干净的对话界面说起主窗口没有做得很花哨就三条区域左侧是会话列表中间是消息流底部是输入框。会话列表把每天的记录按时间归档支持重命名和删除。输入框支持CommandEnter发送Enter换行ShiftEnter也行。界面上唯一的“修饰”是每条消息下面有一个小的复制按钮方便把回答复制到别处——这是我日常使用中最高频的动作。UI细节上我最满意的是输入框的自动高度。SwiftUI里的TextField默认是单行我封装了一个自定义的多行输入视图根据文本内容自适应高度最多支持到6行。3.2 流式输出让回答一个字一个字蹦出来用过网页版Gemini的朋友都知道回答是流式生成的用户体验会比等待一整段返回自然得多。macOS客户端如果做成“转圈等完再显示”我觉得就没法用了。我在客户端里用URLSession的异步字节流处理SSEServer-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界面上留一个下拉框随时切换。参数上我固定了temperature0.7这个值在创造力和稳定性之间比较均衡。另外把safetySettings关到了最低档避免一些正常的代码讨论被过滤——这个也是核心需求。还有一个细节Gemini API的上下文窗口是有上限的连续对话超过一定轮次后最老的记录会被截断。我在客户端做了个机制——当本会话的消息数量超过20条时自动把更早的消息合并成摘要以“简要历史”的形式塞进请求里这样既保留了上下文又不会顶爆窗口。这个方案虽然不是最优解但在个人工具场景下已经非常够用。4.3 API Key的存储与安全边界这是开源项目里最敏感的一环。几乎所有开发者都会犯的错是把API Key硬编码在源码里或写在配置文件里。不管是哪一种只要你上传到GitHubKey就等于公开了。我这里的处理方案是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改进你觉得不够顺手的地方。