OpenTray 的 Windows 残影是怎么来的:DWM、WebView2 与透明窗口

假设你正在做一个托盘工具。用户点击右下角的小图标,一个没有标题栏的面板弹出来。面板后面是 Windows 11 的 Acrylic 材质,前面是一套 Web UI。

pnpm-pub 就是这个结构。OpenTray 先创建原生窗口,再把 WebView2 放进去。为了让 Acrylic 透出来,网页的背景必须透明。听上去很顺:原生窗口负责材质,WebView2 负责按钮和内容,各画各的。

问题出现在 resize。拖一下窗口边缘,刚才的按钮、文字甚至整块白色背景会沿着拖动轨迹留在窗口里。它们像一叠没有清掉的截图,越拖越多。

最初很容易怀疑 WebView2。毕竟残留下来的正是网页内容。我们改过 WebView2 背景,试过 framed 和 frameless,也触发过 WM_PAINTRedrawWindowUpdateWindow。消息计数一直增加,画面却不肯变。

真正把方向扭过来的,不是某个复杂 API,而是一个很朴素的实验。

把 WebView 缩到九分之一

我们把 WebView 的宽和高都改成窗口的三分之一。这样 WebView 只占总面积的九分之一,剩下八分之九直接显示原生 Acrylic。

如果残影属于 WebView2,它至少应该被限制在那一小块区域里。结果不是。WebView 外面的 Acrylic 区域照样留下旧内容。

顶层窗口客户区
┌───────┬────────────────┐
│WebView│                │
│  1/9  │ 原生 Acrylic   │
├───────┘                │
│      其余 8/9 仍有残影 │
└────────────────────────┘

背景残影:窗口调整尺寸后,旧页面内容沿着窗口移动轨迹重复出现

图 1:客户区背景残影。旧内容沿 resize 轨迹重复出现,WebView2 没有覆盖的区域也受影响。

这个结果不能告诉我们 DWM 内部用了哪一种缓存算法,但已经足够排除一大批方向:DOM、CSS 和 WebView 背景不是残影覆盖范围的所有者。问题在更下面,至少已经到了原生窗口这一层。

要继续往下查,就得先认识 Windows 窗口是怎么送到屏幕上的。

先认识 HWND,再认识 DWM

Windows 里的原生窗口由一个 HWND 标识。HWND 是 handle to a window,也就是操作系统窗口对象的句柄。可以把它理解成窗口在 Windows 内部的身份,不要把它理解成 React 组件或者一块像素缓冲区。

一个 HWND 又分为客户区和非客户区。客户区是应用放内容的地方;非客户区是标题栏、边框、系统菜单和原生 resize 区域。后面那圈很像 Windows 7 的边框残影,就属于非客户区。现在先看客户区。

应用通过 WndProc 接收窗口消息。WM_SIZE 表示尺寸变化,WM_PAINT 表示 Windows 允许或要求应用更新绘制,WM_ERASEBKGND 则和背景擦除有关。WndProc 很像一个由操作系统调用的总入口。鼠标、焦点、尺寸和绘制都会从这里经过。

但 WndProc 画完以后,像素通常不会直接进显示器。Windows Vista 以后,桌面由 DWM 统一合成。DWM 的全名是 Desktop Window Manager,可以把它类比成 Android 的 SurfaceFlinger、macOS 的 WindowServer,或者浏览器里负责合成图层的 compositor。

应用更新自己的窗口表面,DWM 再收集所有窗口,处理遮挡、阴影、动画和最终呈现。为了做到这些,DWM 会为参与合成的窗口保留离屏内容。工程里常把它叫 redirection surface,也就是重定向表面。你可以先把它理解成“这个窗口交给桌面合成器的那张图”。

OpenTray 的结构又多了一层。Wry 在 Windows 上默认使用 Windowed WebView2。WebView2 不是在父窗口里执行一段绘图函数,它会创建自己的 child HWND,也就是原生子窗口。

于是实际层级是这样的:

Windows 桌面
└─ DWM 负责最终合成
   └─ OpenTray 顶层 HWND
      ├─ 非客户区:标题栏、边框、resize frame
      └─ 客户区重定向表面
         ├─ OpenTray 原生宿主底层
         └─ WebView2 child HWND
            └─ HTML 页面

前端里的 background: transparent 只意味着 HTML 这一层不提供背景像素。它不会替父 HWND 生成内容,也不会替 DWM 修复一张旧表面。透明层下面是什么,取决于父窗口有没有把自己的区域准备好。

微软还提供 CompositionController 和 Visual Hosting,可以把 WebView2 接到 DirectComposition 视觉树里。这条路更接近浏览器或移动端的 layer 合成,但应用要自己转发鼠标、键盘、触摸、光标和可访问性输入。我们做过 CompositionController 探针,残影仍然存在,所以没有为了一个未经证明的假设重写整套输入系统。

Acrylic 不是 CSS 背景

