Logo
热心市民王先生

遇到的挑战与解决方案

从 Native 迁移到 Web 技术栈并非一帆风顺。Raycast 团队面临了 WebView 渲染、窗口管理、内存优化等多方面的技术挑战。本章深入剖析这些挑战及对应的工程解决方案。

挑战一:WebKit 的 Throttling 问题

问题本质

WebKit 是为网页浏览设计的渲染引擎,对于 Raycast 这类频繁显示/隐藏的桌面应用,它做出了一些”合理但有害”的假设:

“WebKit is a great rendering engine, but it was built for web browsing, not for a desktop app that shows and hides hundreds of times a day.”

(WebKit 是一个很棒的渲染引擎,但它是为网页浏览设计的,而不是为一天显示和隐藏数百次的桌面应用设计的。)

WebKit 的默认优化策略:

  • 当视图不可见时,throttle requestAnimationFrame
  • 当视图不可见时,throttle CSS 动画
  • 当视图不可见时,throttle JavaScript timers

对于 Raycast 这样的启动器,这些优化变成了缺陷:

  • 窗口隐藏时动画被暂停,下次显示时可能出现跳跃
  • 计时器延迟导致状态更新不及时
  • 整体体验出现”卡顿感”

解决方案:欺骗 WebKit 的可见性检测

Raycast 团队采用了巧妙的 workaround:

1. 保持窗口前置但隐藏

// 伪代码示意
window.orderFront(nil)      // 将窗口置于前台
window.alphaValue = 0       // 但透明度设为 0(视觉隐藏)

这样 WebKit 认为窗口是”可见的”(因为确实在前面),但用户看不到它。

2. 禁用 occlusion detection

// 禁用 WebKit 的遮挡检测
webView.configuration.preferences.setValue(
    false, 
    forKey: "windowOcclusionDetectionEnabled"
)

这告诉 WebKit:“不要因为你认为窗口被遮挡就停止渲染”。

3. 预渲染策略

在显示窗口前,触发一次渲染:

// 在即将显示窗口前
requestAnimationFrame(() => {
  // 确保内容已渲染
  window.show();
});

“We work around it by ordering the window to front but keeping it visually hidden (alphaValue = 0), and disabling WebKit’s occlusion detection… Right before showing the window, we trigger our rendering in a requestAnimationFrame to avoid flickering.”

(我们通过将窗口置于前台但保持视觉隐藏(alphaValue = 0)来解决,并禁用 WebKit 的遮挡检测……在显示窗口之前,我们在 requestAnimationFrame 中触发渲染以避免闪烁。)

macOS 与 Windows 的差异

同样的原理在 Windows(WebView2/Chromium)上也需要应用,但具体 API 不同:

“And we had to do specific work to make sure Chromium doesn’t throttle the WebView when our window isn’t focused, since Raycast often needs to update while sitting behind other apps.”

(我们必须做特定的工作来确保当窗口不在焦点时 Chromium 不会 throttle WebView,因为 Raycast 经常需要在位于其他应用后面时更新。)

挑战二:窗口渲染的闪烁问题

问题:窗口打开时的白色闪光

WebView 应用在窗口首次显示时经常出现令人恼火的”白色闪光”(white flash)——这是 WebView 尚未完成渲染时显示的背景色。

解决方案:同步渲染与窗口显示

Raycast 团队利用 WebKit 的私有 API _doAfterNextPresentationUpdate

“We use _doAfterNextPresentationUpdate (a WebKit API for synchronizing rendered state with native presentation) to make sure the WebView has finished drawing before the window becomes visible. Without it, you’d see a flash of stale or empty content.”

(我们使用 _doAfterNextPresentationUpdate(一个用于同步渲染状态与原生呈现的 WebKit API)来确保 WebView 在窗口可见之前已经完成绘制。没有它,你会看到陈旧或空白内容的闪光。)

技术实现流程:

sequenceDiagram
    participant U as 用户
    participant H as Host App
    participant W as WebView
    participant D as Display
    
    U->>H: 触发显示窗口
    H->>W: 发送内容更新
    W->>W: 渲染内容
    W-->>H: _doAfterNextPresentationUpdate 回调
    H->>D: 显示窗口
    D->>U: 已渲染好的内容

Windows 上的白色矩形问题

WebView2 应用在启动时常见的”白色矩形”闪光:

“We control all the initialization parameters ourselves, which lets us avoid the white-rectangle flash that’s common in WebView2 apps on startup.”

(我们自己控制所有初始化参数,这让我们可以避免 WebView2 应用在启动时常见的白色矩形闪光。)

通过精细控制 WebView2 环境配置、窗口创建时机和初始内容加载,Raycast 实现了与 macOS 一致的”无闪烁”体验。

挑战三:窗口大小变化时的渲染问题

问题:窗口展开时的空白区域

Raycast 支持从紧凑模式展开到完整模式。在 v2 早期版本中,WebKit 会在展开过程中留下空白区域:

“When Raycast expands from compact to full-size mode, WebKit would leave the previously hidden area blank for a frame or two – it was throttling the area it considered ‘outside the viewport.’”

(当 Raycast 从紧凑模式展开到完整模式时,WebKit 会将之前隐藏的区域留空一两帧——它在 throttle 它认为是”视口外”的区域。)

解决方案:超尺寸 WebView

巧妙的 workaround:让 WebView 的尺寸始终保持在展开后的最大尺寸,即使窗口本身是紧凑的。

