资讯动态

Gemini Desktop:SwiftUI+AppKit混合开发macOS原生客户端实战

发布时间:2026/10/9 4:15:00 来源:尧图企业网站定制
1. 为什么我要给 Gemini 做一个 macOS 原生客户端用 Gemini 有一段时间了网页版体验其实不差但每天高频使用下来总有几个点让人如鲠在喉。浏览器标签页越开越多切来切去效率极低每次想快速问一句都要先找到那个标签页再等页面加载更别提有时候网络波动网页端直接卡住连个重试按钮都点不动。我算了一笔账如果每天因为切换、等待、重载浪费十分钟一年下来就是六十多个小时。这个时间成本对于一个靠工具吃饭的人来说实在有点肉疼。于是我就想能不能做一个 macOS 原生客户端把 Gemini 的核心对话能力直接搬到桌面上启动快、常驻 Dock、全局快捷键呼出、对话历史本地留存这些在网页端很难做到的事情原生应用可以轻松实现。更重要的是macOS 原生应用可以深度调用系统能力比如菜单栏快捷入口、通知中心提醒、剪贴板无缝衔接这些都是浏览器标签页给不了的体验。这个项目我命名为Gemini Desktop技术栈选的是SwiftUI AppKit混合方案目前已经在 GitHub 上开源。它解决的问题很明确让 Gemini 的重度用户有一个更顺手、更稳定、更符合 macOS 操作习惯的入口。适合谁用如果你每天都要和 Gemini 打交道又恰好是 Mac 用户那这个客户端就是为你准备的。哪怕你只是偶尔用用原生应用的启动速度和响应体验也会让你回不去网页版。提示本文所有内容基于我个人开发实践整理涉及的具体实现细节可能因系统版本和开发环境不同而有差异建议以实际调试结果为准。2. 技术选型为什么是 SwiftUI 加 AppKit 而不是纯 SwiftUI2.1 纯 SwiftUI 的局限性在哪里SwiftUI 这几年的进步有目共睹声明式语法写起来确实爽界面代码量比 AppKit 少一大截。但真到了要做一款生产力工具的时候纯 SwiftUI 的短板就暴露出来了。首先是窗口管理SwiftUI 的 WindowGroup 和 Window 场景在 macOS 上虽然能用但想要精细控制窗口层级、实现悬浮窗、自定义标题栏拖拽区域就力不从心了。其次是菜单栏集成MenuBarExtra 虽然提供了基础能力但要做动态菜单项、右键菜单、状态图标切换还是得回到 AppKit 的 NSStatusItem。还有一个很现实的问题NSTextView 和 SwiftUI TextEditor 的差距。Gemini 的对话内容经常包含代码块、Markdown 格式、长文本SwiftUI 自带的 TextEditor 在渲染富文本时性能堪忧滚动卡顿、选中困难、复制粘贴格式丢失这些问题在实测中反复出现。而 AppKit 的 NSTextView 经过几十年打磨处理这些场景稳得多。2.2 混合方案的具体分工我的做法是界面骨架用 SwiftUI关键组件用 AppKit 包装。具体来说主窗口的布局、侧边栏、设置面板这些用 SwiftUI 写开发效率高对话列表和输入框用 NSViewRepresentable 包装 NSTextView保证文本处理的稳定性和性能菜单栏图标和全局快捷键用 AppKit 的 NSStatusBar 和 NSEvent 监听实现。这种混合方案的好处是既享受了 SwiftUI 的声明式开发效率又在关键路径上保留了 AppKit 的成熟能力。代价是需要处理两种框架之间的数据同步比如 SwiftUI 的 State 和 AppKit 的 delegate 回调怎么衔接。我的经验是把 AppKit 组件封装成独立的 NSViewRepresentable 结构体通过 Binding 和 Coordinator 与 SwiftUI 状态通信这样边界清晰维护起来不头疼。2.3 为什么不用 Electron 或 Tauri有人可能会问为什么不用 Electron 或者 Tauri 这种跨平台方案答案很简单性能和原生体验。Electron 打包出来的应用动辄上百兆启动慢、内存占用高对于一个常驻后台的对话工具来说这是不可接受的。Tauri 虽然轻量但 macOS 上的 WebView 渲染和系统集成能力跟原生 SwiftUI 还是有差距。更重要的是我做这个客户端就是为了解决网页版的卡顿和切换问题如果再用一个 WebView 套壳那跟浏览器标签页有什么区别原生应用的优势在于它可以做到秒启动、低内存、深度系统集成。实测下来Gemini Desktop 的冷启动时间在 0.8 秒左右常驻内存约 80MB比开一个 Chrome 标签页还轻。这种体验上的差异只有真正用过原生应用的人才能体会。3. 核心功能拆解从对话到管理的完整链路3.1 对话界面的设计要点对话界面是这个客户端的核心我花了最多时间打磨这块。整体布局参考了主流聊天应用的思路左侧是会话列表右侧是消息流底部是输入框。但针对 Gemini 的特点做了几个针对性优化。消息气泡的渲染是第一个难点。Gemini 的回复经常包含 Markdown 格式代码块、列表、加粗、链接都有。我用 NSTextView 配合 NSAttributedString 来渲染自己写了一个轻量的 Markdown 解析器把常见的语法转换成对应的富文本属性。代码块用等宽字体加背景色区分链接用系统蓝色并支持点击打开浏览器。这个过程踩了不少坑比如行高计算、段落间距、复制粘贴时的格式保留后面会详细说。流式输出的处理是第二个难点。Gemini 的回复是逐字返回的网页端看起来是打字机效果。在原生应用里我用 URLSession 的 dataTask 配合 delegate 回调每收到一段数据就追加到 NSTextView 的文本存储里同时滚动到底部。这里要注意的是频繁更新 UI 会导致卡顿我的做法是加一个 50 毫秒的节流把短时间内的多次更新合并成一次实测下来流畅度提升明显。3.2 会话管理的实现逻辑会话管理看起来简单做起来琐碎。每个会话需要保存标题、创建时间、最后更新时间、消息列表还要支持重命名、删除、搜索。我用SQLite做本地存储通过 FMDB 这个轻量级封装来操作。为什么不用 Core Data因为 Core Data 的模型定义和迁移机制对于这种简单结构来说太重了SQLite 直接写 SQL 更直观调试也方便。会话标题的生成有个小技巧取用户第一条消息的前二十个字符作为默认标题如果第一条消息太短就等第二条消息再生成。这样既保证了标题的可读性又避免了空标题的尴尬。搜索功能用的是 SQLite 的 LIKE 查询对标题和消息内容做模糊匹配响应速度在毫秒级。3.3 全局快捷键与菜单栏集成全局快捷键是提升效率的关键。我注册了Option Space作为呼出快捷键无论当前在哪个应用按下就能唤出 Gemini Desktop 的输入窗口。实现方式是调用 Carbon 的 RegisterEventHotKey虽然 Carbon 框架已经老旧但在全局快捷键这个场景下它比 NSEvent 的全局监听更稳定、权限问题更少。菜单栏图标用的是 NSStatusItem左键点击弹出快速输入面板右键点击显示菜单包含“打开主窗口”“新建会话”“偏好设置”“退出”等选项。菜单栏图标还支持动态切换当有未读回复时显示一个小红点提醒用户查看。这个功能在写代码或者查资料的时候特别实用不用切窗口就能知道 Gemini 有没有回复完。4. 实操过程从零搭建项目的关键步骤4.1 项目初始化与依赖管理创建 macOS 项目的第一步是在 Xcode 里选择App模板界面选择SwiftUI语言选Swift。项目创建好后我做了几件事在 Build Settings 里把 Deployment Target 设为 macOS 13.0因为 SwiftUI 的某些 API 在更早版本上不可用在 Signing Capabilities 里开启App Sandbox和Hardened Runtime这是上架 App Store 的前提虽然我目前只做本地分发但提前配好没坏处。依赖管理用的是Swift Package Manager没有用 CocoaPods 或 Carthage。SPM 的好处是跟 Xcode 集成度高不需要额外安装工具Package.swift 文件也清晰。我引入的依赖很少主要是 FMDB 用于数据库操作其他功能都尽量用系统框架实现减少第三方依赖带来的维护成本。4.2 网络请求层的封装Gemini 的 API 调用是核心链路我封装了一个GeminiService类负责处理请求构造、发送、流式接收和错误处理。请求体是 JSON 格式包含模型名称、消息列表、生成参数等字段。这里要注意的是API Key 不能硬编码在代码里我的做法是存在 Keychain 里首次启动时引导用户输入后续从 Keychain 读取。流式接收的实现细节值得展开说。我用 URLSession 的dataTask(with:completionHandler:)方法在 completionHandler 里拿到的是完整数据但流式输出需要边收边显示。所以改用URLSessionDataDelegate的urlSession(_:dataTask:didReceive:)回调每次收到数据块就解析并追加到界面。解析的时候要注意数据块可能不是完整的 JSON需要维护一个缓冲区按行分割遇到完整的 JSON 对象再处理。func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { buffer.append(data) while let range buffer.range(of: Data(\n.utf8)) { let lineData buffer.subdata(in: 0..range.lowerBound) buffer.removeSubrange(0..range.upperBound) if let json try? JSONSerialization.jsonObject(with: lineData) as? [String: Any] { handleStreamChunk(json) } } }4.3 本地存储的表结构设计SQLite 的表结构我设计了三个表sessions存会话元信息messages存消息内容settings存用户偏好。sessions 表包含 id、title、created_at、updated_at 字段messages 表包含 id、session_id、role、content、created_at 字段通过 session_id 外键关联。settings 表用键值对存储方便扩展。建表语句在应用首次启动时执行用CREATE TABLE IF NOT EXISTS保证幂等。索引方面给 messages 表的 session_id 和 sessions 表的 updated_at 分别建了索引这样查询某个会话的消息列表、按更新时间排序会话列表时速度会快很多。实测下来即使存了几千条消息查询响应也在 10 毫秒以内。4.4 界面与数据的绑定SwiftUI 的数据流用ObservableObject配合Published属性实现。我创建了一个AppState类持有当前会话列表、当前选中的会话、消息数组等状态。视图通过StateObject或ObservedObject订阅这些状态状态变化时自动刷新界面。这里有个细节要注意NSTextView 的更新不能直接依赖 Published因为 AppKit 组件不参与 SwiftUI 的刷新机制。我的做法是在 AppState 里加一个messageUpdatePublisher用 Combine 的 PassthroughSubject 发送更新事件NSTextView 的 Coordinator 订阅这个事件收到后手动更新文本内容。这样既保持了数据流的统一又兼顾了 AppKit 组件的特殊性。5. 踩坑记录与排查技巧实录5.1 流式输出卡顿的优化过程最开始实现流式输出的时候每收到一个字符就更新一次 NSTextView结果界面卡得没法看。用 Instruments 分析后发现频繁的文本存储修改触发了大量的布局计算CPU 占用率飙升到 80% 以上。解决办法是加节流用一个 Timer 每 50 毫秒检查一次是否有新内容有的话批量追加。这样把更新频率从每秒几十次降到二十次CPU 占用降到 5% 以下流畅度反而更好。另一个优化点是滚动到底部的时机。如果每次追加内容都滚动用户往上翻看历史消息时会被强行拉回底部。我的做法是判断当前滚动位置如果用户已经手动滚动到非底部区域就暂停自动滚动等用户回到底部再恢复。这个细节虽然小但体验提升很明显。5.2 API Key 存储的安全考量API Key 直接写在代码里或者存在 UserDefaults 里都是不安全的。UserDefaults 是明文存储任何能访问用户目录的程序都能读到。我的方案是用Keychain Services通过 SecItemAdd、SecItemCopyMatching 等 API 存取。Keychain 的数据是加密的而且可以设置访问控制只有本应用能读取。代码实现上我封装了一个KeychainHelper结构体提供 save、read、delete 三个方法。需要注意的是Keychain 操作是同步的如果在主线程调用可能会阻塞 UI所以我把读写操作放到后台队列执行通过回调返回结果。另外Keychain 的数据在应用卸载后不会自动清除需要在应用退出时主动清理或者提供“清除 API Key”的选项。5.3 常见问题速查表问题现象可能原因排查方法解决方案应用启动后闪退缺少必要的权限声明查看 Console.app 的崩溃日志在 Info.plist 中添加对应权限描述流式输出不显示URLSession 配置错误打印 delegate 回调是否触发检查 URLSessionConfiguration 是否设置了 delegate消息列表滚动卡顿单元格高度计算频繁用 Instruments 的 Time Profiler 分析缓存高度计算结果避免重复计算API 请求返回 401API Key 无效或过期检查 Keychain 中的 Key 是否正确重新输入 API Key确认账户状态数据库写入失败数据库文件被锁定检查是否有多个实例在运行确保单实例运行加文件锁保护全局快捷键失效与其他应用冲突在系统设置中查看快捷键占用更换快捷键组合或提供自定义选项5.4 几个容易被忽略的细节窗口关闭行为需要特别注意。macOS 应用点击关闭按钮默认是销毁窗口但用户往往期望应用继续在后台运行。我的做法是重写windowShouldClose方法返回 false 并隐藏窗口这样应用继续驻留菜单栏下次点击图标能快速恢复。这个行为跟系统偏好设置里的“关闭窗口时退出应用”选项要联动给用户选择权。深色模式的适配也是个体力活。SwiftUI 的 Color 和 Material 大部分能自动适配但自定义的颜色和图片需要提供两套资源。我的做法是尽量用系统语义颜色比如Color(nsColor: .textColor)、Color(nsColor: .windowBackgroundColor)这样系统切换外观时自动跟随。对于必须自定义的颜色用 Asset Catalog 配置 Any/Dark 两套值。多显示器场景下窗口位置的处理也值得一说。如果用户把窗口拖到外接显示器下次启动时窗口应该出现在上次的位置。我用 NSWindow 的setFrameAutosaveName方法系统会自动保存和恢复窗口位置。但要注意如果外接显示器断开了恢复的位置可能在屏幕外需要加一个判断检测窗口是否在可见区域内不在的话就居中显示。6. 开源后的收获与后续计划6.1 开源社区反馈的几个亮点项目开源后收到了不少有价值的反馈。有人提出支持自定义 API 端点这样可以对接兼容 Gemini 协议的其他服务有人建议增加对话导出功能把会话导出成 Markdown 或 JSON 格式还有人反馈输入框的快捷键支持不够完善比如 Command Enter 发送、Shift Enter 换行这些习惯操作需要补齐。这些建议我都记在了 Issues 里按优先级逐步实现。最让我意外的是有用户把项目移植到了 iOS 上通过 Mac Catalyst 或者直接改 SwiftUI 代码在 iPhone 和 iPad 上跑了起来。虽然我最初只针对 macOS 优化但 SwiftUI 的跨平台能力确实让移植成本降低了不少。这也给了我新的思路后续可以考虑做真正的多平台版本用同一套核心逻辑针对不同平台做界面适配。6.2 性能优化的持续迭代性能优化是个没有尽头的事情。目前我在关注几个方向启动速度还能再压一压通过延迟加载非关键资源、预编译常用视图来实现内存占用在长时间运行后会缓慢增长怀疑是 NSTextView 的文本存储没有及时释放需要进一步排查电池续航方面常驻菜单栏的应用要尽量减少后台活动把定时器和网络请求的频率降下来。还有一个想法是引入本地缓存机制把常用的对话内容缓存在内存里减少数据库查询次数。但缓存失效策略要设计好否则会出现数据不一致的问题。我倾向于用NSCache它自带内存压力响应系统内存紧张时会自动清理比手动管理省心。6.3 给想自己动手的人几点建议如果你也想做一个类似的 macOS 客户端我的建议是先从最小可用版本开始。不要一上来就想着做完整功能先把“能发消息、能收回复、能显示在界面上”这条链路跑通然后再逐步加会话管理、快捷键、菜单栏这些周边功能。这样每完成一个阶段都有成就感也不容易因为摊子铺太大而放弃。多读 Apple 的官方文档尤其是 SwiftUI 和 AppKit 的混合编程部分。网上很多教程要么太旧要么只讲纯 SwiftUI遇到混合场景就抓瞎。官方文档虽然枯燥但准确性最高遇到问题先查文档再去社区搜索能少走很多弯路。善用 Instruments它是排查性能问题的利器。Time Profiler 看 CPU 热点Allocations 看内存分配Leaks 看内存泄漏Network 看请求耗时。我前面提到的流式输出卡顿问题就是靠 Time Profiler 定位到文本存储更新的开销才找到节流这个解决方案的。最后保持耐心。原生开发涉及的东西很杂SwiftUI、AppKit、Core Data、Keychain、URLSession每个都有坑。遇到问题不要慌把错误信息复制出来搜索大概率有人遇到过类似的情况。实在解决不了就去 Stack Overflow 或者 Apple Developer Forums 提问把复现步骤和错误日志写清楚通常很快能得到回复。这个项目我会持续维护后续计划包括支持多模型切换、增加对话模板、优化长文本渲染等。如果你在使用中遇到问题或者有好的想法欢迎在 GitHub 上提 Issue 或 PR。工具这东西越用越顺手越改越好用希望 Gemini Desktop 能成为你日常工作中离不开的那个小助手。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