我们刚刚发布了 Raycast 2.0 的公开 beta。这是自 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 是我们第一次在应用中为一个主要功能使用 web view。编辑器是一个挂载在原生窗口 web view 内的 React 应用。这是在测试我们能否完全用 Web 技术构建一个界面,同时不破坏应用其他部分的手感。它成功了,Notes 也被大量 macOS 和 iOS 用户每天使用。
虽然 v1 的核心是原生应用,但只要 Web 技术合适,我们一直会使用它。归根结底,人们喜欢 Raycast,是因为它的感觉,而不是因为底层用了什么。
为什么要重写
2023 年末,我们开始思考把 Raycast 带到 Windows。其实这从第一天起就是计划的一部分,但早期我们想专注于单个平台,先把体验打磨到位,再考虑扩展。
到那时,Raycast 也已经从一个 launcher 发展成更广义的生产力平台,拥有 AI Chat、Notes、扩展、同步、文件搜索等能力。最初为 launcher 构建的架构,开始限制我们接下来能构建什么。编译时间越来越长,AppKit 越来越频繁地挡路,能深入原生 macOS 的人也越来越难找。即使不考虑 Windows,我们也需要重新思考其中大部分内容。
于是我们开始为新的 Windows 客户端和现有 macOS 客户端一起寻找技术栈。不过首先,任何这样的项目都需要一个好代号。我们称它为“X-Ray”,意思是 cross-platform Raycast。
选择技术栈
我们先研究了 Windows 上构建原生应用有哪些选择——坦率说,那里原生 UI 框架的状态远谈不上好。Microsoft 有不断引入 UI 框架然后转向下一套的历史:WPF、UWP,以及现在仍然较年轻、没有被广泛实战检验的 WinUI 3。如果用 AppKit 在 macOS 上构建一个精致的原生应用已经很有挑战,那么用 WinUI 3 在 Windows 上做这件事,风险看起来大得多。此外,想到要运行两个独立的原生应用,我们也不太放心,因为 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 有类似限制。它在原生侧给你的控制更少,而且当时还足够年轻,我们不想把公司押在它上面。所以我们很快排除了它。
剩下的就是混合方案。事实证明,构建自己的原生 shell 来包裹系统 WebView,正好能给我们需要的东西:macOS 上一个正规的 Xcode 项目,Windows 上一个 Visual Studio 项目;完整访问平台 API;用系统自己的 WebView 做 UI;并完全控制各部分之间如何通信。为了验证这是否真的可行,我们早期做了一个原型。我们能做出半透明窗口吗?能在 WebView 内容上显示原生 tooltip 吗?它会看起来和感觉起来像 Raycast 吗?原型最终看起来几乎和原生应用一样。透明 web view 与窗口背景融合,tooltip 和 action panel 等元素使用原生 overlay。基本上就是我们花多年构建的同一套视觉语言。
不过,这不是银弹。这个方案有真实开销。除了构建应用本身,你本质上还在构建并维护 Electron 开箱即用提供的基础设施。WebView、原生 shell 和 Node.js 后端之间的 IPC,需要在每个平台上设置、调试和优化。没有社区替你解决这些问题。我们选择它,是因为 Raycast 的工作方式。这个取舍对大多数其他桌面应用并不划算。Electron 能把这些处理得足够好,并省下几个月基础设施工作。
我们还看过其他几个选项:Flutter、Qt、React Native for Desktop,以及在两个平台上都运行 Swift(向 The Browser Company 的勇气致敬,但我们没那么冒险)。不过我们很早就排除了它们。它们要么缺少我们需要的原生控制,要么对我们的用户规模来说还不够成熟,或者两者兼有。
它是如何构建的
从高层看,Raycast 2.0 由四部分组成:
- Host app: 每个平台都有自己的应用。macOS 上用 Swift + AppKit,Windows 上用 C# + .NET 8 和 WPF。应用控制所有必须平台原生的东西,比如设置窗口、监听全局快捷键、配置菜单栏或托盘,等等。它们也会在平台的 web view 中加载 Web 前端(macOS 上是 WKWebView,Windows 上是 WebView2),并监管 Node 后端。
- Web frontend: 前端是一个 React + TypeScript 项目,同时发布到两个平台。它包含所有 UI 代码,并为每个窗口构建独立入口(Launcher、AI Chat、Notes、Settings 等)。两个操作系统上的代码库相同。
- Node backend: 一个长期运行的 Node 进程负责应用的业务逻辑,比如数据库访问、扩展运行时、其他长期服务等。Node 是两个平台共同通信的共享层,这意味着功能工作只需要做一次。
- Rust core: 当性能或可移植性比便利性更重要时,我们使用 Rust。我们的数据层可以与 iOS 应用共享。云同步与服务端对应部分共享 schema。自定义文件索引器也经过高度优化,可以在几秒内扫描整个硬盘。
当多个运行时同时存在(Swift/C#、Node、WebView)时,不同层需要相互通信。我们混合使用平台 message handlers 和 stdio transport 来连接一切。为了让这件事安全可维护,接口在一个地方声明,并为每一侧生成类型化 client。这让我们在四个运行时之间获得编译期保证。
实践中,团队大多数人在 Web 前端和 Node 后端工作。功能主要在那里构建。只有当我们需要暴露新的 OS 能力,或为原生手感做优化(下面会讲)时,才会触碰原生 shell。一旦四部分之间的边界设定好,大多数产品工作就不需要跨越它们。
新文件索引器
在 v1 中,文件搜索依赖 Spotlight 元数据。它大体可用,但我们受限于 Spotlight 已索引的内容,而且它完全无法在 Windows 上工作。v2 中,我们用 Rust 从零构建了自己的文件索引器。它作为独立进程运行,直接扫描文件系统,构建搜索索引,并通过文件系统事件保持最新。
在 Windows 上,用常规方式遍历 NTFS 文件系统太慢,达不到我们需要的扫描时间。所以我们构建了专门的 NTFS 扫描器,直接读取 Master File Table——这是在几秒而不是几分钟内索引整块硬盘的唯一实用方式。
索引器是最能体现 Rust 性能价值的地方之一。扫描数十万个文件并构建搜索索引,需要在后台完成,同时不影响应用其他部分。可预测的内存使用和没有 GC pause,让这成为可能。
让它感觉像回到家
当你的 UI 运行在 WebView 中时,“原生感”到底意味着什么?对我们来说,它归结为一个简单测试:如果有人不知道 Raycast 是用什么构建的,他会不会以为这是一个普通 Mac 应用?如果任何东西感觉不对——错误的动画、不合适的 hover 状态、被窗口边缘裁切的 popover——那就是我们没做好。
我们的一位 Windows 工程师说得很好:我们不是一个撒了些原生 hook 的 Web 应用。我们是一个使用 Web 做 UI 的原生应用。这个区别决定了我们把时间花在哪里。下面大多数工作不是为了让东西看起来对,而是为了让东西行为对。
平台惯例
让一个 Web 应用在桌面上感觉不对,最简单的方法就是在已有原生惯例的地方沿用 Web 惯例。以下是我们刻意匹配或避免的一些事情:
- 交互控件上不使用
cursor: pointer。桌面应用不会这么做。这很小,但会立刻传达“这是个网站”。 - 大多数控件没有 hover 高亮。在 macOS 上,按钮和列表项不会像 Web 上那样 hover 高亮。
- Settings 在独立原生窗口中打开,而不是 modal 或侧边栏。
- Popovers 和 tooltips 渲染为原生窗口,而不是 WebView 内的 DOM 元素。它们可以像原生 popover 一样延伸到窗口边界之外。
- 在 macOS Tahoe 上,我们采用了 Apple 新的 Liquid Glass 材质,让 Raycast 从第一天起就融入系统更新后的视觉语言。
- 视图出现或转场时不闪烁。这是 Web 应用常见的破绽,我们做了很多工作来消除它。
这些都是显而易见的部分。更不显眼的工作在下面。
与 WebKit 协作,也绕过它
WebKit 是一个很好的渲染引擎,但它是为网页浏览构建的,不是为每天显示和隐藏数百次的桌面应用构建的。开箱状态下,它会做出一些对 Safari 完全合理、但会给我们造成问题的假设。我们花了很多时间学习如何绕过它们。
- 节流。 当 WebKit 认为一个 view 不可见时,会节流
requestAnimationFrame、CSS 动画和 timers。对一个不断显示和隐藏的 launcher 来说,这会破坏体验。我们的绕法是把窗口置前但保持视觉隐藏(alphaValue = 0),并禁用 WebKit 的遮挡检测(windowOcclusionDetectionEnabled = false)。就在显示窗口之前,我们在一个requestAnimationFrame中触发渲染,以避免闪烁。 - 被遮挡区域渲染。 当 Raycast 从紧凑模式扩展到全尺寸模式时,WebKit 会让之前隐藏的区域空白一两帧——它在节流自己认为“viewport 外”的区域。我们通过让 WKWebView frame 始终保持扩展尺寸来修复,即使窗口本身处于紧凑状态。WebView 会渲染到窗口可见边界之外,所以当窗口扩展时,内容已经在那里了。
- 窗口调整大小。 WebKit 会在动画式窗口 resize 期间暂停绘制,导致可见卡顿。我们通过 override
NSWindow.setFrame并用隐式 Core Animation 替代动画调用来绕过它,这样 WebView 可以在窗口 resize 时继续渲染。 - 窗口打开时闪烁。 我们使用
_doAfterNextPresentationUpdate(一个用于同步渲染状态和原生呈现的 WebKit API),确保 WebView 完成绘制后窗口才变为可见。否则你会看到陈旧或空白内容一闪而过。 - Emoji 渲染。 我们的 emoji picker 一开始很慢,因为 WebKit 会为每个 emoji glyph 在字体链中回退查找。修复方法最后很简单:启动时预热 emoji 字体。但我们花了一段时间才弄清实际发生了什么。
我们还构建了基础设施,可以在运行时切换 WebKit Feature Flags(也就是 Safari Develop 菜单中可用的那些)。内部我们用它来解锁 60 FPS 上限,并启用 requestIdleCallback 来调度非关键工作。
在 Windows 上
WebView2 基于 Chromium,而 Chromium 对节流、渲染和进程管理有自己的想法。要让 acrylic blur-behind 效果和自定义标题栏协同工作,需要原生 shell 和 WebView2 runtime 之间非常细致的配合。我们自己控制所有初始化参数,这让我们可以避免 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 shell):约 40 MB
- WebKit GPU 进程:约 18 MB
- WebKit Networking:约 12 MB
原生 shell 很轻。窗口隐藏时,WebKit GPU 进程会降到 20 MB 以下(你主动使用 Raycast 时可能飙得更高,但关闭窗口后这部分内存会释放)。两个主要成本是 WebView 和 Node 后端。
作为对比,一个没有内容的空 WebView 基线成本约为 50 MB,一个没有 imports 的裸 Node.js 进程约为 12 MB。这些基线是取舍的一部分。其余部分是我们的应用代码、已加载模块、图标和缓存资源,这是我们可以控制并持续优化的。
不是所有内存都一样
这并不意味着更高的占用无关紧要,但它有助于解释你在 Activity Monitor 中看到的数字。当你在 Mac 上打开 Activity Monitor 时,每个进程显示的数字并没有看起来那么直接。macOS 会积极使用可用 RAM:缓存文件、压缩不活跃页面,并把东西留在内存里,让系统更快。
有几件值得知道的事:
- 压缩内存。 当物理 RAM 变得紧张时,macOS 会压缩不活跃页面,而不是把它们写入磁盘。这很快,也意味着一个看起来使用 200 MB 的进程,实际成本可能低得多。一个空闲但 heap 很大的 Node 后端压缩效果很好。
- Dirty pages vs clean pages。 并非所有 resident memory 都同样昂贵。Clean pages(如映射的二进制代码)可以被丢弃并免费从磁盘重读。Dirty pages(如 V8 heap 或解码后的图片)才是真正有成本的部分。让我们的 binary 在磁盘上变大的大部分内容,是操作系统可以立即回收的 clean memory。
- 共享 frameworks。 Activity Monitor 会把系统 framework 内存(WebKit、系统库)计入每个使用它们的进程。当你把 Raycast 所有进程的数字相加时,其实是在重复计算共享页面。真实系统成本低于 Activity Monitor 暗示的数字。
- Memory Pressure 才是真正重要的。 Activity Monitor 内存页底部的图表,才是判断 Mac 是否吃紧的真实指标。如果它是绿色,系统就有充足空间,即使单个进程数字看起来很高。操作系统正在做自己的工作:使用可用 RAM 保持快速,并在别的东西需要时准备归还。
这些都不是粗心使用内存的借口。我们跟踪 phys_footprint(最接近 Activity Monitor 显示值的指标),并积极降低它。开发过程中我们已经显著降低了 v2 的 footprint——早期构建比今天高得多。我们也特别在低内存机器上测试,因为那里最重要。但我们希望读者在看这些数字时拥有正确心智模型。
撇开内存,v2 有些地方明显快于 v1。
- 搜索。 v2 中的根搜索包含完整文件搜索,由新的 Rust 文件索引驱动。在 v1 中,文件搜索只能通过单独命令使用,并依赖 Spotlight 元数据。新的索引器直接搜索你的文件,不依赖 Spotlight,同时保持搜索体验其他部分响应迅速。
- 文本渲染。 AI Chat 以及任何涉及富文本渲染的功能,都是 WebKit 真正发光的地方。几十年来为 Web 优化的文本排版和渲染在这里体现出来:滚动长对话、渲染 markdown、处理带语法高亮的代码块。macOS 上的 TextKit 有能力,但 WebKit 在这类工作负载上获得了更多投入。
我们还没完成。内存和性能仍是活跃重点,我们知道还有改进空间。团队正在进一步降低稳态 footprint,让更多前端和后端懒加载,优化图标和图片处理,并收紧 V8 heap。毕竟,它还在 beta。
取舍
没有免费的重写。下面是变好的地方和变难的地方。
变好的地方
先说积极面。下面是我们觉得 Raycast 第二版中得到改善的部分:
- 开发速度。 这是最大的变化。热重载意味着 UI 改动会在一秒内出现,而 v1 中需要重新编译 Swift target 并重启应用。我们可以更快原型、迭代和修 bug。这直接惠及用户:功能更快发布,修复更快落地。
- 一个团队,两个平台。 大多数产品工作发生在共享 Web 前端和 Node 后端。当我们发布一个功能,它会同时在 macOS 和 Windows 上工作。在 v1 中,每个 UI 改动按定义都只属于 macOS。额外好处是,移动团队也会受益于 Rust model layer 和新的同步引擎。
- 招聘。 找能做 React、TypeScript 和 Node 的工程师,要比找深度 AppKit 经验的工程师容易得多。这并不意味着我们不再需要原生工程师——我们仍有专门的 Swift 和 C# 工程师负责 host apps——但大多数产品工作不再需要专门平台知识。
- 更丰富的 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 codegen 有助于保持同步,但这套技术栈客观上比单语言原生应用更复杂。
- Windows 多样性。 Windows 是一个比 macOS 多样得多的平台。用户运行不同 OS 版本、硬件配置和显示设置——8 GB RAM 配 4K 显示器和较旧 CPU 并不罕见。使用系统 WebView 也意味着不同机器上的 WebView2 版本可能不同,所以我们需要考虑不同渲染行为和 API 可用性。要测试的表面积更大,要处理的边缘情况更多。
- 一些原生细节更难。 在 AppKit 中免费获得的东西——比如某些辅助功能行为、拖放边缘情况或 IME 处理——在 WebView 中需要显式工作。我们已经处理了重要部分,但平台行为中还有很长一条小尾巴需要关注,我们仍在推进。
- 按需启动窗口。 在 v1 中,AI Chat 和 Notes 这样的窗口一旦被调用,就会一直保留在内存中,所以你按下快捷键时它们会立刻出现。在 v2 中,我们更积极地销毁不活跃窗口以控制内存,这意味着冷启动打开它们时会有短暂延迟。我们正在寻找正确平衡:加入宽限期,让你在快速切换时窗口保持温热,同时在不使用时仍回收内存。
我们认为这些取舍是值得的。不是因为缺点不重要,而是因为开发速度、跨平台覆盖和招聘上的收益,会随时间直接转化为更好的产品。更难的部分可以通过工程努力解决。更好的部分则很难通过其他方式获得。
接下来去哪里
如果你读到了这里,可能期待我们给出哪种方案“最好”的判决。我们其实不这样思考。我们把代码视为达成目的的手段。对我们重要的是产品,而不是技术栈。我们是自己的用户,每天在自己拥有的每台机器上使用 Raycast;如果感觉不对,我们就不会发布。这就是标准,也是这次重写花了这么久的原因。
Raycast 2.0 现在已经进入公开 beta。如果有什么感觉不对、感觉慢,或感觉不像 Raycast,请告诉我们。这正是我们现在需要的反馈。
简单感谢一下完成这件事的团队。从一个原型开始,如今它已经到了每个想尝试它的人手里。没有大量努力和对细节的反复打磨,这不可能发生。
我们做这件事,是为了继续推动桌面生产力的意义,尤其是在 AI 正在改变人们与机器交互方式的当下。有了这个新代码库,我们可以快速前进,在两个平台上发布高质量应用,并始终贴近用户真正需要的东西。后面还有很多。很快再见!