把 Python 桌面应用改写成纯 C,表面上看只是换了一门语言,真正动手后才发现,麻烦往往不在业务代码,而在那些“示例里一行带过”的细节:COM 对象什么时候释放、回调接口应该怎样实现、窗口样式为什么会影响渲染,以及为什么所有函数都返回成功,页面却始终没有出现。
最近我把一个基于 pywebview 的 Spore 修改器迁移到了纯 C、Win32 和 WebView2,最终打包成单个 exe。整个过程踩了不少坑,也积累了一些可以复用的经验。本文按照实际开发顺序,从环境搭建讲到窗口、通信和自动化验证,希望能帮后来者少走一些弯路。
本文环境为 Windows 10/11 x64、Visual Studio 2022 MSVC,以及 WebView2 Runtime 155。
一、先把工具链准备对
纯 C 项目不需要完整的 Visual Studio IDE,安装以下两项就够了:
- Visual Studio Build Tools,勾选 C++ 生成工具;
- Windows SDK。
VS Code 只是编辑器,不是必需组件。
WebView2 的头文件和导入库不包含在 Windows SDK 中,需要单独下载。可以从 nuget.org 获取 Microsoft.Web.WebView2 的 .nupkg 包。它本质上是一个 ZIP 文件,解压后取出:
build/native/include/WebView2.h
build/native/x64/WebView2LoaderStatic.lib
分别放到项目的:
third_party/webview2/include/
third_party/webview2/lib/
这里有一个很容易踩的坑:不要包含 WebView2EnvironmentOptions.h。这个头文件依赖 wrl/implements.h,只能用于 C++。纯 C 编译时会直接报出 WRL requires C++ compilation。
需要的接口,包括 ICoreWebView2EnvironmentOptions,在 WebView2.h 里已经声明了,不必画蛇添足。
如果习惯在 Git Bash 中开发,建议把编译过程写进 .bat 文件,再执行:
cmd.exe //c scripts/build.bat
批处理文件有两个细节值得注意:
- 保存为 CRLF 行尾,LF 行尾可能导致“系统找不到指定的路径”;
- 批处理中的重定向应写成
1>nul 2>&1,不能把 Git Bash 的/dev/null原样搬进去。
Visual Studio 的安装路径也不要写死,可以通过 vswhere 自动定位:
for /f "usebackq tokens=*" %%i in (`"%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set VS=%%i
call "%VS%\VC\Auxiliary\Build\vcvars64.bat" 1>nul 2>&1
这样换一台机器,构建脚本通常也能继续工作。
二、编译参数不多,但每一个都有原因
资源文件和 C 源文件需要分开处理。先用 rc.exe 把 .rc 编译成 .res,再交给链接器:
rc /nologo /fo resources\resource.res resources\resource.rc
cl /nologo /MT /O2 /W3 /utf-8 /I third_party\webview2\include ^
src\main.c src\webview.c src\trainer.c resources\resource.res ^
/Fe:SporeTrainer.exe /link /SUBSYSTEM:WINDOWS ^
user32.lib gdi32.lib ole32.lib oleaut32.lib shlwapi.lib comctl32.lib advapi32.lib shell32.lib dwmapi.lib ^
third_party\webview2\lib\WebView2LoaderStatic.lib
不要把 .rc 文件直接传给 cl,否则会报 D9024 无法识别源文件类型。
另外,/utf-8 建议固定加上。只要源码中包含中文字符串,漏掉这个参数就可能出现 C2001 常量中有换行符 等莫名其妙的错误。真正的原因并不一定是字符串换行,而是 MSVC 按错误的编码读取了 UTF-8 源文件。
资源脚本里如果有 UTF-8 中文,例如版本信息中的 FileDescription,还需要添加:
#pragma code_page(65001)
中文版本信息字符串块可以使用:
BLOCK "080404b0"
/MT 配合 WebView2LoaderStatic.lib,可以避免携带额外的 CRT 和 WebView2 Loader DLL。不过需要明确:单文件不等于不需要 WebView2 Runtime,目标机器仍然要安装 WebView2 运行时。
可以用下面的命令检查 exe 实际依赖了哪些 DLL:
objdump -p SporeTrainer.exe | grep "DLL Name"
三、所有调用都成功,页面为什么还是空白?
这是整个迁移过程中最隐蔽、也最典型的问题。
当时的现象是:
CreateCoreWebView2EnvironmentWithOptions返回S_OK;CreateCoreWebView2Controller返回S_OK;Navigate同样返回S_OK;- 浏览器子进程启动约 150 ms 后退出;
- 系统反复重启浏览器进程,最后放弃;
NavigationCompleted和WebResourceRequested等事件始终没有触发。
从调用结果看一切正常,但 COM 对象的生命周期已经出了问题。
C++ 示例中经常能看到这样的代码:
m_controller = controller;
如果 m_controller 是 wil::com_ptr,赋值时会隐式调用 AddRef。纯 C 没有这层包装。若只是保存回调参数中的裸指针:
g_ctl = controller;
回调返回后,加载器可能释放自己的引用,控制器随之销毁。浏览器随后认为宿主窗口已经关闭,于是直接退出。
正确做法是在确认回调成功后,立即增加引用计数:
static HRESULT STDMETHODCALLTYPE ctrl_Invoke(
ICoreWebView2CreateCoreWebView2ControllerCompletedHandler *self,
HRESULT result,
ICoreWebView2Controller *controller)
{
if (FAILED(result) || !controller)
return S_OK;
controller->lpVtbl->AddRef(controller);
g_ctl = controller;
/* 后续初始化 */
return S_OK;
}
如果需要长期保存 environment 回调中的对象,同样要处理好引用计数。
但 get_CoreWebView2 不一样。它通过 [out] 参数返回对象,调用者拿到的已经是自己持有的引用,不需要再额外 AddRef。
这段经历也提醒我:COM 函数返回成功,只代表这次调用成功,不代表对象在下一行代码里仍然活着。
遇到类似问题,可以通过环境变量打开 Chromium 日志:
WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--enable-logging --v=1 --log-file=D:\path\wv.log
如果日志里出现:
RemoveKeepAlive(Default, kBrowserWindow)
Unloading profile
就应该优先检查宿主窗口和 WebView2 对象的生命周期,而不是继续怀疑页面代码。
排错时还可以做一个最小复现:普通重叠窗口、最简单的页面、一次 Navigate。如果最小程序正常,再逐项加入无边框样式、资源拦截和透明效果。相比同时修改多个部分,这样更容易找到真正的触发点。
四、纯 C 回调不是“传一个函数指针”那么简单
WebView2 的事件回调本质上是一个 COM 接口对象。除了业务函数 Invoke,还必须实现 IUnknown 的三个方法:
QueryInterfaceAddRefRelease
其中 QueryInterface 至少要识别 IID_IUnknown 和回调接口自身的 IID。否则运行时可能不报错,却静默跳过事件。
还要分清两层结构:先定义 vtbl,再让接口对象指向这张表,不能直接把函数指针塞进接口对象。
static ICoreWebView2WebMessageReceivedEventHandlerVtbl msg_vtbl = {
msg_QueryInterface,
msg_AddRef,
msg_Release,
msg_Invoke
};
static ICoreWebView2WebMessageReceivedEventHandler msg_handler = {
&msg_vtbl
};
webview->lpVtbl->add_WebMessageReceived(
webview,
&msg_handler,
&token
);
对于程序生命周期内一直存在的静态回调对象,AddRef 和 Release 可以简化为返回 1,不做真正的释放。
但这个简化只适用于静态对象。如果回调是动态分配的,就必须实现真实的引用计数,并在计数归零时释放内存,否则不是泄漏,就是提前释放。
五、无边框窗口要处理的不只是边框
WebView2 很适合做现代界面,但与无边框窗口组合后,会牵出一组联动问题。
固定尺寸窗口,慎用 WS_THICKFRAME
WS_POPUP | WS_THICKFRAME 看似能保留窗口边框能力,但上边框属于非客户区,而 WebView2 默认只覆盖客户区。结果就是页面顶部露出一条窗体底色。
如果窗口尺寸固定,直接使用纯 WS_POPUP 更省事。Windows 11 的圆角可以通过 DWM 属性补上:
DWORD corner = 2; /* DWMWCP_ROUND */
DwmSetWindowAttribute(
hwnd,
33, /* DWMWA_WINDOW_CORNER_PREFERENCE */
&corner,
sizeof(corner)
);
不要过度依赖 CSS 拖动区域
-webkit-app-region: drag 在 WebView2 中不够可靠。更稳妥的做法是让 JavaScript 把拖动请求发给 C,由 Win32 完成窗口移动:
document.querySelector("header").addEventListener("mousedown", e => {
if (e.button === 0 && !e.target.closest("button")) {
window.chrome.webview.postMessage({ cmd: "drag" });
}
});
C 侧收到消息后执行:
ReleaseCapture();
SendMessageW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0);
这样既能拖动窗口,也不会影响标题栏里的按钮。
淡入动画必须等页面就绪
WebView2 使用 DirectComposition 渲染。窗口处于透明初始状态时,如果立刻开始淡入,可能整个过程只看到窗体底色,最后页面突然跳出来。
比较稳妥的顺序是:
- 窗口以 alpha 为 0 的状态创建;
- 等待
NavigationCompleted; - 回调中通过
PostMessage通知 UI 线程; - UI 线程收到通知后再启动淡入定时器;
- 动画期间保留分层窗口样式,不要中途移除;
- 设置约 3 秒的兜底定时器,页面加载异常时也能显示窗口。
为了防止白闪,还可以同时把 ICoreWebView2Controller2::put_DefaultBackgroundColor、窗口类的 hbrBackground 和页面背景设置成同一种颜色。
六、C 与 JavaScript 通信,简单方案反而更稳
在 C++ 中,AddHostObjectToScript 很方便;到了纯 C,就要自己实现 IDispatch,成本明显高得多。
对于常规桌面工具,JSON 消息已经足够:
- JavaScript 使用
window.chrome.webview.postMessage(...)发消息; - C 使用
get_WebMessageAsJson接收; - C 使用
PostWebMessageAsJson回复; - JavaScript 通过
message事件监听。
宽字符与 UTF-8 的转换可以交给:
MultiByteToWideChar(..., CP_UTF8, ...);
WideCharToMultiByte(..., CP_UTF8, ...);
如果消息格式固定、字段很少,手写简单的 JSON 字段提取也能胜任,不必为了几个字符串立即引入完整依赖。但输入结构一旦复杂,或者内容不受信任,就应该使用真正的 JSON 解析器,不要继续堆字符串查找。
七、页面也可以嵌进 exe,不必释放到磁盘
单文件应用并不意味着必须把 HTML 放在 exe 旁边。可以将页面声明为资源:
index.html RCDATA "web\\index.html"
运行时注册资源请求过滤器:
AddWebResourceRequestedFilter(
L"https://app.local/*",
COREWEBVIEW2_WEB_RESOURCE_CONTEXT_ALL
);
在 WebResourceRequested 事件中,依次使用:
FindResourceW
LoadResource
LockResource
取得资源字节,再用 SHCreateMemStream 包装成 IStream,最后通过 CreateWebResourceResponse 返回给 WebView2。
这样页面不需要落盘,也不依赖真实 Web 服务器。
不过,自定义协议不能因此省略安全检查。当前这种扁平资源方案应拒绝包含 /、\ 和 .. 的请求路径,避免路径穿越或意外访问。浏览器自动请求的 favicon.ico 如果没有提供,通常可以忽略。
八、DPI、线程和自动化验证
DPI 建议在 manifest 中声明 PerMonitorV2:
<dpiAwareness>PerMonitorV2</dpiAwareness>
<dpiAware>true/pm</dpiAware>
声明后不要再调用 SetProcessDPIAware,避免重复设置。运行时 155 的日志中可能出现 failed to set WebView dpi awareness of 1,实测不影响使用,不必被这条日志带偏。
如果程序有后台轮询线程,还要守住两条边界:
- 共享状态使用
CRITICAL_SECTION等机制保护; - 后台线程通过
PostMessage(WM_APP + n, ...)通知 UI 线程,不要跨线程直接调用 WebView2 API。
自动化截图也有坑。普通 CopyFromScreen 有时抓不到 WebView2 的 DirectComposition 图层,白屏不一定代表页面没有渲染。可以尝试:
PrintWindow(hwnd, hdc, 2); /* PW_RENDERFULLCONTENT */
鼠标自动化方面,SetCursorPos 只移动光标,不产生输入消息。使用 mouse_event 或 SendInput 时,要带上绝对坐标移动。
不过,WebView2 对合成输入的响应并不总是稳定。若目的只是验证 JS 到 C 的通信,使用 ICoreWebView2::ExecuteScript 在页面上下文直接调用 postMessage,通常比模拟点击更可靠。
如果使用 PowerShell 编写 P/Invoke 验证脚本,字符串参数要指定 CharSet=CharSet.Unicode;包含中文的 .ps1 文件还要保存为带 BOM 的编码。遇到编码问题时,改用 Python ctypes 往往更省心。
写在最后
这次迁移最值得记住的,不是某一条编译参数,而是几个排查顺序:
- 先确认工具链、编码和链接方式;
- 页面不显示时,优先检查 COM 对象生命周期;
- 事件不触发时,检查回调接口和 vtbl 是否完整;
- 无边框、透明和动画问题,要放在窗口与渲染的整体关系中分析;
- UI 自动化抓不到内容,不代表 WebView2 没有渲染;
- 所有跨线程操作,最终都应回到 UI 线程处理。
另外还有两个发布前的小提醒:exe 正在运行时重新链接会报 LNK1104,需要先结束进程;WebView2 默认会在 exe 旁创建 Xxx.exe.WebView2 用户数据目录,程序刚退出时文件可能仍被占用,稍等片刻再清理即可。
纯 C 写 WebView2 确实比 C++ 繁琐,但这种繁琐并非没有价值。把引用计数、回调结构和线程边界一一理清之后,程序的行为会变得更加可预测。真正耗费时间的往往不是代码量,而是那些被高级封装悄悄隐藏起来的细节。