Acrylic 和 Mica 是 DWM 提供的 system backdrop。应用告诉 DWM 想使用哪一种材质,DWM 根据系统主题、窗口激活状态和桌面内容生成效果。它们不是网页的 backdrop-filter,也不是 WebView2 画出来的一张半透明图片。

这意味着材质窗口仍然需要一个完整的原生客户区。WebView2 透明以后,DWM 材质从下面透出来;如果父 HWND 从来没有稳定拥有那块表面,DWM 就可能继续拿旧内容参与合成。

为了验证这个判断,我们做了 native-material-host-paint-probe-20260716.exe。探针只保留一个原生窗口、Acrylic/Mica 切换和三种 host paint。随后又把 OpenTray 的 win32-bug 改成相同结构,唯一明显的差别是按钮由透明 WebView2 绘制。

host paint 指的是顶层 HWND 如何画自己的客户区。三个模式的结果很直接:

模式原生窗口做了什么看到的结果
1. No host paint收到绘制消息,但不填客户区resize 后容易留下旧内容
2. Black host paint用黑色画刷填满客户区Acrylic 仍然可见,残影消失
3. Gray host paint用灰色画刷填满客户区用来确认父窗口绘制确实参与合成

第二种模式第一次看很反直觉。既然父窗口刷黑,为什么窗口没有变成黑色?因为黑色在这里是 DWM 材质的合成底,不是盖在 WebView2 上面的黑色图层。Acrylic 仍然由 DWM 呈现,WebView2 仍然只画按钮和文字。

这也解释了为什么按下 Invalidate + UpdateWindow 没用。WM_PAINT 计数增加,只能证明 WndProc 被调用;如果处理函数没有为目标区域提供有效内容,消息再多也不会自动得到正确像素。

OpenTray 的材质窗口现在会在 WM_ERASEBKGNDWM_PAINT 中使用 BLACK_BRUSH 填满整个客户区。伪代码并不复杂:

WndProc(hwnd, message):
  if isMaterialWindow(hwnd) and message is WM_ERASEBKGND:
    FillRect(hwnd.clientRect, BLACK_BRUSH)
    return handled

  if isMaterialWindow(hwnd) and message is WM_PAINT:
    paint = BeginPaint(hwnd)
    FillRect(hwnd.clientRect, BLACK_BRUSH)
    EndPaint(hwnd, paint)
    return handled

  return DefWindowProc(hwnd, message)

这里还有两个 style 容易踩坑。WS_CLIPCHILDREN 会让父窗口绘制时避开子窗口区域。子窗口完全不透明时,这是一种优化;WebView2 透明时,它等于让父 HWND 放弃 WebView2 下面那块区域,所以材质窗口需要移除它。

WS_EX_NOREDIRECTIONBITMAP 则表示窗口不要求 DWM 保留通常的重定向表面。OpenTray 的普通窗口可以走 Softbuffer,材质窗口却需要 DWM 宿主表面。把普通透明窗口的 style 原样搬到 Acrylic 窗口,表现不会稳定。

问题不只在怎么画,还在什么时候画

把客户区刷黑以后,冷启动顺序仍然很重要。WebView2 controller 创建时会生成 child HWND、初始化 COM,并触发一批尺寸和位置消息。如果父窗口还没完成材质、初始尺寸和 host paint,第一张进入 DWM 的表面就可能是不完整的。

COM 是 Windows 的组件对象模型。这里不需要先学完整套 COM,只要记住一点:WebView2、AppWindow 这类对象会受线程和初始化顺序约束。类型正确,不代表调用时机正确。

OpenTray 的冷启动现在按下面的顺序执行:

createMaterialWebView(options):
  hwnd = createHiddenTopLevelWindow()
  setHostPaintPolicy(hwnd, black)
  applyWin32Style(hwnd, options.style)
  applyDwmBackdrop(hwnd, options.material)
  setInitialBounds(hwnd, options.bounds)
  recommitNativeHostSurface(hwnd)

  context = createWebViewContext(stableProfilePath(options))
  webview = createWindowedWebView2(hwnd, context, transparent=true)
  initializeAppWindowOverlay(hwnd)  // WebView2 已建立 COM
  setWebViewBounds(webview, getClientRect(hwnd))
  showWindow(hwnd)

  return { hwnd, context, webview }

webview-control 曾经出现过一个看似无关的症状:窗口能打开,但按任何按钮都会让 broker 断开。JavaScript 只看到 broker connection closed,实际是原生进程先退出了。原因是 AppWindow titlebar overlay 初始化得太早,它必须等 WebView2 在 HWND 所在线程上建立 COM 以后再进入。

对于熟悉 Android 或 iOS 的开发者,这和 Surface、View、Layer 的首次提交顺序很像。父层没有有效内容时,子层透明不会帮你生成背景。

resize 也要让父窗口先走

冷启动修好以后,用户拖动窗口边缘仍然会不断触发 WM_WINDOWPOSCHANGEDWM_SIZE。这时顺序不能反过来。

