SwiftUI + AppKit 实战:打造原生 macOS Gemini 客户端

发布时间:2026/10/9 11:27:02
SwiftUI + AppKit 实战:打造原生 macOS Gemini 客户端 1. 从“回本焦虑”说起为什么我要自己写一个 Gemini 桌面客户端去年订阅了 Gemini 的付费套餐之后我陷入了一种很典型的“订阅焦虑”——每个月扣费的时候都在想这个月到底用回来多少。网页版不是不能用但每次都要切浏览器、找标签页、登录状态偶尔失效真正想快速问一句的时候这套流程的摩擦成本高得离谱。尤其是写代码写到一半脑子里冒出一个问题切到浏览器、等页面加载、输入、再切回来思路已经断了。这种体验用久了人就会本能地减少使用频率而使用频率一低订阅费就更显得亏。我算过一笔账如果每天因为“懒得切浏览器”而少问 5 个问题一个月就是 150 次交互的损失。按订阅费摊下来每次交互的成本被硬生生抬高了一大截。所以对我来说“回本”的核心不是用得更多而是把使用摩擦降到接近于零。当提问这个动作变得像在本地打开一个记事本一样自然时使用频率自然就上去了订阅的价值才真正被释放出来。这就是我决定动手写一个 macOS 原生客户端的直接动机。市面上的第三方客户端不是没有但大多是基于 Electron 或者 WebView 套壳的启动慢、内存占用高、和系统割裂感强。我想要的是一打开就能用、快捷键呼出、和 macOS 深度融合的东西。技术选型上SwiftUI 负责界面和状态管理AppKit 负责那些 SwiftUI 还搞不定的窗口级操作这是目前 macOS 原生开发里最务实的组合。项目已经开源下面我把整个开发过程中的思考、踩坑和实操细节完整地拆一遍。这篇文章适合几类人看一是和我一样有订阅回本焦虑、想提升日常效率的普通用户二是有一定 Swift 基础、想入门 macOS 原生开发但不知道从哪下手的开发者三是想了解 SwiftUI 和 AppKit 混合开发实战经验的人。我会尽量把“为什么这么做”讲透而不是只丢一堆代码。2. 技术选型为什么是 SwiftUI AppKit 而不是 Electron2.1 套壳方案的三个致命伤在动手之前我认真评估过 Electron 和 Tauri 这两条路。Electron 的优势是生态成熟、跨平台、前端开发者上手快但放到“一个常驻后台、随时呼出的 AI 助手”这个场景里它的缺点被无限放大了。第一个是冷启动速度。Electron 应用哪怕做了优化冷启动普遍在 1.5 到 3 秒之间因为它要拉起一个完整的 Chromium 运行时。而我想要的是按下快捷键之后窗口在 200 毫秒内出现。这个差距在“随手问一句”的场景里是决定性的——超过一秒的等待人就会放弃。第二个是内存占用。一个空白的 Electron 窗口起步就是 150MB 以上加上渲染进程和 GPU 进程轻松突破 300MB。对于一个只是用来发消息、收消息的工具来说这个开销完全不合理。原生方案下同样的功能内存占用可以控制在 60MB 到 100MB 之间。第三个是系统融合度。Electron 应用很难做到真正的原生体验菜单栏图标的行为、窗口的毛玻璃效果、系统的深浅色切换、辅助功能支持这些都需要额外适配而且总有一种“差一口气”的感觉。macOS 用户对原生质感是很敏感的套壳应用一眼就能看出来。Tauri 比 Electron 轻量很多用系统 WebView 替代了打包 Chromium但它依然依赖 WebView 渲染在 macOS 上和系统的融合度还是不如纯原生。而且 Tauri 的 Rust 后端对我来说增加了额外的学习和调试成本收益不明显。2.2 SwiftUI 和 AppKit 的分工边界选定原生之后下一个问题是 SwiftUI 和 AppKit 怎么分工。我的原则很简单能用 SwiftUI 表达的就用 SwiftUISwiftUI 表达不了或者表达起来别扭的下沉到 AppKit。SwiftUI 负责的部分包括消息列表的渲染、输入框、设置面板、侧边栏的会话管理、加载状态和错误提示。这些是声明式的、和数据状态强绑定的 UISwiftUI 写起来非常舒服改一个State或者ObservedObject界面自动更新省掉了大量手动刷新界面的代码。AppKit 负责的部分包括全局快捷键注册、菜单栏图标和弹出面板、窗口级别的控制比如无边框窗口、窗口置顶、窗口位置记忆、以及 NSTextView 的一些高级文本处理。这些是 SwiftUI 目前还覆盖不到或者覆盖得不好的地方。举个具体的例子SwiftUI 的TextEditor在 macOS 上功能非常有限不支持富文本、不支持自定义快捷键、光标行为也不够灵活所以输入框我最终是用NSTextView包了一层NSViewRepresentable来实现的。这种混合模式的关键是边界要清晰。我的做法是把所有 AppKit 相关的代码集中放在一个AppKitBridge目录下通过NSViewRepresentable和NSViewControllerRepresentable暴露给 SwiftUI 层。SwiftUI 层完全不需要知道底下是 AppKit只和协议打交道。这样后期如果要替换某一块实现改动范围是可控的。2.3 最低系统版本的取舍我把最低支持版本定在了 macOS 13 Ventura。原因有几个一是 macOS 13 引入了NavigationSplitView这是做侧边栏加详情页布局的官方方案比之前手动拼HStack优雅太多二是 macOS 13 的 SwiftUI 在性能和稳定性上比 12 有质的提升尤其是列表滚动和状态更新方面三是根据我自己的观察还在用 macOS 12 及以下的用户占比已经很低了为了兼容他们而放弃新 API 不划算。如果你要兼容更老的系统那 SwiftUI 的很多新特性都用不了开发体验会急剧下降很多地方不得不回退到 AppKit反而失去了选 SwiftUI 的意义。所以我的建议是个人项目大胆用新 API把精力花在功能本身而不是兼容性上。3. 核心功能拆解一个 AI 客户端到底需要什么3.1 会话管理多轮对话的状态怎么存AI 客户端最核心的数据结构就是会话Conversation和消息Message。我的设计是一个会话包含一个消息数组每条消息有角色用户或助手、内容、时间戳和状态发送中、成功、失败。会话本身有标题、创建时间和最后更新时间。状态管理上我用了一个ConversationStore作为单一数据源它是一个ObservableObject内部维护一个会话数组和当前选中的会话 ID。所有对会话的增删改查都通过这个 Store 进行SwiftUI 视图通过EnvironmentObject或者ObservedObject订阅它。这样做的好处是数据流是单向的任何界面上的变化都能追溯到一次明确的状态修改调试起来很清楚。持久化方面我一开始想用 Core Data但后来发现对于这种结构简单的数据直接 Codable 序列化成 JSON 存到 Application Support 目录反而更简单可控。Core Data 的模型定义、迁移、上下文管理对于一个小工具来说太重了。JSON 方案的问题是数据量大之后读写会变慢但一个会话历史撑死也就几 MB完全在可接受范围内。我加了一个防抖用户停止输入 2 秒后才写盘避免频繁 IO。提示存 JSON 的时候一定要用FileManager拿到正确的 Application Support 路径不要硬编码路径。沙盒环境下硬编码路径会直接失败而且不同用户的路径是不一样的。3.2 流式响应打字机效果背后的 SSE 处理AI 对话体验里流式响应是刚需。如果等模型把整段话生成完再一次性显示用户会盯着空白屏幕等好几秒体验极差。流式输出让文字像打字一样一个个蹦出来感知上的响应速度快了非常多。Gemini 的流式接口返回的是 SSEServer-Sent Events格式的数据流。处理这个流的关键是用 URLSession 的 bytes 流式 API而不是等整个 response 回来。具体来说我用的是URLSession.shared.bytes(for:)拿到一个异步字节序列然后逐行解析。每一行如果以data:开头就把后面的 JSON 解析出来提取出文本增量追加到当前消息的内容上。这里有个坑SSE 的数据块不保证按行完整到达可能一次收到半行也可能一次收到好几行。所以必须维护一个缓冲区按换行符切分最后不完整的那一段留在缓冲区里等下一次数据。我一开始没做这个处理结果偶尔会出现 JSON 解析失败排查了半天才发现是数据分片的问题。另一个坑是取消请求。用户可能在流式输出到一半的时候想停止这时候要能干净地取消掉 URLSession 的任务并且把已经收到的内容保留下来。我用Task来包裹整个流式请求取消的时候调用task.cancel()然后在 catch 里判断是不是CancellationError如果是就正常结束而不是报错。3.3 全局快捷键让提问像呼吸一样自然这是整个项目里我最看重的功能。我希望无论我在哪个应用里按下一个快捷键就能呼出输入框输入问题回车发送然后窗口自动收起或者保持。这个流程里任何一步的卡顿都会破坏体验。全局快捷键的实现用的是 Carbon 的RegisterEventHotKey虽然 Carbon 是个老框架但它是目前 macOS 上注册全局快捷键最可靠的方式不需要辅助功能权限。相比之下用NSEvent.addGlobalMonitorForEvents需要用户授予辅助功能权限体验上多了一道门槛。注册快捷键的代码大致是这样先定义一个EventHotKeyID然后调用RegisterEventHotKey传入键码和修饰键最后安装一个事件处理器来响应触发。键码和修饰键的映射需要查表这部分比较繁琐我封装了一个HotKeyManager类来管理。窗口呼出之后焦点要自动落到输入框上这需要调用NSApp.activate(ignoringOtherApps: true)把应用激活然后让输入框成为第一响应者。这里有个细节如果应用之前是隐藏状态激活之后窗口可能不会自动到最前面需要额外调用window.makeKeyAndOrderFront(nil)。注意全局快捷键很容易和其他应用冲突。我在设置里做了一个快捷键录制控件让用户可以自己改。录制的时候要拦截按键事件把修饰键和主键都记录下来同时要排除掉只有修饰键的情况。3.4 菜单栏常驻不占 Dock 的轻量存在我希望这个工具是“召之即来挥之即去”的所以它默认不显示在 Dock 里而是常驻菜单栏。实现方式是在 Info.plist 里设置LSUIElement为true这样应用就不会出现在 Dock 和 CmdTab 切换器里。菜单栏图标用NSStatusItem创建点击之后弹出一个NSPopover或者一个无边框窗口来显示主界面。我选的是无边框窗口因为 Popover 在内容较多的时候滚动体验不好而且尺寸受限。无边框窗口可以自由控制大小和位置还能记住上次的位置。菜单栏图标的交互也有讲究左键点击呼出主窗口右键点击弹出一个菜单包含“新建会话”“设置”“退出”等选项。这个区分是通过判断NSApp.currentEvent的类型来实现的。4. 开发过程中踩过的那些坑4.1 SwiftUI 列表性能消息多了就卡消息列表用的是ScrollView加LazyVStack理论上 LazyVStack 是懒加载的只渲染可见区域。但实际用下来当消息数量超过一两百条、而且每条消息内容比较长的时候滚动还是会卡。排查之后发现两个原因。第一个是每条消息的视图太重。我一开始在消息气泡里做了很多装饰性的东西阴影、圆角、渐变背景这些视觉效果在单个视图上开销不大但数量一多就累积起来了。后来我把阴影去掉了圆角用简单的RoundedRectangle性能明显改善。第二个原因是Markdown 渲染。AI 返回的内容经常包含代码块、列表、加粗等 Markdown 格式我用了一个第三方库来渲染。这个库在每次视图更新的时候都会重新解析整段文本消息一多就是灾难。我的优化方案是缓存解析结果只有内容真正变化时才重新解析用EquatableView或者手动实现Equatable来避免不必要的重绘。还有一个更根本的优化给消息列表加一个上限。当会话里的消息超过一定数量比如 200 条时只渲染最近的部分更早的消息折叠起来或者分页加载。这个策略在聊天类应用里很常见能有效控制内存和渲染压力。4.2 输入框的焦点问题为什么回车有时候没反应输入框用NSTextView封装之后遇到的最烦人的问题是焦点管理。具体表现是窗口呼出之后有时候输入框没有自动获得焦点需要手动点一下才能输入有时候输入了内容按回车事件没有被正确捕获。焦点问题的根源在于 macOS 的窗口激活和第一响应者机制。当应用从后台被激活时窗口成为 key window 和第一响应者的设置是有时序的如果设置得太早窗口还没准备好设置就会失效。我的解决方案是在窗口didBecomeKey的通知里再去设置第一响应者确保时机正确。回车事件的处理我一开始用的是NSTextView的doCommandBy代理方法判断 selector 是不是insertNewline:。但这个方法在中文输入法下会有问题——输入法候选框里的回车和确认输入的回车会混淆。后来我改成了监听NSEvent的 keyDown并且判断当前是否有输入法正在组合文本通过hasMarkedText()只有在没有组合文本的时候才把回车当作发送。提示处理中文输入法的回车是个经典坑。核心判断逻辑是如果NSTextView当前有 marked text正在输入法组合中回车应该交给输入法处理否则才当作发送。这个判断不做中文用户会疯掉。4.3 网络请求的错误处理超时、断网、限流网络请求这块我踩的坑主要集中在错误处理上。AI 接口可能返回各种各样的错误网络超时、连接中断、认证失败、请求过于频繁、内容被拦截等等。如果只是简单地 catch 一下然后弹个“请求失败”用户根本不知道发生了什么也不知道该怎么办。我的做法是对错误进行分类每类给出不同的提示和处理策略。网络超时和连接中断提示用户检查网络并提供重试按钮认证失败提示用户重新登录或者检查密钥请求过于频繁提示用户稍后再试并显示建议的等待时间内容被拦截把拦截原因展示出来让用户知道是内容问题而不是系统故障。重试机制上我实现了一个简单的指数退避第一次失败后等 1 秒重试第二次等 2 秒第三次等 4 秒最多重试 3 次。但要注意不是所有错误都值得重试。认证失败重试多少次都没用内容被拦截重试也是一样的结果。只有网络类的临时错误才适合重试。还有一个细节是请求的取消和并发控制。用户可能连续发好几条消息如果每条都独立发请求可能会出现响应乱序的问题。我的做法是给每个会话维护一个请求队列同一时间只允许一个请求在进行新的请求排队等待。这样虽然牺牲了一点并发但保证了消息顺序的正确性逻辑也简单很多。4.4 打包和签名从 Xcode 到可分发开发完成之后打包分发又是一道坎。macOS 应用默认需要签名才能在别的机器上运行否则会被 Gatekeeper 拦截。对于开源项目来说我没有苹果开发者账号所以做不了公证notarization只能提供未签名的构建让用户自己处理。我的做法是在 README 里写清楚下载之后如果提示“无法打开因为来自身份不明的开发者”需要在“系统设置 - 隐私与安全性”里手动允许。或者用xattr -d com.apple.quarantine命令去掉隔离属性。这些步骤对普通用户来说有点门槛但开源项目也只能这样了。如果你有开发者账号那流程就正规很多用 Developer ID 证书签名然后提交公证公证通过之后用户下载就能直接打开。公证的过程需要把应用打包成 zip 或者 dmg用notarytool提交等待几分钟到几十分钟不等。构建配置上我建议把 Debug 和 Release 的配置分开。Debug 用本地的开发证书Release 用 Developer ID。还要注意把不必要的调试符号和日志在 Release 里去掉减小包体积。5. 开源之后项目结构和协作的那些事5.1 目录结构怎么组织才清晰开源项目的目录结构直接影响别人愿不愿意读你的代码。我的组织方式是按功能模块划分而不是按文件类型划分。具体来说顶层目录有App应用入口和生命周期、Features各个功能模块比如 Chat、Settings、Conversation、Core网络、存储、模型等基础设施、AppKitBridgeAppKit 相关的桥接代码、Resources资源文件。每个 Feature 目录下再细分 View、ViewModel、Model。这样当你想找“会话列表是怎么实现的”直接进Features/Conversation就能找到所有相关代码不用在几十个文件里翻。我见过很多项目把所有 View 放一个目录、所有 Model 放一个目录文件一多就完全找不到北。按功能划分虽然前期要多建几个文件夹但长期来看可维护性好太多。5.2 让别人能跑起来README 和构建说明开源项目最常见的失败是“别人 clone 下来跑不起来”。我在 README 上花了很大功夫确保一个没有接触过这个项目的人能顺利构建。README 里必须包含的东西项目简介和截图、系统要求macOS 版本、Xcode 版本、构建步骤clone、打开 xcodeproj、选 scheme、build、配置说明API 密钥怎么填、常见问题构建失败怎么办、运行报错怎么办。配置这块我踩过一个坑一开始我把 API 密钥硬编码在代码里开源之前差点直接提交上去。后来改成了从环境变量或者配置文件读取并且把配置文件加进了.gitignore。开源项目一定要在提交前检查有没有泄露密钥、token、个人信息这个检查要做在 commit 之前而不是 push 之后才想起来。5.3 许可证的选择MIT 还是 GPL许可证我选了 MIT。原因很简单我希望别人能自由地使用、修改、甚至商用这个项目MIT 是最宽松、限制最少的。GPL 要求衍生作品也必须开源对于一个小工具来说这个约束有点重可能会劝退一些想基于它做二次开发的人。选许可证的时候要考虑清楚你的意图如果你希望代码被尽可能广泛地使用选 MIT 或 Apache 2.0如果你希望衍生作品也保持开源选 GPL。没有绝对的好坏只有适不适合你的目标。对于个人开源的小工具MIT 基本是不会错的选择。5.4 后续可以扩展的方向这个项目目前实现了核心的对话功能但还有很多可以做的。比如多模型切换让用户可以在不同的 AI 服务之间选择比如对话导出把会话导出成 Markdown 或者 PDF比如提示词模板让用户保存常用的提问模板一键调用比如本地知识库把常用文档索引起来提问的时候自动带上相关上下文。我在实际使用中体会最深的一点是工具的价值不在于功能多而在于核心流程是否顺畅。与其堆一堆用不上的功能不如把“呼出、提问、得到回答”这条主链路打磨到极致。我现在的日常使用中从按下快捷键到看到回答的第一个字整个过程不到一秒这种顺畅感才是我当初写这个客户端的真正目的。订阅回不回本已经不重要了重要的是我重新找回了“随手就能问”的那种自然感。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询