news 2026/10/9 4:15:02

用 SwiftUI 和 AppKit 打造 macOS 原生 Gemini 客户端:从开发到开源

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用 SwiftUI 和 AppKit 打造 macOS 原生 Gemini 客户端:从开发到开源

1. 从“网页标签页里吃灰”到桌面原生:我为什么非要自己写一个 Gemini 客户端

我订阅 Gemini 大概有半年多,说实话,前几个月用得很勤,后面就慢慢变成“想起来才点开”。原因不复杂——我日常写代码、查文档、整理笔记都在 macOS 上,而 Gemini 的网页版始终是浏览器里的一个标签页。标签页这东西,开着占内存,关掉就忘,切来切去还容易和一堆调试页面混在一起。我算过一笔账:订阅费按月扣,但真正高频使用的天数可能连一半都不到,这钱花得有点冤。

后来我动了念头:既然每天都在 macOS 上干活,为什么不做一个原生客户端,把它固定在我的工作流里?于是就有了这个开源项目——一个用 SwiftUI 和 AppKit 搭起来的 macOS 原生 Gemini 客户端。它不是简单的网页套壳,而是真正调用系统能力、贴合 macOS 交互习惯的桌面应用。写完之后我自己的使用频率明显上来了,算是把这笔订阅费“用回本”了。

这篇文章我会把整个开发过程拆开讲:为什么选 SwiftUI 而不是 Electron、原生客户端到底解决了哪些网页版解决不了的痛点、开发中踩了哪些坑、以及如果你想自己动手或者直接拿来用,需要注意什么。适合两类人看:一是想给自己常用的在线服务做个桌面客户端的开发者,二是对 macOS 原生开发感兴趣、想找个真实项目练手的人。哪怕你只是想知道“原生客户端和网页版到底差在哪”,也能从里面找到答案。

2. 原生客户端到底赢在哪:网页版解决不了的四个真实痛点

2.1 全局快捷键与随时唤起,才是高频使用的关键

网页版最大的问题不是功能弱,而是“够不着”。你得先切到浏览器,再找到那个标签页,有时候标签页太多还得翻。原生客户端可以直接注册全局快捷键,比如我设的是Option + Space,无论当前在哪个应用里,按一下就能唤出输入框,问完问题按 Esc 收起,整个过程不打断思路。

这个体验差异是质变的。网页版是“我要去用它”,原生客户端是“它随时在我手边”。我实测下来,同样的功能,有全局快捷键之后我的日均使用次数大概翻了一倍。这不是玄学,是交互路径从“三步”压缩到“一步”带来的自然结果。

2.2 系统级集成:剪贴板、通知、菜单栏一个都不能少

macOS 原生应用能拿到很多网页拿不到的能力。举几个我实际用上的:

  • 剪贴板直读:复制一段代码或报错信息,唤出客户端时自动填入输入框,省去手动粘贴。
  • 系统通知:长回答生成完毕时发一条通知,我可以先去干别的,好了再回来看。
  • 菜单栏常驻:把图标放在菜单栏,点一下就是输入框,不占 Dock 空间。
  • 深色模式跟随:自动跟随系统外观,不用手动切换。

这些能力单看都不起眼,但叠在一起,就是“原生感”的来源。网页版受限于浏览器沙箱,这些基本都做不了,或者做得很别扭。

2.3 性能与资源占用:Electron 套壳和真原生的差距

很多人做桌面客户端第一反应是 Electron,毕竟前端技术栈熟悉。但我一开始就排除了这条路,原因很简单:我打开这个客户端是为了少开一个浏览器标签页,结果你给我塞一个内置 Chromium,内存占用比标签页还高,这不是本末倒置吗?

我用 SwiftUI + AppKit 做出来之后,空载内存占用大概在几十 MB 量级,冷启动基本是秒开。而同等功能的 Electron 应用,空载轻松上百 MB,启动还要等运行时初始化。对于我这种会把它常驻在后台的人来说,这个差距是实打实的。

2.4 数据留在本地:会话记录的掌控感

网页版的会话记录存在服务端,你没法方便地导出、备份、搜索。原生客户端可以把会话历史存在本地,用 SQLite 或者直接写文件都行。我现在的做法是本地存一份,支持全文搜索,想找之前问过的某个方案,直接搜关键词就行,不用在网页里一条条翻。

提示:本地存储会话时要注意敏感信息的处理。如果你会把客户端分享给别人用,最好默认不落盘,或者提供加密选项,别把 API Key 和对话内容明文写进文件。