flowchart LR
    subgraph "窗口(紧凑模式)"
        A1[可见区域]
    end
    
    subgraph "WebView(始终最大尺寸)"
        B1[可见区域]
        B2[隐藏区域<br/>已预渲染]
    end
    
    A1 -->|完全覆盖| B1
    
    style B2 fill:#ffe6cc

“We fixed it by keeping the WKWebView frame always at the expanded size, even when the window itself is compact. The WebView renders beyond the window’s visible bounds, so when the window expands, the content is already there.”

(我们通过保持 WKWebView 的 frame 始终处于展开后的尺寸来修复,即使窗口本身是紧凑的。WebView 渲染到窗口可见边界之外,所以当窗口展开时,内容已经在那儿了。)

窗口动画时的 stutter 问题

WebKit 会在窗口动画调整大小时暂停绘制,导致明显的卡顿:

“WebKit suspends drawing during animated window resizes, which caused visible stuttering.”

(WebKit 在窗口动画调整大小时暂停绘制,导致明显的卡顿。)

解决方案: 重写 NSWindow.setFrame,将动画调用替换为隐式 Core Animation:

// 重写 setFrame 以禁用 WebKit 的暂停行为
override func setFrame(_ frameRect: NSRect, display flag: Bool, animate animateFlag: Bool) {
    // 使用隐式动画而非显式动画
    // 这样 WebView 在窗口动画期间继续渲染
    super.setFrame(frameRect, display: flag, animate: false)
    // 应用自定义的 Core Animation 动画
}

挑战四:macOS Tahoe 的 Liquid Glass 适配

挑战:新设计语言的第一天支持

macOS Tahoe(macOS 15)引入了全新的 Liquid Glass 设计语言。作为系统级生产力工具,Raycast 需要在第一天就适配这一视觉风格。

解决方案:透明 WebView 与系统材质融合

Raycast 的架构使其能够相对容易地支持新设计语言:

“On macOS Tahoe, we adopted Apple’s new Liquid Glass material so Raycast blends with the system’s updated visual language from day one.”

(在 macOS Tahoe 上,我们采用了苹果的新 Liquid Glass 材质,使 Raycast 从第一天就融入系统更新的视觉语言。)

技术实现:

  • WebView 的透明背景允许系统材质透过显示
  • CSS backdrop-filter 与系统视觉效果协调
  • Host App 负责设置正确的材质类型

挑战五:Windows 平台的多样性挑战

问题:Windows 生态的碎片化

相比 macOS 的相对统一,Windows 平台的多样性带来了额外挑战:

“Windows is a much more diverse platform than macOS. Users run different OS versions, hardware configurations, and display setups – 8 GB of RAM on a 4K display with an older CPU is not unusual.”

(Windows 是一个比 macOS 更加多样化的平台。用户运行不同的 OS 版本、硬件配置和显示设置——在较旧的 CPU 上用 8GB 内存驱动 4K 显示器并不罕见。)

具体问题

维度macOSWindows挑战
OS 版本相对统一分散(Win10/Win11/不同更新版本)API 可用性差异
硬件苹果控制极度多样性能特征不可预测
内存配置16GB+ 常见8GB 仍常见内存优化更关键
WebView 版本系统统一可能不同版本渲染行为差异

解决方案:广泛的兼容性测试

“Using the system WebView also means the WebView2 version can differ across machines, so we need to account for different rendering behaviors and API availability. There’s more surface area to test and more edge cases to handle.”

(使用系统 WebView 也意味着 WebView2 版本可能因机器而异,所以我们需要考虑不同的渲染行为和 API 可用性。需要测试的范围更大,需要处理的边界情况更多。)

具体措施:

  • 在多种 Windows 配置上测试(内存、CPU、显示分辨率组合)
  • 处理 WebView2 不同版本的 API 差异
  • 为低内存配置优化(v2 的 350-450MB 占用需要在此类设备上验证可用性)

挑战六:Emoji 渲染性能

问题:Emoji 选择器的卡顿

一个看似简单的功能出现了性能问题:

“Our emoji picker was initially slow because WebKit was falling back through the font chain for every emoji glyph.”

(我们的 Emoji 选择器最初很慢,因为 WebKit 对每个 Emoji 字形都在回退字体链。)

解决方案:字体预热

解决方案出人意料地简单:

“The fix turned out to be simple – prewarm the emoji font on startup – but it took a while to figure out what was actually happening.”

(修复结果很简单——在启动时预热 Emoji 字体——但花了一段时间才弄清楚实际发生了什么。)

技术原理:

  • WebKit 在遇到未缓存的字体时会遍历整个字体回退链
  • Emoji 字体通常位于链的末端
  • 启动时强制加载 Emoji 字体到缓存,避免运行时的遍历
// 伪代码:字体预热
async function prewarmEmojiFont() {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  // 使用 Emoji 强制加载字体
  ctx.font = '16px "Apple Color Emoji", "Segoe UI Emoji"';
  ctx.fillText('', 0, 0);  // 渲染一个 Emoji 触发字体加载
}

挑战总结

挑战类别具体问题解决方案类型
WebKit 行为Throttling、渲染暂停Workaround + 私有 API
窗口管理闪烁、尺寸变化卡顿渲染同步 + 超尺寸 WebView
平台适配Liquid Glass、Windows 多样性架构设计 + 广泛测试
细节优化Emoji 渲染慢预加载策略

关键洞察: 这些挑战大多源于 WebKit/WebView2 是”为网页浏览设计”而非”为桌面应用设计”。Raycast 的成功在于:

  1. 深入理解 WebView 的内部工作机制
  2. 愿意使用私有 API 和 workaround
  3. 对每个细节(即使只是 Emoji 选择器)的执着优化