
有一类 Windows 桌面程序逻辑跑得稳、性能也够但界面还停留在 2010 年自己画的按钮、手写的列表框、改个颜色要重编。重写成本太高老代码碰不动。不重写新功能又堆不上去。我负责的 yyzTools 就是这种「半老不新」的产物——核心是一坨 C Win32 原生代码文件搜索、剪贴板、OCR 全在 C 层但 40 功能模块里有相当一部分是给现代交互准备的命令面板、动态壁纸配置、FFmpeg 的批量任务表单。这些用 Win32 控件写会疯我们最后选了C Win32 WebView2 混合架构原生层管性能和系统能力WebView2 管那些「该用网页写」的界面。这篇文章不是教你「Hello WebView2」而是把我们从零接入到能稳定交付的真实路径跑一遍重点放在 Win32 老程序接入时才会遇到的坑——那些官方示例根本不会提、但会让你加班到半夜的东西。想完整复现这套架构的去 GitHub 搜 yyztools仓库里有 .zenmod 模块样例和宿主代码骨架。官网搜索 yyzTools 也能找到开发者文档。一、先想清楚你真的需要 WebView2 吗不是所有界面都该交给 WebView2。我们的判断标准很朴素表单密集、布局复杂、要经常改样式→ 网页写。命令面板的搜索结果高亮、FFmpeg 任务的多条件配置纯 Win32 写能把人写吐。高频重绘、和鼠标键盘强耦合、追求 0 延迟→ 留原生。录屏的取色框、按键提示的全局热区塞进 WebView 反而多一层损耗。yyzTools 的做法是一个进程里原生窗口和 WebView2 窗口并存。命令面板是 WebView文件搜索的实时结果列表每秒刷几千条走原生列表控件。两种窗口用同一套消息循环用户感知不到边界。二、依赖runtime 还是 fixed version这个选择坑了我一天WebView2 有两种分发方式依赖系统已安装的WebView2 Runtime用户从 Microsoft Store 或 Edge 更新里拿到的那个。把Fixed Version的 WebView2 运行时随程序打包自己指定路径加载。我们一开始用 runtime结果测试机上一台 Win10 LTSC 没装 Edge、也没装 runtime程序一启动CreateCoreWebView2EnvironmentWithOptions直接返回失败连报错都语焉不详。那天我以为是 COM 初始化的问题折腾到凌晨才发现是目标机器压根没有 runtime。最终我们改成fixed version把WebView2Loader.dll和运行时目录打进安装包环境创建时显式传路径// 关键把运行时目录指到我们自己带的版本而不是依赖系统 runtime wchar_t exePath[MAX_PATH]; GetModuleFileNameW(nullptr, exePath, MAX_PATH); std::wstring exeDir exePath; exeDir exeDir.substr(0, exeDir.find_last_of(L\\)); std::wstring webviewRuntime exeDir L\\WebView2Runtime; auto options Microsoft::WRL::MakeCoreWebView2EnvironmentOptions(); // fixed version 必须靠 --webview-exe 之外的这个字段指定 // 注意这里传的是运行时所在目录不是 dll 本身 HRESULT hr CreateCoreWebView2EnvironmentWithOptions( nullptr, webviewRuntime.c_str(), // 第三个参数user data folder 之前其实是 browserExecutableFolder options.Get(), CallbackICoreWebView2CreateCoreWebView2EnvironmentCompletedHandler( [](HRESULT hr, ICoreWebView2Environment* env) - HRESULT { // 环境创建完才能创建 Controller return S_OK; }).Get());这里有个命名上的坑CreateCoreWebView2EnvironmentWithOptions的第三个参数语义是browserExecutableFolder也就是你带的 fixed runtime 目录不是 user data folder。我第一次看文档把第四个参数 options 当成路径调了一小时才发现顺序不对。三、STAWebView2 和你的 Win32 消息循环天生不对付WebView2 的 COM 对象必须在单线程套间STA里跑。而很多老 Win32 程序为了后台计算图省事主线程直接CoInitializeEx(nullptr, COINIT_MULTITHREADED)甚至不初始化。我们项目主线程本来是 MTA因为文件索引在后台线程狂跑结果 WebView2 一创建就报RPC_E_WRONG_THREAD。结论很硬承载 WebView2 的那个线程必须CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED)而且从创建环境到创建 Controller、到之后所有 WebView2 调用都得在同一个 STA 线程。我们的折中主线程保持 STA 跑消息循环和 WebView2繁重索引丢到专门的 MTA 工作线程池数据通过消息回传给主线程刷新 UI。这样既满足 WebView2又不耽误搜索速度。// 主线程入口 int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int) { // 必须是 STA否则 WebView2 创建即崩 CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); // DPI 感知要在这里、创建任何窗口之前声明见下一节 SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); // ... 注册窗口类、创建主窗口 MSG msg; while (GetMessage(msg, nullptr, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); // WebView2 的回调也靠这个泵驱动 } CoUninitialize(); return 0; }四、DPI 这个坑不处理界面就是糊的老 Win32 程序常见的「不声明 DPI 感知」在混了 WebView2 之后会极其明显系统帮你做位图拉伸网页渲染出来的字发虚高分屏上像蒙了层雾。修法是在创建任何窗口之前声明 per-monitor v2 感知上面代码已含。但要注意两件事必须在CreateWindow之前调用晚了不生效。窗口跨显示器拖动、DPI 变化时要监听WM_DPICHANGED并同时调整原生窗口大小和 WebView Controller 的Bounds否则网页区域和原生边框会错位。case WM_DPICHANGED: { int newDpi HIWORD(wParam); // 系统给的新推荐矩形在 lParam 里 RECT* rect (RECT*)lParam; SetWindowPos(hWnd, nullptr, rect-left, rect-top, rect-right - rect-left, rect-bottom - rect-top, SWP_NOZORDER | SWP_NOACTIVATE); // 别忘了同步 WebView 的大小否则网页内容不跟着缩放 if (g_controller) { RECT bounds; GetClientRect(hWnd, bounds); g_controller-put_Bounds(bounds); } return 0; }五、窗口尺寸WebView 不会自己跟着父窗口变这是最容易被忽略的一点。原生子控件Edit、Button会随父窗口WM_SIZE自动重排但 WebView2 的 Controller 不会——它的大小要你手动put_Bounds。case WM_SIZE: { if (g_controller) { RECT bounds; GetClientRect(hWnd, bounds); g_controller-put_Bounds(bounds); // 必须手动同步 } return 0; // 注意这里别 return DefWindowProc否则某些样式下会多一次重绘抖动 }我当时漏了put_Bounds现象是窗口放大后网页还停在左上角一小块调试了半天才反应过来它不是普通子控件。六、JS 和 C 互调桥的建法界面交给网页后绕不开一个问题网页里的按钮点了怎么调回 C 的文件搜索C 拿到结果怎么塞回网页WebView2 给的是postMessage双向通道。建桥要在文档创建前注入一段脚本把window.chrome.webview.postMessage暴露好并挂上接收端// 文档加载前注入保证网页一启动就有 bridge wchar_t initScript[] Lwindow.__host { L send: (msg) window.chrome.webview.postMessage(JSON.stringify(msg)), L onmessage: null L}; Lwindow.chrome.webview.addEventListener(message, e { L if (window.__host.onmessage) window.__host.onmessage(JSON.parse(e.data)); L});; env-AddScriptToExecuteOnDocumentCreated( initScript, CallbackICoreWebView2AddScriptToExecuteOnDocumentCreatedCompletedHandler( [](HRESULT hr, LPCWSTR id) - HRESULT { return S_OK; }).Get()); // C 侧接收网页来的消息 ComPtrICoreWebView2WebMessageReceivedEventHandler handler CallbackICoreWebView2WebMessageReceivedEventHandler( [](ICoreWebView2* wv, ICoreWebView2WebMessageReceivedEventArgs* args) - HRESULT { LPWSTR raw; args-get_WebMessageAsJson(raw); // 拿到的是 JSON 字符串 // 解析 raw根据 action 派发到原生功能搜索/读剪贴板/调 OCR... // 处理完用 wv-PostWebMessageAsJson(...) 把结果发回网页 CoTaskMemFree(raw); return S_OK; }).Get(); webview-add_WebMessageReceived(handler.Get());坑点get_WebMessageAsJson返回的内存用CoTaskMemFree释放我一度当成delete[]用在低概率下崩了。网页里postMessage传的是字符串C 侧get_WebMessageAsJson拿到的是带引号的 JSON 字符串如果网页直接postMessage({a:1})对象会被序列化你拿到的raw是{\a\:1}解析时别再套一层。反向发回PostWebMessageAsJson要求传入合法 JSON 字符串拼错一个引号网页端JSON.parse直接抛异常而 WebView2 不会告诉你解析失败——断点都打不到。七、声明式模块我们怎么让「会写网页的人」也能加功能纯 C 接 WebView2 还是门槛高。yyzTools 把窗口宿主做成了声明式一个.zenmod文件描述「这是一个 WebView 模块、加载哪个页面、窗口多大、叫什么名字、支持哪几国语言」主程序读到声明就自动创建原生窗口 WebView2 宿主。模块作者只写网页不碰任何 Win32。一段.zenmod声明长这样示意{ id: ffmpeg.batch, type: web, name: { zh-CN: FFmpeg 批量处理, en: FFmpeg Batch }, entry: Web/ffmpeg/index.html, window: { width: 920, height: 640, resizable: true }, native: [file.search, clipboard.read, ffmpeg.run], lang: [zh-CN, en, ja] }native字段声明这个网页允许调用哪些宿主能力主程序在 bridge 层做白名单校验——网页不能越权调 OCR 或文件系统只能走声明过的接口。这一层权限隔离是我们踩过一次「网页误调了全局热键注册」之后补上的。八、主题同一套页面两套皮几乎白送纯 Win32 换主题是什么体验每个控件记一遍自己的颜色WM_CTLCOLORSTATIC里返不同的刷子Owner-draw 的地方逐个改改完重绘漏一处就风格的「补丁感」。而交给 WebView2 的界面主题这件事变成了换一张变量表// 原生侧只做一件事把当前主题经桥发给网页 void HostWindow::ApplyTheme(const std::wstring theme) // light / dark { webview_-PostWebMessageAsJson( (L{\action\:\theme\,\value\:\ theme L\}).c_str()); }// 网页侧收到后切根属性颜色全部走 CSS 变量一份样式表两种观感 window.__host.onmessage (msg) { if (msg.action theme) { document.documentElement.setAttribute(data-theme, msg.value); } };页面上所有颜色都从var(--bg)、var(--text)这类变量取data-theme一切整页瞬间换装——不重载、不重绘、不重编译。原生侧唯一要做的是记住用户选了哪个主题、在切换时发一条消息以及在窗口创建时先把主题发过去避免首帧闪一下默认样式。同一个界面浅色和深色的实际效果两张图是同一份 HTML、同一份 JS、同一份数据只差一张变量表。这也是第一节那条「表单密集、要经常改样式 → 网页写」判据的延续样式会变的界面交给样式系统能力强的一层。原生控件不是做不了深色模式是做了之后每次调整的成本都比网页高一个量级。九、几个想清楚再做的取舍进程模型WebView2 默认每个环境一条浏览器进程。模块多的时候我们让同类 WebView 共享一个环境避免开十几个浏览器进程把内存吃爆。代价是某个网页崩了会带到同一环境的所有实例——目前我们接受这个权衡因为模块都是自己写的、可控。内存一个空 WebView2 实例常驻大概几十 MB。命令面板这种「用完即关」的我们做成了懒加载 关闭即销毁 Controller而不是一直挂着。调试开发期给 WebView 开 F12用OpenDevToolsWindow直接弹开发者工具比瞎猜强一百倍。上线版本用编译开关关掉。十、回到开头这套架构值不值yyzTools 现在的 40 功能模块、379 个.zenmod命令模块里凡是交互复杂的基本都跑在 WebView2 上。原生层专心做它能做好的事——134 万文件的索引、本地离线 OCR、全局热键——网页层负责把能力「漂亮地递到用户手边」。接入过程最大的教训不是某个 API 怎么调而是WebView2 不是往 Win32 里塞个浏览器那么简单它要求你的线程模型、DPI 策略、窗口生命周期都按它的规矩来。规矩踩对之前每一个坑都长得像别的问题。