yyzTools 桥接层架构总览:C++ + WebView2 混合桌面应用的 JS 调原生方案

本文复盘 yyzTools(一套 Windows 桌面效率工具集)的桥接层架构。它用 C++ 做宿主、WebView2 承载前端页面,两层之间靠一套自研桥接层通信。重点不在功能,在「为什么这么分层」。

一、为什么是混合架构

yyzTools 是一套 Windows 桌面效率工具集(命令面板、翻译、OCR、文件预览、剪贴板历史、批量处理等 40+ 模块)。选型时面临的问题是:

  • 纯原生桌面(Win32 / Qt / WPF)开发效率低、UI 迭代慢
  • 纯 Electron 体验好但内存大(捆绑整个 Chromium)

混合架构是个折中:C++ 做宿主管系统能力,Web 技术做 UI 层

yyzTools 的选型是:

  • 宿主层:C++ / Win32,承载窗口、系统调用、进程管理
  • 渲染层:WebView2(Windows 10/11 自带的 Edge 内核,不额外捆绑 Chromium)
  • 前端框架:Alpine.js(3KB gzip,无虚拟 DOM、无构建链复杂度)
  • 构建工具:Vite 多入口,每个功能页面独立打包

相对 Electron 的优势是不捆绑 Chromium,内存占用更低、启动更快;相对纯原生的优势是 UI 层用 Web 技术迭代,效率高。

二、分层结构

整体分四层,自上而下:

┌─────────────────────────────────────────┐
│  前端页面 (Alpine.js + 原生 JS)           │
│  各功能页面独立目录,互不耦合               │
│  通过 web/lib/zen_api.js 统一调用          │
├─────────────────────────────────────────┤
│  WebView2 桥接层                          │
│  BindSync  -> 同步返回                     │
│  BindAsync -> 异步回调(耗时操作)          │
├─────────────────────────────────────────┤
│  C++ NativeApi 层                         │
│  各 Manager 封装系统调用                   │
│  FileManager / ProcessManager / ...      │
├─────────────────────────────────────────┤
│  Win32 系统 API                            │
└─────────────────────────────────────────┘

关键设计:前端不直接调 window.Zen,而是统一通过 web/lib/zen_api.jsZenAPI 封装类。这层封装的价值后面讲。

三、桥接层:两种绑定模式

C++ 侧在 NativeApi::SetupBindings()src/yyztools/NativeApi.cpp)里集中注册所有暴露给 JS 的方法。注册分两种:

// 同步绑定:绝大多数方法(配置、文件、窗口、剪贴板等)
m_bindManager->BindSync("getConfig",
    std::bind(&NativeApi::GetConfig, this, std::placeholders::_1));

// 异步绑定:仅耗时操作(OCR、应用信息查询等)
m_bindManager->BindAsync("getAppInfo",
    std::bind(&NativeApi::GetAppInfo, this,
        std::placeholders::_1,
        std::placeholders::_2  // 回调参数
    )
);

3.1 BindSync(同步)

绝大多数方法用同步绑定。前端调 window.Zen.getConfig(),C++ 侧执行完直接返回结果。适合:配置读写、文件操作、窗口控制、剪贴板、进程查询--这些都是「调一下立刻有结果」的操作。

3.2 BindAsync(异步)

异步绑定带第二个回调参数。前端发起调用后不阻塞,C++ 侧在后台线程做完活,再通过回调把结果送回前端。适合耗时操作:OCR 识别(本地模型推理要几百毫秒到几秒)、应用信息批量查询(要等索引就绪)。

注意一个反直觉点:yyzTools 里 getAppInfo(应用信息查询)是异步的,而 searchApp(应用搜索)是同步的。原因:getAppInfo 内部要 WaitForInitialization() 等后台索引线程就绪,冷启动直接查会拿到空表,所以做成异步;searchApp 走的是另一条不阻塞的路径。

四、返回值契约

所有方法的输出恒为 JSON 对象,统一字段:

  • error0(或 "0")表成功
  • 非零表失败,配 errorMsg

这里有个容易踩的坑:同一个 error 字段,在两条序列化路径下类型不同

五、类型陷阱与归一化

C++ 侧有两条 JSON 生成路径:

路径 出现位置 error 类型 布尔/整型字段
手拼字符串(BuildNormalResult / R"({...}) 模板) 约 139 处 数字 0 保持原类型
boost::property_tree + SaveToString 约 26 处 字符串 "0" 全部变字符串

所以前端不能直接比 res.error === 0--在 ptree 路径下它是 "0",直接比会判失败。归一化方案在 zen_api.js

class ZenAPI {
    static isOk(res) {
        return !!res && String(res.error) === '0';
    }
}

内部 String(res.error) === '0' 归一,同时兼容异常路径产出的 "-1",还加了 !!res 判空防止 res 为 undefined 时报错。所有业务代码判成功一律走 ZenAPI.isOk(res),不在页面里直连 window.Zen 自己比 error

5.1 布尔字段的更隐蔽陷阱

errorisOk 兜底,但布尔字段没有这层封装。exists / isDirectory 这类字段若来自 ptree 路径,值是字符串 "true" / "false"

JavaScript 里 "false" 是真值。如果用 truthy 或 !! 判断:

// 错误:永远进 if("false" 是真值)
if (res.isDirectory) { ... }

// 正确:显式比较
if (res.isDirectory === "true") { ... }

这类 bug 表现为「目标已存在」误报、文件夹图标错乱--逻辑没错,是类型比较错了。归一化只在 error 这一个高频字段做了,其余布尔字段要在前端显式 === "true"=== "1"

六、资源加载

主进程用 GetBaseUrl() 拿到 web 资源根目录,按 \{page}\index.html 加载各页面。tabdock 等通过 WebView2 虚拟主机名 https://yyztools.localhost/ 映射静态资源。

每个功能页面是独立目录(cmdplate、ocr、translate、download、calculator 等),互不耦合,Vite 多入口各自打包。新增页面在 vite.config.jsrollupOptions.input 登记一个入口即可。

七、这层架构的取舍

好的地方

  • 前端用熟悉的 Web 技术迭代 UI,不用学 WPF/Qt 的 XAML
  • C++ 层专注系统能力,职责清晰
  • Alpine.js 比 React/Vue 轻,没有虚拟 DOM 开销和构建链复杂度

要权衡的地方

  • 桥接层是手写的,没有类型系统约束 error 字段类型一致性,靠约定 + 归一化兜底
  • 两条 JSON 路径的类型差异是历史包袱,新代码统一走哪条需要持续治理
  • WebView2 依赖系统自带 Edge,Windows 10 早期版本要装 runtime

八、小结

yyzTools 这套架构的核心思路是:C++ 宿主 + WebView2 渲染 + 统一桥接层。桥接层用 BindSync / BindAsync 区分同步异步,用 ZenAPI.isOk 归一化返回值类型陷阱。前端不直连 window.Zen,统一走 zen_api.js 封装。

下篇会展开讲 WebView2 桥接层的具体实现细节,包括 BindManager 内部怎么把 C++ 函数挂到 JS 可调用、异步回调怎么安全地回到 UI 线程。


(本文为 yyzTools 架构复盘,基于实际项目源码。)

posted @ 2026-08-21 11:04  yyzTools  阅读(2)  评论(0)    收藏  举报