3. 技术选型:为什么是 SwiftUI + AppKit,而不是 Electron 或 Tauri

3.1 SwiftUI 负责界面,AppKit 补足系统能力

纯 SwiftUI 做 macOS 应用,日常界面完全够用,声明式写法开发效率很高。但有些系统级能力 SwiftUI 还没覆盖到,比如全局快捷键、菜单栏额外控制、更细粒度的窗口行为,这时候就得下沉到 AppKit。我的做法是:主界面和大部分交互用 SwiftUI,遇到 SwiftUI 搞不定的地方,用NSViewRepresentable或NSHostingView把 AppKit 组件桥接进来。

这种混合方案的好处是既享受了 SwiftUI 的开发效率,又不被它的能力边界卡住。代价是要理解两套框架的桥接方式,下面我会具体讲。

3.2 和 Electron、Tauri 的横向对比

方案内存占用启动速度系统集成开发门槛包体积
SwiftUI + AppKit低快最完整需 Swift 基础小
Electron高慢一般前端即可大
Tauri中中较好前端 + Rust中

选 SwiftUI 的核心理由是:我要的就是“原生”这两个字。如果只是想要个桌面壳,那用什么都行;但既然目标是贴合 macOS 体验,那用系统原生框架是最直接的路。Tauri 其实是个不错的折中,包体积和内存都比 Electron 好,但它的系统集成深度还是不如原生,而且引入 Rust 又增加了一层学习成本。

3.3 网络层与流式响应的处理思路

Gemini 的接口返回是流式的,也就是回答一个字一个字往外蹦。网页版天然支持这种体验,原生客户端要自己处理。我用的是URLSession的bytes异步序列,逐块读取返回数据,解析出增量文本后更新界面。

这里有个关键点:流式更新必须回到主线程刷新 UI,否则会崩。Swift 的@MainActor和async/await配合起来处理这个很顺手。另外要注意 SSE(Server-Sent Events)格式的解析,返回的每一块数据可能包含多个事件,也可能一个事件被拆成多块,得做好缓冲和边界处理,不能假设“一次读取就是一个完整事件”。

4. 动手实现:从零搭起一个能跑的原生客户端

4.1 项目骨架与依赖管理

我用 Xcode 建的是 macOS App 模板,语言选 Swift,界面选 SwiftUI。依赖管理用 Swift Package Manager,没有引入第三方 UI 库,保持干净。整个项目结构大致是:

GeminiClient/ ├── App/ │ ├── GeminiClientApp.swift // 入口 │ └── AppDelegate.swift // 处理全局快捷键等 ├── Views/ │ ├── ChatView.swift // 主对话界面 │ ├── InputBar.swift // 输入框 │ └── MessageBubble.swift // 消息气泡 ├── Services/ │ ├── GeminiService.swift // 网络请求与流式解析 │ └── StorageService.swift // 本地会话存储 ├── Models/ │ └── Message.swift // 数据模型 └── Utils/ └── KeychainHelper.swift // 安全存储 API Key

这个结构不复杂,但分层清晰:界面、服务、模型、工具各管各的,后面加功能不会乱。

4.2 全局快捷键的注册与冲突处理

全局快捷键用 AppKit 的NSEvent.addGlobalMonitorForEvents或者更底层的 Carbon 热键 API。我实际用的是后者,因为addGlobalMonitor只能监听,不能拦截,而且有些场景下不触发。Carbon 的RegisterEventHotKey更可靠,但它是 C API,在 Swift 里调用需要处理一些指针细节。