如果先放大 WebView2,再补画父 HWND,新露出来的区域就有机会先显示父窗口上一轮的重定向内容。OpenTray 现在先交给 DefWindowProc 处理系统几何,再提交父表面,最后更新 WebView2。

on WM_SIZE:
  DefWindowProc(hwnd, WM_SIZE, ...)
  recommitNativeHostSurface(hwnd)
  bounds = getClientRect(hwnd)
  webview.controller.setBounds(bounds)
  moveChildWindow(webview.hwnd, bounds)
  webview.controller.notifyParentWindowPositionChanged()

DefWindowProc 是 Windows 的默认窗口过程。应用接管 WndProc 后,仍然要把自己不负责的部分交回系统。NotifyParentWindowPositionChanged 则告诉 WebView2,父窗口的几何已经变化。

旧实现曾经在 resize 期间最小化再恢复窗口。这个动作会迫使 shell 重建一部分状态,所以有时能清残影,但也会带来闪烁、焦点变化和错误的可见性事件。给它加 debounce 只会降低错误动作的频率。父表面先于子窗口提交以后,这条 shell workaround 就不再需要。

第二张图为什么像 Windows 7

客户区残影消失后,我们又看到了一圈老式窗口边框。它和第一张图不是同一种问题。第一张图发生在客户区,这一圈发生在非客户区。

非客户区边框残影:frameless 窗口外侧残留旧 Windows 边框

图 2:非客户区边框残影。旧标题栏和 resize frame 的像素仍留在顶层窗口外圈。

标准 Windows 窗口通常带有 WS_OVERLAPPEDWINDOWWS_THICKFRAME 等 style。Windows 根据它们生成标题栏、边框和 resize 命中区。所谓 frameless,可以是彻底移除原生 frame,也可以保留 WS_THICKFRAME 的缩放能力,再通过 WM_NCCALCSIZE 扩大客户区。

WM_NCCALCSIZE 是 Windows 计算客户区和非客户区边界时发出的消息。OpenTray 的对照窗口先让 DefWindowProc 算出原生 resize inset,再修改顶部。一次回归里,左右只多 1px,顶部却多了 7px:原生窗口高度是 506,页面的 window.outerHeight 只有 499。那 7px 不是 CSS margin,而是 caption 还占着位置。

修复时保留左右和底部的原生 resize inset,顶部只保留 DWMWA_VISIBLE_FRAME_BORDER_THICKNESS。这个属性是 DWM 报告的可见边框厚度。结果是原生 resize 手感还在,WebView2 也能贴到窗口顶部。

这些实现细节不需要全部变成跨平台 API。OpenTray 对外仍然提供 framelessresizable,Windows 在内部处理 Win32 frame,macOS 按 NSWindow 的规则实现。公共 API 描述用户要的行为,平台层负责把行为翻译成自己的窗口结构。

WebView2 还是一个浏览器

窗口显示正确以后,pnpm-pub 又暴露了发布路径的问题。本地 pnpm pub start 正常,pnpx pnpm-pub@latest start 却只显示托盘图标几秒。

WebView2 启动时需要 user data folder,也就是浏览器 profile。缓存、Cookie、权限和进程锁都在里面。旧版 OpenTray 让 profile 跟随 opentray.exe 的位置,pnpx 则会把可执行文件放在临时安装目录。路径变深、权限变化或者锁冲突时,WebView2 环境可能直接创建失败。

现在 OpenTray 使用稳定目录:

~/.opentray/webview/<package-version>/<caller-label>

WebContext 也必须比 WebView 活得更久。这个细节属于资源生命周期,不是渲染算法,却会决定窗口能不能创建。它再次说明了同一件事:嵌入式 WebView 不是一张网页截图,它是一套带原生窗口、线程和 profile 的浏览器运行时。

我现在怎样判断一块桌面残影

我会先问它跟着哪一层走。把 WebView 缩小以后,残影还在外面,就去查父 HWND;残影只在标题栏和边框,就去查非客户区;本地能启动而安装包失败,就去查 broker、profile 和二进制路径。

屏幕上的异常像素
├─ DOM 与 CSS
├─ WebView2 surface 或 child HWND
├─ 顶层 HWND 客户区与 DWM redirection surface
├─ 标题栏、边框、resize frame 等非客户区
└─ COM、profile、broker 与安装路径

日志仍然要记 WM_PAINTWM_SIZE、style 和 bounds,但消息计数不能代替视觉结果。原生对照程序也必须和真实程序共享窗口大小、DPI、材质、style 和创建顺序,每次只改一个变量。否则所谓最小复现,只是另一个看起来相似的程序。

这次之后,我对 transparent 这个词谨慎了很多。在浏览器里,它通常只是露出父元素;在 Windows 桌面里,它会一路露出 WebView2 的父 HWND、DWM 材质和系统保留的窗口表面。父窗口没有画好时,透明层不会隐藏问题,它会把那张旧表面老老实实地露出来。

源码和文档