我们刚刚发布了 Raycast 2.0 的公开测试版。这是自 2020 年首次发布 Raycast 以来最大的一次版本更新,也是第一个同时运行在 macOS 和 Windows 上的版本。


为了做到这一点,我们从头重写了整个应用。新的架构,以及一套混合了 TypeScript、Swift、C#、Rust、Node 和 React 的技术栈。Web 技术从一开始就是 Raycast 的一部分,为扩展和 Notes 提供支撑。在 v2 中,我们进一步加大了这方面的投入,同时依然让应用保持一贯的原生感和速度。
如果说发布文章讲的是新功能,那么这篇文章讲的就是它是如何构建出来的。重写背后的故事,我们一路上做出的取舍,以及完成一次这种规模的重写到底需要什么。难点并不在于让 Raycast 跑起来。真正难的是让它感觉对。
起点
Raycast v1 的核心是一款基于 AppKit、用 Swift 构建的原生 macOS 应用。我们几乎从不使用标准 UI 组件。它们并不是为我们关心的那种键盘优先、面向高阶用户的工作流设计的,所以我们自己构建了一切。每一行列表、每一个快捷键、每一种默认行为,都是由我们自己处理的。我们也没有大量使用 SwiftUI。它和 Raycast 并行成熟,但在性能和控制力上一直没有真正达到我们的标准。v1 中唯一使用它的地方是每年的 Wrapped 功能,而且它和应用的其他部分隔离得很好。
扩展生态则完全建立在另一套技术栈上:React、TypeScript 和 Node.js。UI 以声明式方式描述,并由原生应用负责渲染。Felix 曾在这篇文章里详细介绍过这套架构。为第三方开发者选择一套熟悉的技术栈,是如今商店里能有数千个扩展、覆盖几乎所有常用工具的重要原因。API 从设计上也具备可移植性。扩展本身并不假设自己运行在 macOS 上,这让我们去年得以把目录中的很大一部分带到 Windows 上。
Raycast Notes 是我们第一次在应用的一个重要功能中使用 WebView。编辑器是一个挂载在原生窗口 WebView 里的 React 应用。它是一次验证:我们能否完全用 Web 技术构建一个界面,同时又不破坏应用其他部分的手感。结果证明可行,而且 Notes 如今被大量 macOS 和 iOS 用户日常使用。
虽然 v1 的核心是原生应用,但只要 Web 技术更合适,我们一直都会采用它。归根结底,人们喜欢 Raycast 是因为它用起来的感觉,而不是因为它底层用了什么。
为什么要重写
2023 年底,我们开始认真思考把 Raycast 带到 Windows 上。从第一天起,这就是计划的一部分。但在早期,我们想先专注于单个平台,把体验打磨到位,再考虑扩展到更多平台。
到了那个时候,Raycast 也已经从一个启动器成长为更广义的生产力平台,包含 AI Chat、Notes、扩展、同步、文件搜索等能力。最初那套为启动器设计的架构,开始限制我们接下来能构建的东西。编译时间越来越长,AppKit 越来越频繁地挡在路上,而能深入原生 macOS 开发的人也越来越难找。即使没有 Windows 这件事,我们也需要重新思考其中的大部分内容。
于是,我们开始为新的 Windows 客户端和现有的 macOS 客户端一起寻找技术栈。不过首先,任何这样的项目都需要一个好代号。我们称它为 “X-Ray”,意思是跨平台的 Raycast。
选择技术栈
我们从审视 Windows 上可用于构建原生应用的方案开始。坦白说,那里的原生 UI 框架状态远不算理想。Microsoft 有一段不断推出 UI 框架、然后又转向下一套方案的历史:WPF、UWP,以及现在的 WinUI 3。WinUI 3 仍然相当年轻,也还没有经历足够广泛的实战检验。如果说用 AppKit 在 macOS 上构建一款精致的原生应用已经很有挑战,那么在 Windows 上用 WinUI 3 做同样的事,风险看起来要大得多。此外,想到要运行两套彼此独立的原生应用,我们也很不安,因为 Raycast 的大多数扩展本应在两个平台上表现一致。维护两套独立的 UI 技术栈,意味着工作量翻倍,却并不会让我们更快。
因此,完全原生的路线很快就被排除了。而且由于 Raycast 代码库的大部分都是 UI,我们也不能只是共享一个后端,然后为每个平台构建独立前端。这把我们引向了基于 Web 的技术栈。它天然提供跨平台 UI、庞大的库生态、优秀的开发体验,以及比原生桌面开发大几个数量级的人才池。Raycast 扩展本来就建立在 Web 技术栈上,而且一直运行得很好,所以探索把它用于整个应用显得很自然。即使我们只为 Windows 构建,Web 也是合理的选择(Microsoft 也把混合应用列为构建桌面应用的推荐路径之一)。它天然跨平台这一点,也让它值得在 macOS 上一并考虑。
于是我们评估了三个选项:Electron、Tauri,以及构建自己的混合技术栈。
Electron 是显而易见的选择。老实说,对大多数公司来说,它可能就是发布桌面应用的正确选择。它维护良好、经过实战检验,并且有庞大的生态。VS Code、Linear 或 Superhuman 这样的应用证明了你可以用它做出优秀的产品。Apple 和 Microsoft 并没有让大团队在它们的平台上创建复杂桌面应用变得容易,这也是 Electron 填补这个空缺的原因。真心地说,我们认为这是一件好事。
但对 Raycast 来说,它并不是最合适的。我们的应用和操作系统深度集成。我们依赖全局快捷键、剪贴板管理、辅助功能 API、窗口管理、能浮在其他应用上方且不抢焦点的自定义面板,以及更多能力。我们需要访问底层原生代码,才能精细控制应用行为。哪怕是内部面板的半透明效果这种小细节,对我们也非常重要。Electron 可以做到其中一部分,但 Web 和原生代码之间的边界可能会很痛苦。我们也不想在 macOS 上捆绑 Chromium,尤其是在可以使用系统 WebKit 的情况下。简单来说,我们需要确保自己掌控技术栈的每个部分,并且能在需要时轻松退回到原生实现。Electron 不是这件事的最佳选择。
Tauri 也有类似限制。它在原生侧给你的控制更少,而且在当时仍然年轻到我们不想把公司的未来押在它上面。所以我们也很快排除了它。
剩下的就是混合方案。事实证明,构建我们自己的原生壳、并用它包裹系统 WebView,正好给了我们需要的一切。在 macOS 上是一个正经的 Xcode 项目,在 Windows 上是一个 Visual Studio 项目。完整访问平台 API。用系统自己的 WebView 来承载 UI。并且完全控制各个部分之间如何通信。为了验证这条路是否真的可行,我们很早就做了一个原型。我们能实现半透明窗口吗?能在 WebView 内容上方显示原生 tooltip 吗?它看起来和用起来会像 Raycast 吗?原型的效果几乎和原生应用一模一样。透明的 WebView 与窗口背景融合,tooltip 和 action panel 之类的东西使用原生覆盖层。整体上就是我们花了多年建立起来的同一种视觉语言。
不过,这并不是银弹。这种方案有实实在在的开销。除了构建应用本身之外,你实际上还要构建并维护 Electron 开箱即用提供给你的那套基础设施。WebView、原生壳和 Node.js 后端之间的 IPC 需要按平台搭建、调试和优化。没有社区替你解决这些问题。我们选择它,是因为 Raycast 的工作方式决定了它值得这样做。对大多数其他桌面应用来说,这个取舍并不划算。Electron 已经能把这些事处理得足够好,还能帮你省下数月的基础设施工作。
我们还看过 Flutter、Qt、React Native for Desktop,以及在两个平台上都运行 Swift(向 The Browser Company 的勇气致敬,但我们没那么冒险)。不过这些方案都很早就被排除了。它们要么缺少我们需要的原生控制力,要么对我们的用户规模来说还不够成熟,或者两者兼有。
它是如何构建的
从高层看,Raycast 2.0 由四个部分组成:
- 宿主应用: 每个平台都有自己的应用。macOS 上用 Swift 和 AppKit 编写,Windows 上用 C#、.NET 8 和 WPF 编写。应用负责所有必须平台原生的事情,比如创建窗口、监听全局快捷键、配置菜单栏或托盘等等。它们也会在平台 WebView 中加载 Web 前端(macOS 上是 WKWebView,Windows 上是 WebView2),并监管 Node 后端。
- Web 前端: 前端是一个同时发布到两个平台的 React + TypeScript 项目。它包含所有 UI 代码,并为每个窗口构建独立入口(Launcher、AI Chat、Notes、Settings 等)。两个操作系统使用同一套代码库。
- Node 后端: 一个长期运行的 Node 进程负责应用的业务逻辑,例如数据库访问、扩展运行时、其他长期运行的服务等等。Node 是两个平台共同对话的共享层,这意味着功能开发只需要做一次。
- Rust 核心: 在性能或可移植性比便利性更重要的地方,我们使用 Rust。我们的数据层可以和 iOS 应用共享。云同步和服务器端共享一套 schema。而自定义文件索引器则经过高度优化,可以在数秒内扫描完整硬盘。
由于同时存在多个运行时(Swift/C#、Node、WebView),不同层之间需要彼此通信。我们混合使用平台消息处理器和 stdio 传输来连接所有部分。为了让这些接口安全可维护,我们把接口声明在一个地方,并为每一侧生成带类型的客户端。这样一来,四个运行时之间都能获得编译期保证。
实际工作中,团队的大部分成员主要在 Web 前端和 Node 后端中工作。功能就是在那里构建出来的。当我们需要从操作系统暴露新能力,或针对原生手感做优化(下文会讲)时,才会触碰原生壳。一旦四个部分之间的边界确定下来,大多数产品工作就不需要跨越这些边界。
新的文件索引器
在 v1 中,文件搜索依赖 Spotlight 元数据。它(大体上)可用,但我们受限于 Spotlight 已经索引的内容,而且它完全无法在 Windows 上工作。在 v2 中,我们用 Rust 从零构建了自己的文件索引器。它作为独立进程运行,直接扫描文件系统,构建搜索索引,并通过文件系统事件保持更新。
在 Windows 上,用普通方式遍历 NTFS 文件系统太慢,无法达到我们需要的扫描时间。所以我们构建了专用的 NTFS 扫描器,直接读取 Master File Table。这是把整块硬盘的索引时间从数分钟降到数秒的唯一现实做法。
索引器是 Rust 性能最重要的用武之地之一。扫描数十万文件并构建搜索索引,需要在后台完成,同时不能影响应用的其他部分。可预测的内存使用和没有 GC 暂停,使这成为可能。
让它感觉像本机应用
当 UI 运行在 WebView 里时,“感觉原生”到底是什么意思?对我们来说,它归结为一个简单测试:如果有人在不知道 Raycast 是用什么构建的情况下使用它,他们会觉得这是一款普通的 Mac 应用吗?如果任何地方感觉不对——错误的动画、不属于这里的 hover 状态、在窗口边缘被裁切的 popover——那就说明我们还没做好。
我们的一位 Windows 工程师说得很好:我们不是一个在上面撒了一些原生 hook 的 Web 应用。我们是一款使用 Web 来构建 UI 的原生应用。这个区别决定了我们把时间花在哪里。下面的大部分工作并不是为了让东西看起来对,而是为了让东西行为上对。
平台约定
让一个 Web 应用在桌面上显得不对劲,最简单的方式就是在已有原生约定的地方照搬 Web 约定。以下是一些我们刻意匹配或避免的事情:
- 交互控件上不使用
cursor: pointer。桌面应用不会这样做。这很小,但会立刻传递出“这是个网站”的信号。 - 大多数控件不使用 hover 高亮。在 macOS 上,按钮和列表项不会像 Web 上那样在 hover 时高亮。
- 设置在单独的原生窗口中打开,而不是 modal 或侧边栏。
- Popover 和 tooltip 渲染为原生窗口,而不是 WebView 内部的 DOM 元素。它们可以像原生 popover 一样延伸到窗口边界之外。
- 在 macOS Tahoe 上,我们采用了 Apple 新的 Liquid Glass 材质,让 Raycast 从第一天起就融入系统更新后的视觉语言。
- 视图出现或切换时不闪烁。这是 Web 应用常见的破绽,而我们做了大量工作来消除它。
这些是显而易见的部分。下面则是没那么明显的工作。
与 WebKit 协作,也绕过 WebKit
WebKit 是很棒的渲染引擎,但它是为浏览网页而构建的,不是为一天中会显示和隐藏数百次的桌面应用构建的。开箱即用时,它会做一些对 Safari 完全合理、但会给我们带来问题的假设。我们花了很多时间学习如何绕过这些问题。
- 节流。 当 WebKit 认为某个视图不可见时,它会节流
requestAnimationFrame、CSS 动画和定时器。对于一个不断显示和隐藏的启动器来说,这会破坏很多东西。我们的绕过方式是把窗口置前,但在视觉上保持隐藏(alphaValue = 0),并禁用 WebKit 的遮挡检测(windowOcclusionDetectionEnabled = false)。在真正显示窗口之前,我们会在一次requestAnimationFrame中触发渲染,以避免闪烁。 - 被遮挡区域的渲染。 当 Raycast 从紧凑模式扩展到完整尺寸时,WebKit 会让之前隐藏的区域空白一两帧——它在节流自己认为“视口之外”的区域。我们通过让 WKWebView 的 frame 始终保持扩展后的尺寸来修复它,即使窗口本身仍处于紧凑状态。WebView 会渲染到窗口可见边界之外,因此当窗口扩展时,内容已经在那里了。
- 窗口调整大小。 在动画式调整窗口大小期间,WebKit 会暂停绘制,导致可见卡顿。我们的绕过方式是重写
NSWindow.setFrame,用隐式 Core Animation 替代动画调用,让 WebView 在窗口调整大小时继续渲染。 - 窗口打开时闪烁。 我们使用
_doAfterNextPresentationUpdate(一个用于同步渲染状态和原生呈现的 WebKit API),确保 WebView 已完成绘制后窗口才变为可见。否则你会看到陈旧或空白内容一闪而过。 - Emoji 渲染。 我们的 emoji 选择器最初很慢,因为 WebKit 会为每个 emoji 字形沿着字体链逐级回退。修复方式最后很简单——启动时预热 emoji 字体——但弄清楚实际发生了什么花了不少时间。
我们还构建了在运行时切换 WebKit Feature Flags 的基础设施(也就是 Safari 开发菜单里那些同样的开关)。我们在内部用它解除 60 FPS 限制,并启用 requestIdleCallback 来调度非关键工作。
在 Windows 上
WebView2 基于 Chromium,而 Chromium 对节流、渲染和进程管理都有自己的想法。要让 acrylic 背景模糊效果在自定义标题栏下正常工作,需要原生壳和 WebView2 运行时之间非常谨慎的协调。我们自己控制所有初始化参数,这让我们能避免 WebView2 应用启动时常见的白色矩形闪烁。
管理多个窗口也比 macOS 上更复杂:每个窗口都需要自己的 WebView2 environment,并配置正确的 acrylic 效果、自定义 chrome 和输入处理组合。而且我们还必须做专门工作,确保当窗口没有焦点时 Chromium 不会节流 WebView,因为 Raycast 经常需要在其他应用背后继续更新。
内存与性能
对基于 Web 的桌面应用最常见的批评是:它们慢、臃肿、吃内存。这是合理的担忧,我们也想诚实回应。
简短版本是:是的,Raycast v2 比 v1 使用更多内存。这个增长是真实的,但它也是有边界、可测量,并且可以持续改进的。团队把性能和内存视为一等优先级,而不是以后有空再处理的东西。
数字
Raycast v1(完全原生 UI,扩展使用 Node 后端)在使用一段时间后通常会停留在 200–300 MB 左右。类似场景下,Raycast v2 大约在 350–450 MB。具体数字取决于你有多少扩展、使用哪些功能,以及加载了多少内容。
这确实更高,我们也不打算掩饰。随着内存优化成为当前重点,这些数字也不是最终结果;我们预计在退出 beta 阶段时能进一步降低它们。下面是 v2 在主窗口隐藏时的粗略内存拆分(这也是 Raycast 大多数时间所处的状态):
- WebView(WebContent):约 120–200 MB
- Node.js 后端:约 150–200 MB
- 原生应用(Swift 壳):约 40 MB
- WebKit GPU 进程:约 18 MB
- WebKit Networking:约 12 MB
原生壳很轻量。当窗口隐藏时,WebKit GPU 进程会降到 20 MB 以下(你主动使用 Raycast 时可能会升高,但关闭窗口后这部分内存会释放)。两个主要成本是 WebView 和 Node 后端。
作为对比,一个不加载任何内容的空 WebView 基线成本大约是 50 MB,一个不导入任何模块的裸 Node.js 进程大约是 12 MB。这些基线是取舍的一部分。其余则是我们的应用代码、已加载模块、图标和缓存资源,而这些是我们能控制并持续优化的东西。
并非所有内存都一样
这并不意味着更高的占用不重要,但它有助于解释你在活动监视器中看到的东西。在 Mac 上打开活动监视器时,你看到的每个进程的数字并不像表面上那么简单。macOS 会积极使用可用内存:缓存文件、压缩不活跃页面,并把内容留在内存里,让系统更快。
有几件事值得了解:
- 压缩内存。 当物理内存变紧张时,macOS 会压缩不活跃页面,而不是把它们写到磁盘。这很快,也意味着一个看起来使用了 200 MB 的进程,实际成本可能低得多。一个空闲但堆很大的 Node 后端很容易被压缩。
- 脏页与干净页。 并非所有驻留内存的代价都一样。干净页(比如映射的二进制代码)可以被丢弃,并免费从磁盘重新读取。脏页(比如 V8 堆或解码后的图片)才是真正有成本的部分。我们二进制文件在磁盘上变大的大部分内容,都是系统可以立刻回收的干净内存。
- 共享框架。 活动监视器会把系统框架内存(WebKit、系统库)计入每个使用它们的进程。当你把 Raycast 所有进程的数字加总时,你是在重复计算共享页面。真实的系统成本低于活动监视器暗示的数字。
- Memory Pressure 才是真正重要的指标。 活动监视器“内存”标签页底部的图表,才是判断你的 Mac 是否吃力的真实指标。如果它是绿色的,即使单个进程数字看起来很高,系统也仍然有足够空间。操作系统正在做自己的工作:使用可用内存来保持速度,并在其他东西需要时随时交还。
这些都不是让我们可以随意浪费内存的借口。我们跟踪 phys_footprint(最接近活动监视器显示值的指标),并持续努力降低它。我们已经在开发过程中显著削减了 v2 的占用——早期构建比现在高得多。我们也会特别在低内存机器上测试,因为这才是它最重要的地方。但我们希望读者在看这些数字时,有正确的心智模型。
除了内存之外,v2 在一些地方明显快于 v1。
- 搜索。 v2 的根搜索包含由新的 Rust 文件索引提供支持的完整文件搜索。在 v1 中,文件搜索只能通过单独命令使用,并依赖 Spotlight 元数据。新的索引器直接搜索你的文件,不依赖 Spotlight,同时让搜索体验的其他部分保持响应。
- 文本渲染。 AI Chat 以及任何涉及富文本渲染的功能,都是 WebKit 真正发光的地方。Web 在文本布局和渲染上的数十年优化在这里体现得很明显:滚动长对话、渲染 markdown、处理带语法高亮的代码块。macOS 上的 TextKit 也有能力,但 WebKit 在这种工作负载上得到了更多投入。
我们还没完成。内存和性能仍是当前重点,我们知道还有改进空间。团队正在进一步降低稳定状态下的占用,让更多前端和后端内容按需懒加载,优化图标和图片处理,并收紧 V8 堆。毕竟,它仍然还在 beta 阶段。
取舍
任何重写都有代价。下面是变得更好的部分,以及变得更难的部分。
哪些变好了
先说积极的一面。下面是我们认为 Raycast 第二个大版本中得到改善的地方:
- 开发速度。 这是最大的一点。热重载意味着 UI 改动能在一秒内出现,而 v1 中需要重新编译 Swift target 并重启应用。我们可以更快地原型验证、迭代和修 bug。这会直接惠及用户:功能更早发布,修复更快落地。
- 一个团队,两个平台。 大多数产品工作发生在共享的 Web 前端和 Node 后端中。当我们发布一个功能时,它会同时在 macOS 和 Windows 上工作。在 v1 中,每个 UI 改动按定义都只属于 macOS。额外的好处是,移动团队也会受益于 Rust 模型层和新的同步引擎。
- 招聘。 找到能做 React、TypeScript 和 Node 的工程师,比找到有深厚 AppKit 经验的工程师容易得多。这并不意味着我们不再需要原生工程师——我们仍然有专门的 Swift 和 C# 工程师负责宿主应用——但大多数产品工作不再需要专门的平台知识。
- 更丰富的 UI。 有些东西用 Web 技术栈就是更容易做好:富文本编辑、markdown 渲染,以及带动画的复杂布局。Notes 和 AI Chat 都从中受益。它也为编辑、解析和渲染等领域提供了成熟构件,同时仍然让我们掌控那些让 Raycast 感觉像 Raycast 的部分。
- 扩展更简单。 由于 Node.js 现在随应用打包,当你第一次从 Store 安装扩展时,不再需要单独下载它。而且因为应用本身和扩展运行在同一套技术栈上(React、TypeScript、Node),构建内部功能和构建扩展的感觉几乎一样。
哪些变难了
一切并不完美,所以这里是更复杂技术栈带来的代价:
- 更高的内存基线。 如前一节所述,v2 比 v1 使用更多内存。WebView 和 Node 进程带来了完全原生应用没有的基线成本。使用 Web 技术栈也可以保持低内存,只是需要更有意识地努力。我们正在积极缩小差距,并预计这些数字会随着退出 beta 阶段继续下降。
- 技术栈复杂度。 四个运行时(Swift 或 C#、Node、WebView、Rust)意味着更多移动部件。调试一个问题时,你可能会从 React 前端一路经过 IPC 到 Node 后端,再进入 Rust 模块。带类型的 IPC 代码生成有助于保持同步,但这套栈客观上比单语言原生应用复杂。
- Windows 的多样性。 Windows 是一个比 macOS 多样得多的平台。用户运行不同的系统版本、硬件配置和显示设置——8 GB 内存配 4K 显示器和较旧 CPU 并不罕见。使用系统 WebView 也意味着不同机器上的 WebView2 版本可能不同,因此我们需要考虑不同的渲染行为和 API 可用性。需要测试的表面积更大,要处理的边缘情况也更多。
- 一些原生便利变得更难。 在 AppKit 中免费获得的东西——比如某些辅助功能行为、拖放边缘情况或 IME 处理——在 WebView 中都需要显式工作。我们已经处理了重要部分,但仍有一长串细小的平台行为需要关注,我们还在继续推进。
- 按需启动窗口。 在 v1 中,AI Chat 和 Notes 这样的窗口一旦被调用,就会一直保留在内存中,因此你按下快捷键时它们会立刻出现。在 v2 中,我们会更激进地销毁不活跃窗口以控制内存,这意味着冷启动打开时会有短暂延迟。我们正在努力找到正确平衡:加入宽限期,让你快速切换时窗口保持温热,同时在你不使用时仍然回收内存。
我们认为这些取舍是值得的。不是因为缺点不重要,而是因为开发速度、跨平台覆盖和招聘方面的收益,会随着时间直接转化为更好的产品。更难的部分可以通过工程投入解决。而更好的部分,如果走其他路线,会非常难获得。
接下来
如果你读到了这里,可能会期待我们给出一个关于哪种方案“最好”的结论。我们并不真的这么看问题。代码对我们来说是达到目的的手段。我们关心的是产品,而不是技术栈。我们自己就是用户,每天在自己拥有的每台机器上使用 Raycast;如果某个东西感觉不对,我们就不会发布。这就是标准,也是这次重写花了这么久的原因。
Raycast 2.0 现在已经进入公开 beta。如果有什么地方感觉不对、感觉慢,或者感觉不像 Raycast,请告诉我们。这类反馈正是我们现在最需要的。
也向完成这件事的团队说一声谢谢。一个从原型开始的东西,现在已经交到了所有想尝试它的人手里。没有巨大的努力和对细节的反复打磨,这不可能发生。
我们做这件事,是为了继续推动桌面生产力的边界,尤其是在 AI 正在改变人们与机器互动方式的当下。有了这套新的代码库,我们可以快速前进,在两个平台上发布高质量应用,并且始终贴近用户真正需要的东西。后面还有很多内容。很快再见!