import Carbon.HIToolbox func registerHotKey() { var hotKeyRef: EventHotKeyRef? let hotKeyID = EventHotKeyID(signature: OSType(0x47454D49), id: 1) // "GEMI" let modifiers: UInt32 = UInt32(optionKey) let keyCode: UInt32 = UInt32(kVK_Space) RegisterEventHotKey(keyCode, modifiers, hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef) }

踩过的坑:快捷键一定要做冲突检测。我一开始设的Option + Space在某些输入法下会被占用,导致按了没反应。后来加了个设置界面让用户自己改,并且注册失败时给出提示,而不是默默失效。

4.3 流式响应的解析与界面刷新

网络请求部分,核心是构造正确的请求体,然后处理流式返回。Gemini 的接口用的是streamGenerateContent这类端点,返回的是分块数据。我用URLSession.shared.bytes(for:)拿到异步字节序列,逐行读取:

let (bytes, response) = try await URLSession.shared.bytes(for: request) for try await line in bytes.lines { guard line.hasPrefix("data: ") else { continue } let jsonString = String(line.dropFirst(6)) if let data = jsonString.data(using: .utf8), let chunk = try? JSONDecoder().decode(Chunk.self, from: data) { await MainActor.run { self.appendText(chunk.textDelta) } } }

这里有几个细节值得说。第一,bytes.lines会按行切分,但 SSE 的数据可能一行里包含多个字段,得按协议解析。第二,空行是事件分隔符,不能忽略。第三,网络中断要有重试和错误提示,不能让界面卡在“正在输入”状态。

4.4 API Key 的安全存储:别写进代码里

API Key 绝对不能硬编码在源码里,尤其是开源项目。我用的是系统 Keychain,通过Security框架读写。这样即使别人拿到你的代码,也拿不到你的 Key。用户第一次打开时引导输入,存进 Keychain,之后从 Keychain 读取。

func saveAPIKey(_ key: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: "gemini_api_key", kSecValueData as String: key.data(using: .utf8)! ] SecItemDelete(query as CFDictionary) SecItemAdd(query as CFDictionary, nil) }

注意:开源前一定要检查提交历史里有没有误提交过 Key。我见过不少项目在.gitignore加上配置文件之前,已经把带 Key 的文件提交上去了,后面删掉文件也没用,历史里还在。用git filter-repo之类的工具清理,或者干脆重新初始化仓库。

5. 开发中踩过的坑:那些文档里不会写的细节

5.1 SwiftUI 和 AppKit 桥接时的线程问题

SwiftUI 的视图更新默认在主线程,但如果你从后台线程直接改@Published属性,可能会遇到“Publishing changes from background threads is not allowed”的警告,严重时界面不刷新甚至崩溃。我的做法是所有网络回调统一用@MainActor标注,或者显式DispatchQueue.main.async包一层。

桥接 AppKit 组件时还有个坑:NSViewRepresentable的makeNSView和updateNSView调用时机和你想象的不一样。updateNSView可能在每次状态变化时都被调用,如果你在里面做了重操作,性能会很难看。我的经验是尽量让 AppKit 组件自己管理状态,SwiftUI 只负责传初始值和接收回调。

5.2 窗口管理与菜单栏应用的取舍

我一开始想做纯菜单栏应用,不占 Dock。但实际用下来发现,纯菜单栏应用在需要看长回答时很别扭,窗口太小。后来改成“主窗口 + 菜单栏快捷入口”的混合模式:平时收在菜单栏,需要完整对话时打开主窗口。这样兼顾了随手唤起和完整阅读两个场景。

窗口管理还有个细节:主窗口关闭后,应用不应该退出(macOS 惯例),而是回到菜单栏。这需要在AppDelegate里处理applicationShouldTerminateAfterLastWindowClosed返回false。

5.3 流式输出时的滚动抖动

流式输出时,文本不断追加,如果每次都自动滚到底部,用户想往上翻看历史就会被强行拉回来,体验很差。我的解决方案是:只有当用户当前就在底部附近时才自动滚动,一旦用户手动往上滚,就暂停自动滚动,并在底部显示一个“回到底部”的按钮。

判断“是否在底部”需要拿到NSScrollView的contentView.bounds和documentView的高度做比较。这个逻辑在 SwiftUI 的ScrollView里不太好做,我最后是用ScrollViewReader配合一个状态标记实现的,虽然不完美,但够用。

5.4 打包、签名与分发的现实问题

自己用的话,Xcode 直接跑就行。但要分发给别人,就绕不开签名和公证。没有开发者账号的话,别人下载后会被系统拦截,需要手动在“安全性与隐私”里放行。我开源的是源码,大家自己编译,就绕过了这个问题。如果你要分发二进制,建议老老实实走签名流程,否则用户安装体验会很差。

另外,开源许可证要选清楚。我选的是 MIT,宽松、简单,别人拿去改也好、商用也好,都没负担。如果你希望衍生作品也开源,那就选 GPL 系。这个在项目根目录放个LICENSE文件就行,别省这一步。

6. 开源之后:项目维护与后续可以怎么玩

6.1 开源项目的结构整理与文档

开源不是把代码传上去就完事。我花了不少时间写 README,把“这是什么”“怎么编译”“怎么配置 API Key”“有哪些已知问题”讲清楚。还放了截图和动图,让人一眼知道这东西长什么样。Issue 模板和 PR 模板也配上了,省得后面沟通成本太高。

