把 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

批处理文件有两个细节值得注意:

  1. 保存为 CRLF 行尾,LF 行尾可能导致“系统找不到指定的路径”;
  2. 批处理中的重定向应写成 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 的三个方法:

  • QueryInterface
  • AddRef
  • Release

其中 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 渲染。窗口处于透明初始状态时,如果立刻开始淡入,可能整个过程只看到窗体底色,最后页面突然跳出来。

比较稳妥的顺序是:

  1. 窗口以 alpha 为 0 的状态创建;
  2. 等待 NavigationCompleted;
  3. 回调中通过 PostMessage 通知 UI 线程;
  4. UI 线程收到通知后再启动淡入定时器;
  5. 动画期间保留分层窗口样式,不要中途移除;
  6. 设置约 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 往往更省心。

写在最后

这次迁移最值得记住的,不是某一条编译参数,而是几个排查顺序:

  1. 先确认工具链、编码和链接方式;
  2. 页面不显示时,优先检查 COM 对象生命周期;
  3. 事件不触发时,检查回调接口和 vtbl 是否完整;
  4. 无边框、透明和动画问题,要放在窗口与渲染的整体关系中分析;
  5. UI 自动化抓不到内容,不代表 WebView2 没有渲染;
  6. 所有跨线程操作,最终都应回到 UI 线程处理。

另外还有两个发布前的小提醒:exe 正在运行时重新链接会报 LNK1104,需要先结束进程;WebView2 默认会在 exe 旁创建 Xxx.exe.WebView2 用户数据目录,程序刚退出时文件可能仍被占用,稍等片刻再清理即可。

纯 C 写 WebView2 确实比 C++ 繁琐,但这种繁琐并非没有价值。把引用计数、回调结构和线程边界一一理清之后,程序的行为会变得更加可预测。真正耗费时间的往往不是代码量,而是那些被高级封装悄悄隐藏起来的细节。