假设你正在做一个托盘工具。用户点击右下角的小图标,一个没有标题栏的面板弹出来。面板后面是 Windows 11 的 Acrylic 材质,前面是一套 Web UI。
pnpm-pub 就是这个结构。OpenTray 先创建原生窗口,再把 WebView2 放进去。为了让 Acrylic 透出来,网页的背景必须透明。听上去很顺:原生窗口负责材质,WebView2 负责按钮和内容,各画各的。
问题出现在 resize。拖一下窗口边缘,刚才的按钮、文字甚至整块白色背景会沿着拖动轨迹留在窗口里。它们像一叠没有清掉的截图,越拖越多。
最初很容易怀疑 WebView2。毕竟残留下来的正是网页内容。我们改过 WebView2 背景,试过 framed 和 frameless,也触发过 WM_PAINT、RedrawWindow、UpdateWindow。消息计数一直增加,画面却不肯变。
真正把方向扭过来的,不是某个复杂 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_ERASEBKGND 和 WM_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_WINDOWPOSCHANGED 和 WM_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
客户区残影消失后,我们又看到了一圈老式窗口边框。它和第一张图不是同一种问题。第一张图发生在客户区,这一圈发生在非客户区。
图 2:非客户区边框残影。旧标题栏和 resize frame 的像素仍留在顶层窗口外圈。
标准 Windows 窗口通常带有 WS_OVERLAPPEDWINDOW、WS_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 对外仍然提供 frameless 和 resizable,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_PAINT、WM_SIZE、style 和 bounds,但消息计数不能代替视觉结果。原生对照程序也必须和真实程序共享窗口大小、DPI、材质、style 和创建顺序,每次只改一个变量。否则所谓最小复现,只是另一个看起来相似的程序。
这次之后,我对 transparent 这个词谨慎了很多。在浏览器里,它通常只是露出父元素;在 Windows 桌面里,它会一路露出 WebView2 的父 HWND、DWM 材质和系统保留的窗口表面。父窗口没有画好时,透明层不会隐藏问题,它会把那张旧表面老老实实地露出来。