文档这块我的原则是:假设读者完全不懂 macOS 开发,也能照着步骤跑起来。编译命令、Xcode 版本要求、依赖安装,全部写明白。很多开源项目卡在“跑不起来”这一步,就是因为文档默认读者什么都会。

6.2 可以继续扩展的方向

这个客户端目前是能用的状态,但可扩展的地方还很多。我列几个自己打算做的:

  • 多模型切换:现在只接了 Gemini,后面可以抽象一层,支持切换不同服务。
  • 会话导出:支持导出成 Markdown 或 PDF,方便归档。
  • 提示词模板:常用提问存成模板,一键调用。
  • 本地知识库:把常用文档喂进去,做检索增强。

这些都不难,关键是抽象好接口,别把代码写死。我现在的GeminiService就是按协议设计的,后面加新服务只要实现同一个协议就行。

6.3 给想自己动手的人几点实在建议

如果你看完也想给自己常用的服务做个原生客户端,我的建议是:先做最小可用版本,别一上来就追求功能齐全。我第一版只有“输入框 + 发请求 + 显示回答”三个功能,跑通之后再慢慢加全局快捷键、本地存储、菜单栏这些。这样每一步都有正反馈,不容易半途而废。

还有就是,别怕用 AppKit。很多人一听 AppKit 就觉得老、复杂,其实你只需要用到它的几个能力,不用系统学。遇到问题查文档、搜例子,边做边学,比先啃完一本教程再动手效率高得多。

最后分享一个我自己的小习惯:我会把开发过程中遇到的每个坑都记在一个 Markdown 文件里,包括现象、原因、解决办法。这个文件后来直接变成了项目的 FAQ 文档,一举两得。踩坑不可怕,踩完不记才可惜。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/9 4:14:14

Java后端TMS物流运输系统实战:运单状态机、调度派单与计费规则设计

简介:本资源为基于Java开发的TMS物流运输系统后端设计源码,面向具备一定Java基础、希望深入理解企业级物流系统架构的开发者与学习者。项目围绕订单管理、运输路线规划、货物跟踪等核心业务展开,涵盖调度、结算、车队管理、网关及通用模块&am…

作者头像 李华
网站建设 2026/10/9 4:13:45

SSM+JSP代驾系统毕设全解析:从技术选型到答辩准备

做这类“基于SSMJSP的代驾应用系统”毕设项目,我接触过不少同学的版本。说句实在话,题目看起来不难,但要把代驾业务从下单、派单、司机接单,再到计价、支付、评价,这一整条链路做成一个能演示、能答辩、能交付源码的系…

作者头像 李华
网站建设 2026/10/9 4:13:36

从ETL到EDA:数据准备全流程实战指南

在数据分析和机器学习项目里,我经常被问到同一个问题:“数据准备到底做到什么程度才算完?” 很多人跑完ETL(抽取、转换、加载)就直接建模,结果模型上线一塌糊涂;也有人在Jupyter里画了几个直方图…

作者头像 李华
网站建设 2026/10/9 4:13:07

区间和计数问题详解:前缀和、树状数组与离散化实战(P5459)

上周刷洛谷的时候,碰上了 P5459 [BJOI2016] 回转寿司 这道题。名字看着像模拟,结果是一道非常标准的“区间和计数”问题。我一开始想用双指针滑窗,卡了半天才反应过来,这题里每个寿司的价值 a_i 有正有负,前缀和根本不…

作者头像 李华
网站建设 2026/10/9 4:12:54

Mac mini轻量AI助理:B站评论自动响应实战方案

1. 项目概述:一台Mac mini如何扛起B站评论区的AI值守重担“运行8个月回复4500条评论”——这句话不是营销话术,是我把一台2020款M1芯片Mac mini塞进书桌抽屉后的真实日志。它没接显示器,没连键盘鼠标,只靠一根网线和一个Type-C电源…

作者头像 李华
网站建设 2026/10/9 4:11:46

Claude记忆增强实战:四组件构建长对话工作记忆系统

1. 项目概述:这不是一个独立工具,而是一次认知范式的悄然迁移“claude-mem”这个关键词最近在技术圈和AI应用社区里频繁浮现,但它不是官方发布的某个产品、插件或开源仓库,也没有对应的GitHub地址、Docker镜像或PyPI包名。它本质上…

作者头像 李华