从零构建 Gemini 桌面客户端:SwiftUI 与 AppKit 混合开发实战

发布时间:2026/10/9 17:23:01
从零构建 Gemini 桌面客户端:SwiftUI 与 AppKit 混合开发实战 1. 为什么我要自己动手写一个 Gemini 桌面客户端1.1 从网页版到原生客户端的真实痛点用 Gemini 处理日常事务有一段时间了网页版能用但用久了总有几个地方让人如鲠在喉。最直接的问题就是窗口切换成本。我平时写代码、查文档、整理笔记浏览器里常年开着十几个标签页Gemini 的标签一旦被挤到后面想用的时候就得先找标签、再等页面重新聚焦有时候页面还会因为长时间挂后台而重新加载之前聊到一半的上下文直接断掉。这种体验在需要频繁调用 AI 的场景下累积起来的时间损耗非常可观。第二个痛点是系统级集成缺失。网页版没法用全局快捷键唤起没法在菜单栏常驻没法跟 macOS 的剪贴板、选中文本、服务菜单打通。我经常遇到的情况是在某个应用里看到一段需要处理的文字得先复制切到浏览器粘贴等回复再复制结果切回来。这一套动作重复几十次之后人是会烦的。原生客户端能做的事情就多了——全局快捷键一按就出来选中文字直接通过服务菜单发送回复结果一键回填到当前光标位置这些都是网页版给不了的。第三个痛点是登录态和会话管理。网页版登录状态偶尔会掉尤其是清理浏览器数据或者换设备之后得重新走一遍登录流程。而且网页版对多会话的管理比较粗糙历史记录翻起来费劲想同时开几个不同主题的对话也不方便。原生客户端可以把会话存在本地用 SQLite 或者文件系统管理想怎么组织就怎么组织还能做全文搜索。最后一个也是促使我真正动手的原因——成本回收心理。我订阅了 Gemini 的高级服务每个月固定支出但实际使用频率受限于上面这些体验问题总觉得没把这个钱用回本。既然订阅费已经付了那不如花点时间把使用体验拉满让每一次调用都顺畅这样单位成本才摊得下来。这个逻辑听起来有点自我安慰但确实是我按下新建 Xcode 项目按钮的直接动机。1.2 技术选型为什么是 SwiftUI AppKit 混合决定自己写之后第一个要定的是技术栈。macOS 原生开发现在有两条主流路线一条是纯 SwiftUI另一条是 AppKit还有一条是两者混合。我最终选的是SwiftUI 为主、AppKit 为辅的混合方案这个选择背后有几个具体考量。纯 SwiftUI 的优点是声明式语法写起来快状态管理清晰适配深浅色模式、动态字体这些系统特性几乎零成本。但它的短板也很明显窗口管理能力弱。比如我想做一个类似 Spotlight 那样的无边框浮动窗口或者想让窗口在失去焦点时自动隐藏SwiftUI 的 WindowGroup 和 Window 场景在这方面的控制力不够细。另外SwiftUI 对 NSTextView 这种富文本编辑控件的封装还不完善而聊天界面里输入框需要支持多行、富文本粘贴、快捷键处理这些用 AppKit 的 NSTextView 更靠谱。所以我的分工是这样的主窗口的布局、侧边栏会话列表、消息气泡渲染用 SwiftUI这部分迭代快改起来舒服全局快捷键注册、浮动窗口的 NSWindow 控制、输入框的 NSTextView 封装、菜单栏额外项用 AppKit这部分需要精细控制SwiftUI 暂时替代不了。两者之间通过 NSHostingView 和 NSViewControllerRepresentable 桥接数据层用 ObservableObject 统一管理SwiftUI 和 AppKit 都能订阅同一份状态。这个混合方案的好处是各取所长坏处是桥接层需要写一些样板代码而且要注意线程问题——AppKit 的 UI 操作必须在主线程SwiftUI 的 Published 更新也要回主线程两者混用的时候容易出竞态。我的做法是所有状态变更都通过一个 MainActor 标记的 Store 类来走这样编译器会帮我检查线程安全省得自己记。1.3 开源决策与项目定位写完之后我犹豫了一阵要不要开源。犹豫的点在于代码里有一些我自己觉得写得不够优雅的地方怕被人挑刺。但后来想通了——开源的价值不在于展示完美代码而在于提供一个可运行的起点让后来的人不用从零开始踩坑。而且我自己在开发过程中大量参考了开源社区的资料回馈一下是应该的。项目定位很明确一个轻量、原生、注重系统集成的 Gemini 桌面客户端。不做大而全的 AI 工作台不集成十几个模型就专注把 Gemini 在 macOS 上的使用体验做好。功能范围控制在多会话管理、全局快捷键唤起、选中文本快捷发送、本地会话存储、Markdown 渲染、代码块高亮。这些是日常高频用到的其他的以后再说。开源协议选的是 MIT最宽松的那种。理由很简单我希望别人能随便用、随便改哪怕拿去商用我也没意见。这个项目本身不是什么核心竞争力没必要用 GPL 那种传染性协议限制别人。仓库结构上我把核心逻辑和 UI 分开放在不同的 target 里方便别人只取自己需要的部分。2. 核心功能拆解与实现细节2.1 全局快捷键与浮动窗口的实现全局快捷键是这个客户端最核心的体验提升点。实现上用的是Carbon 的 RegisterEventHotKey而不是 NSEvent 的 addGlobalMonitorForEvents。原因在于 NSEvent 的全局监听需要辅助功能权限而且只能监听不能拦截按键还是会传到前台应用。Carbon 的 API 虽然老但它是系统级的注册能真正拦截按键而且不需要额外权限。具体代码大概长这样import Carbon func registerHotKey() { var hotKeyRef: EventHotKeyRef? let hotKeyID EventHotKeyID(signature: OSType(0x47454D49), id: 1) // GEMI let modifiers: UInt32 UInt32(cmdKey | optionKey) let keyCode: UInt32 UInt32(kVK_ANSI_G) RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, hotKeyRef) var eventType EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) InstallEventHandler(GetApplicationEventTarget(), { _, event, _ - OSStatus in // 处理快捷键触发 NotificationCenter.default.post(name: .toggleFloatingWindow, object: nil) return noErr }, 1, eventType, nil, nil) }浮动窗口用 NSPanel 而不是 NSWindow因为 NSPanel 天生支持nonactivatingPanel样式可以在不抢焦点的情况下显示。这个特性很关键——我希望按快捷键弹出窗口后输入框能直接接收键盘输入但又不希望当前正在用的应用失去焦点。NSPanel 配合becomesKeyOnlyIfNeeded true和styleMask里加上.nonactivatingPanel就能做到。窗口的显示位置我做了个小心思跟随鼠标当前位置而不是固定在屏幕中央。因为用快捷键唤起的场景通常是我正看着某处内容想快速问一下窗口出现在鼠标附近更符合直觉。获取鼠标位置用NSEvent.mouseLocation然后做一下屏幕边界检测防止窗口跑到屏幕外面去。注意Carbon 的 RegisterEventHotKey 在 App Sandbox 环境下也能用但如果你要上架 App Store需要确认快捷键组合不与系统保留快捷键冲突。我测试下来 CmdOptionG 是安全的但 CmdSpace 这种就别想了系统占用了。2.2 选中文本快捷发送的服务菜单集成macOS 的服务菜单是一个被严重低估的系统特性。它允许应用把功能注册到系统的右键菜单和应用程序菜单里其他应用选中文本后可以直接调用。我把发送到 Gemini注册成了服务这样在任何应用里选中文字右键就能看到这个选项点一下就把文字发到客户端并自动发起对话。实现分两步。第一步是在 Info.plist 里声明服务keyNSServices/key array dict keyNSMenuItem/key dict keydefault/key string发送到 Gemini/string /dict keyNSMessage/key stringsendToGemini/string keyNSPortName/key stringGeminiClient/string keyNSSendTypes/key array stringNSStringPboardType/string /array /dict /array第二步是在 AppDelegate 里实现对应的处理方法objc func sendToGemini(_ pboard: NSPasteboard, userData: String, error: AutoreleasingUnsafeMutablePointerNSString) { guard let text pboard.string(forType: .string) else { return } // 唤起窗口并发送文本 NotificationCenter.default.post(name: .sendTextToGemini, object: text) }这里有个坑服务菜单的注册需要应用至少运行过一次系统才会把它加入服务列表。而且如果应用被移动了位置服务可能会失效需要重新注册。我在 README 里专门写了这一点因为第一次用的人大概率会困惑为什么右键菜单里找不到。另外服务菜单默认是折叠的需要在系统设置 - 键盘 - 键盘快捷键 - 服务里手动勾选启用。这个也没法绕过只能引导用户去设置。我在应用首次启动时弹了个提示告诉用户去哪里开启体验上会好一些。2.3 会话存储与本地数据管理会话数据我用的是SQLite GRDB的组合。选 GRDB 而不是 Core Data理由是Core Data 的模型编辑器在多人协作和版本控制时很痛苦而 GRDB 用纯 Swift 代码定义表结构diff 起来清晰。而且 GRDB 对 SQL 的控制更直接做全文搜索、复杂查询的时候不用跟 NSFetchRequest 较劲。表结构设计得很简单三张表表名字段说明conversationsid, title, created_at, updated_at会话元信息messagesid, conversation_id, role, content, created_at消息记录settingskey, value应用配置全文搜索用的是 SQLite 的 FTS5 扩展GRDB 对它有封装。建一个虚拟表把 messages 的 content 同步进去搜索的时候直接查这个虚拟表速度很快。同步用触发器做插入消息的时候自动更新 FTS 表不用在应用层手动维护。数据库文件放在~/Library/Application Support/GeminiClient/下面这是 macOS 应用存用户数据的标准位置。备份和迁移都方便用户想手动备份的话直接拷这个目录就行。我还在设置里加了个导出全部会话为 Markdown的功能把每个会话写成一个 .md 文件方便用户把内容迁移到别的地方。实操心得SQLite 的 WAL 模式一定要开。默认的 journal 模式在频繁写入时性能差很多WAL 模式下读写可以并发聊天这种高频写入场景提升明显。GRDB 里通过 Configuration 设置journalMode .wal就行。2.4 Markdown 渲染与代码高亮方案Gemini 的回复经常包含 Markdown 格式尤其是代码块。如果直接当纯文本显示可读性很差。我试过几个方案最后选的是swift-markdown 解析 自定义渲染。swift-markdown 是苹果开源的 Markdown 解析库把文本解析成 AST然后我遍历 AST 生成 SwiftUI 的视图。这样做的好处是渲染逻辑完全可控想怎么显示就怎么显示。比如代码块我识别出 language 属性后用 Highlightr 这个库做语法高亮它底层是 highlight.js 的 Swift 封装支持的语言很全。代码块的渲染我做了几个细节处理左上角显示语言标签右上角放复制按钮背景色用稍深的灰色区分正文横向滚动而不是折行。最后一点很重要——代码折行之后缩进全乱了横向滚动虽然需要用户手动滑但保持了代码的原始格式。这个取舍我纠结过最后选了横向滚动因为看代码的人通常更在意格式正确。行内代码用等宽字体加浅色背景链接用系统蓝色并可点击用Link组件打开浏览器。列表、引用、表格这些也都有对应的渲染逻辑。表格渲染比较麻烦SwiftUI 没有原生的表格布局我用 LazyVGrid 拼的列宽根据内容自适应。整个渲染管线是收到文本 - swift-markdown 解析 - 遍历 AST - 生成 SwiftUI View 树 - 缓存。缓存这一步是为了性能因为每次状态更新都重新解析整个消息会卡。我用消息 ID 做 key把渲染结果缓存起来只有内容变化时才重新解析。3. 从零到一的完整实操流程3.1 开发环境搭建与项目初始化开始之前确认你的 macOS 版本在 13.0 以上Xcode 版本 15.0 以上。低版本的 Xcode 对 SwiftUI 的新 API 支持不全会编译报错。我一开始用 Xcode 14 折腾了半天升级到 15 之后很多问题自动消失了。创建项目的步骤打开 Xcode选File - New - Project选macOS - App点 NextProduct Name 填 GeminiClientInterface 选SwiftUILanguage 选Swift取消勾选Use Core Data我们用 GRDB勾选Include Tests选一个本地目录点 Create项目创建好之后第一件事是改 Bundle Identifier改成你自己的域名反写比如com.yourname.GeminiClient。这个后面注册服务菜单和签名都要用。然后通过 Swift Package Manager 加依赖。在 Xcode 里选File - Add Package Dependencies依次添加https://github.com/groue/GRDB.swift- 数据库https://github.com/apple/swift-markdown- Markdown 解析https://github.com/raspu/Highlightr- 代码高亮版本都选最新的稳定版就行。加完之后等 Xcode 解析完依赖编译一次确认没问题。接下来配置 Info.plist。除了前面说的 NSServices还要加几个键keyLSMinimumSystemVersion/key string13.0/string keyNSHumanReadableCopyright/key stringMIT License/string keyLSUIElement/key false/LSUIElement 设为 false 表示应用在 Dock 里显示图标。如果你想做成纯菜单栏应用就设为 true但那样就没有主窗口了我建议还是保留 Dock 图标方便切换。3.2 网络层与 Gemini 接口对接网络层我用的是URLSession async/await没有引入 Alamofire 这类第三方库。原因很简单需求不复杂URLSession 够用少一个依赖少一份维护成本。Gemini 的接口调用大致是这样的结构struct GeminiService { let apiKey: String let baseURL https://generativelanguage.googleapis.com/v1beta func sendMessage(_ text: String, history: [Message]) async throws - AsyncStreamString { var contents: [[String: Any]] [] for msg in history { contents.append([ role: msg.role .user ? user : model, parts: [[text: msg.content]] ]) } contents.append([ role: user, parts: [[text: text]] ]) let body: [String: Any] [ contents: contents, generationConfig: [ temperature: 0.7, maxOutputTokens: 8192 ] ] var request URLRequest(url: URL(string: \(baseURL)/models/gemini-pro:streamGenerateContent?key\(apiKey))!) request.httpMethod POST request.setValue(application/json, forHTTPHeaderField: Content-Type) request.httpBody try JSONSerialization.data(withJSONObject: body) let (bytes, _) try await URLSession.shared.bytes(for: request) return AsyncStream { continuation in Task { for try await line in bytes.lines { // 解析 SSE 格式的流式响应 if line.hasPrefix(data: ) { let json String(line.dropFirst(6)) if let chunk parseChunk(json) { continuation.yield(chunk) } } } continuation.finish() } } } }这里的关键点是流式响应。Gemini 支持streamGenerateContent接口返回的是 SSE 格式的数据流每行一个 JSON 块。用 URLSession 的bytes(for:)方法拿到字节流然后按行解析就能实现打字机效果。这个体验比等完整回复再显示好太多尤其是长回复的时候。API Key 的存储我用的是Keychain不是 UserDefaults。UserDefaults 是明文存储的任何能读到 plist 文件的程序都能拿到你的 key。Keychain 虽然 API 用起来啰嗦一点但安全性有保障。封装一个简单的 KeychainHelper提供 save/read/delete 三个方法就够了。注意API Key 千万不要硬编码在代码里然后提交到 Git。我见过太多开源项目犯这个错误key 泄露之后被人刷爆额度。用 Keychain 存代码里只留读取逻辑仓库里放一个 .gitignore 排除掉本地配置文件。3.3 界面布局与交互细节打磨主界面布局是经典的侧边栏 内容区结构。侧边栏放会话列表内容区放消息流和输入框。用 NavigationSplitView 实现这是 SwiftUI 在 macOS 上的标准分栏组件。侧边栏的会话列表用 List 加 selection 绑定支持多选和右键菜单。右键菜单里放重命名、删除、导出这几个操作。会话按更新时间倒序排列最近用的在最上面。每个会话项显示标题和最后一条消息的摘要标题如果用户没设置就用第一条消息的前 20 个字自动生成。消息流的渲染用 ScrollView LazyVStack。LazyVStack 很重要消息多了之后普通 VStack 会一次性渲染所有子视图卡到没法用。LazyVStack 只渲染可见区域滚动流畅度提升明显。每条消息是一个独立的 View用户消息右对齐、蓝色背景AI 消息左对齐、灰色背景这是聊天界面的通用范式用户不用学习就能上手。输入框是我花时间最多的地方。用 NSViewRepresentable 包装 NSTextView实现了几个细节Enter 发送ShiftEnter 换行这是聊天应用的标配但 NSTextView 默认 Enter 是换行需要重写insertNewline方法自适应高度输入内容多了之后输入框自动长高最多长到 200pt 然后内部滚动粘贴图片支持直接粘贴截图转成 base64 发给 Gemini 的多模态接口快捷键支持CmdK 清空输入框CmdEnter 强制发送自动滚动的逻辑也要处理。新消息来的时候如果用户当前已经在底部就自动滚到底如果用户往上翻了在看历史就不要打断他而是在底部显示一个有新消息的按钮。这个细节看起来小但体验差别很大。3.4 打包、签名与分发开发完之后要打包成 .app 分发给别人用。Xcode 里选Product - Archive然后走 Distribute App 流程。这里有几个选择分发方式适用场景优缺点App Store面向普通用户审核严格沙盒限制多Developer ID独立分发需要签名和公证但限制少直接拷贝自己用无需签名但别人用会报错我选的是Developer ID方式。因为 App Store 的沙盒限制会影响全局快捷键和文件访问而且审核周期长。Developer ID 签名之后做公证notarization用户下载后能直接打开不会弹无法验证开发者的警告。公证的流程是先用codesign签名然后用xcrun notarytool提交给苹果公证服务等几分钟拿到结果最后用xcrun stapler把公证票据钉到 app 上。这一套流程可以在 Xcode 里自动完成但需要你先在开发者账号里配置好证书。如果你没有开发者账号也可以直接分发未签名的 app但用户第一次打开需要右键点打开或者在终端里执行xattr -cr /path/to/app清除隔离属性。我在 README 里写了这个说明方便没有账号的人自己编译使用。4. 踩坑记录与问题排查4.1 常见编译与运行问题速查开发过程中遇到的问题不少我整理了一个速查表按现象、原因、解决方案三个维度列出来现象可能原因解决方案编译报错 Cannot find GRDB依赖没解析成功清理 DerivedData重新解析包依赖快捷键不生效与其他应用冲突换一个组合键或在设置里让用户自定义服务菜单不显示应用未运行过或未勾选先运行一次应用去系统设置里勾选服务窗口不显示NSPanel 的 level 设置不对设为 .floating 或 .modalPanel流式响应卡顿主线程更新 UI 太频繁用 throttle 限制更新频率比如 50ms 一次数据库锁死多线程同时写入用 GRDB 的 DatabaseQueue 串行化写入代码高亮不显示Highlightr 主题未加载检查主题名称是否正确用默认主题测试其中流式响应卡顿这个问题我排查了很久。一开始每收到一个字符就更新一次 Published 属性结果 UI 疯狂重绘CPU 直接飙到 100%。后来改成用一个缓冲区累积每 50ms 刷新一次 UI流畅度立刻正常了。这个思路跟游戏里的帧率限制是一个道理——不是更新越快越好而是要匹配渲染能力。数据库锁死的问题出现在我同时开多个会话窗口的时候。GRDB 的 DatabaseQueue 是串行的多个写入操作排队执行但如果一个操作里又嵌套了另一个写入就会死锁。解决方法是把嵌套写入拆成两个独立的事务或者用 DatabasePool 代替 DatabaseQueue后者支持并发读。4.2 性能优化的几个关键点性能优化我主要做了三件事按收益从大到小排列第一是消息列表的懒加载。前面提过 LazyVStack但光用它还不够。每条消息的 Markdown 渲染结果我做了缓存用 NSCache 存key 是消息内容的 hash。这样滚动的时候不会重复解析帧率稳定在 60fps。第二是网络请求的取消机制。用户可能在 AI 还在回复的时候切换到别的会话这时候原来的请求应该取消不然会浪费流量和额度。URLSession 的 Task 支持取消我在切换会话的时候调用task.cancel()然后在 catch 里处理 CancellationError不弹错误提示。第三是启动速度优化。应用启动时要加载数据库、初始化服务、注册快捷键这些如果都在主线程做启动会卡顿。我把数据库初始化放到后台队列UI 先显示出来数据加载完了再刷新。启动时间从 1.2 秒降到了 0.3 秒左右。内存占用方面正常使用大概在 80-120MB 之间主要开销是 WebKit 的 Markdown 渲染和图片缓存。如果消息特别多几千条内存会涨到 200MB 以上这时候需要做分页加载只保留最近 100 条在内存里其他的从数据库按需读取。4.3 用户反馈中的高频问题开源之后收到不少 issue挑几个有代表性的说说。最多人问的是为什么登录不了。这里要澄清一下这个客户端用的是 API Key 方式不是账号密码登录。你需要去 AI 服务提供商的开发者后台申请一个 API Key填到设置里。API Key 和订阅账号是两套体系订阅了高级服务不代表自动有 API 权限可能需要单独开通。这个我在 README 里写清楚了但还是有人不看文档直接问。第二多的是快捷键跟其他应用冲突。这个确实没法完全避免因为系统级的快捷键组合是有限的。我的做法是在设置里提供自定义功能用户自己改。默认用 CmdOptionG冲突概率比较低。第三是能不能支持其他模型。这个我暂时不做原因前面说过项目定位就是专注 Gemini。但代码结构上我留了扩展点Service 层是协议化的想加别的模型实现一个协议就行。有几个人 fork 之后自己加了也提了 PR我合并了一部分通用的改动。实操心得开源项目一定要写好 README 和 CONTRIBUTING。README 里把这是什么、怎么装、怎么用、常见问题讲清楚能减少 80% 的重复 issue。CONTRIBUTING 里说明代码风格、提交规范、PR 流程能提高合并效率。我一开始没写这些结果前两周光回复 issue 就花了不少时间。5. 后续可以怎么扩展这个项目目前的功能已经覆盖了我的日常使用但还有几个方向值得做。多模态输入是一个现在只支持文本和粘贴图片其实可以加语音输入用系统自带的 Speech 框架就能做。会话同步是另一个现在数据存在本地换设备就没了可以加个 iCloud 同步用 CloudKit 实现。插件系统也在考虑让用户自己写脚本扩展功能比如自定义 prompt 模板、自动化工作流。不过这些都是后话当前版本先把稳定性做好。我自己的使用体验是有了这个客户端之后调用 AI 的频率明显提高了因为成本太低了——按个快捷键说句话结果就出来了。订阅费算是用回本了甚至有点超值。如果你也有类似的需求不妨 clone 下来试试代码不难懂改起来也方便。遇到问题提 issue我看到会回。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询