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