☰
Gemini Desktop:SwiftUI+AppKit混合开发macOS原生客户端实战
2026/10/9 4:14:47 网站建设 项目流程

1. 为什么我要给 Gemini 做一个 macOS 原生客户端

用 Gemini 有一段时间了,网页版体验其实不差,但每天高频使用下来,总有几个点让人如鲠在喉。浏览器标签页越开越多,切来切去效率极低;每次想快速问一句,都要先找到那个标签页,再等页面加载;更别提有时候网络波动,网页端直接卡住,连个重试按钮都点不动。我算了一笔账:如果每天因为切换、等待、重载浪费十分钟,一年下来就是六十多个小时。这个时间成本,对于一个靠工具吃饭的人来说,实在有点肉疼。

于是我就想,能不能做一个 macOS 原生客户端,把 Gemini 的核心对话能力直接搬到桌面上?启动快、常驻 Dock、全局快捷键呼出、对话历史本地留存,这些在网页端很难做到的事情,原生应用可以轻松实现。更重要的是,macOS 原生应用可以深度调用系统能力,比如菜单栏快捷入口、通知中心提醒、剪贴板无缝衔接,这些都是浏览器标签页给不了的体验。

这个项目我命名为Gemini Desktop,技术栈选的是SwiftUI + AppKit混合方案,目前已经在 GitHub 上开源。它解决的问题很明确:让 Gemini 的重度用户有一个更顺手、更稳定、更符合 macOS 操作习惯的入口。适合谁用?如果你每天都要和 Gemini 打交道,又恰好是 Mac 用户,那这个客户端就是为你准备的。哪怕你只是偶尔用用,原生应用的启动速度和响应体验,也会让你回不去网页版。

提示:本文所有内容基于我个人开发实践整理,涉及的具体实现细节可能因系统版本和开发环境不同而有差异,建议以实际调试结果为准。

2. 技术选型:为什么是 SwiftUI 加 AppKit 而不是纯 SwiftUI

2.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 能成为你日常工作中离不开的那个小助手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询