简介:这份资源面向需要在Windows桌面端嵌入网页渲染能力的C++开发者,尤其是使用MFC框架、希望用Chromium内核替代传统IE控件的工程师。内容围绕谷歌CEF(Chromium Embedded Framework)浏览器控件的集成与使用展开,覆盖从库文件引入、项目配置到浏览器实例创建、生命周期管理的完整流程,并涉及CefLifeSpanHandler、CefLoadHandler、CefRequestHandler等回调接口的实现思路,以及CEF多线程上下文处理等关键细节。资源包共450个文件,以250个h头文件、116个pak资源包、14个dll动态库为主,另含cc/cpp源码、lib静态库、vcxproj工程文件及exe示例程序,压缩包约166.98MB,结构完整便于直接编译调试。目前已有1210人学习下载,适合想快速搭建MFC+CEF基础框架、理解浏览器控件嵌入与交互机制的开发者参考。
1. 谷歌CEF浏览器控件的使用例子:为什么我最终放弃了系统自带 WebView
去年做一个工业上位机项目时,客户要求界面里嵌一个能跑现代前端框架的浏览器窗口,用来展示实时看板和三维组态。我第一反应是用系统自带的 WebView 控件,结果在目标机器上翻车了——那台工控机装的是老版本运行时,WebView 内核停留在几年前,Vue 3 的产物直接白屏,控制台报了一堆不认识的语法错误。折腾两天后我换成了 CEF,也就是 Chromium Embedded Framework,中文一般叫「谷歌CEF浏览器控件」。它把 Chromium 内核打包成一个可嵌入的库,让桌面程序拥有一个和 Chrome 几乎一致的渲染引擎,前端写什么它就能跑什么。
这个标题讲的「使用例子」,本质上是解决三类人的诉求:一是做桌面客户端但被系统 WebView 版本碎片化折磨的开发者;二是需要在原生界面里加载复杂网页、又要和原生代码双向通信的工程师;三是想把现有 Web 应用快速套壳成桌面程序、但不想学 Electron 那一整套 Node 体系的人。CEF 的核心价值在于「内核可控」——你可以指定用哪个 Chromium 版本,行为在所有机器上一致,不再看系统脸色。代价是包体变大、编译链变长,这些后面会细说。
2. CEF 的三种集成形态与选型:别一上来就编译源码
2.1 先搞清楚你要的是哪种「使用例子」
很多人搜「CEF 使用例子」,搜到的却是从源码编译 libcef 的教程,然后被几小时的编译时间和一堆依赖劝退。其实 CEF 的集成形态分三档,选错了方向,后面全是无用功。
第一档是二进制分发包(binary distribution),官方预编译好了 libcef.dll、头文件和静态库,你直接在自己的工程里链接即可。这是绝大多数项目的正确起点,十分钟就能跑起一个窗口。第二档是源码编译(source distribution),只有当你需要改 Chromium 本身、裁剪功能、或者目标平台没有现成二进制包时才需要,编译一次动辄数小时,机器内存不够还会中途挂掉。第三档是基于 CEF 的封装框架,比如某些 C++ 封装库或 .NET 绑定,它们把 CEF 的 C 接口包成更顺手的类,适合不想碰底层消息循环的团队。
我一般会建议:先用二进制包把最小窗口跑通,确认渲染和通信都符合预期,再考虑要不要深入。下面这张表是我选型时常用的判断依据。
| 集成形态 | 适用场景 | 上手成本 | 包体影响 |
|---|---|---|---|
| 二进制分发包 | 常规桌面嵌入、快速验证 | 低,小时级 | 约 100MB 起 |
| 源码编译 | 需裁剪内核、特殊平台 | 高,天级 | 可定制 |
| 第三方封装 | 不想写 C++ 消息循环 | 中 | 取决于封装层 |
2.2 最小可运行例子的目录结构
不管哪种形态,一个能跑的最小 CEF 程序都包含这几部分:入口函数、CEF 的初始化与关闭、一个继承自 CefClient 的客户端类、一个继承自 CefLifeSpanHandler 的窗口生命周期处理器,以及一个渲染进程的消息处理。目录上我习惯这样组织,方便后续扩展:
my_cef_demo/ ├── include/ # CEF 头文件,来自二进制包 ├── lib/ # libcef 静态库或导入库 ├── bin/ # libcef.dll、资源文件、locales 目录 ├── src/ │ ├── main.cpp # 入口,负责 CEF 初始化 │ ├── simple_client.h/.cpp # CefClient 实现 │ └── simple_handler.h/.cpp # 生命周期与加载处理 └── CMakeLists.txt这里有个血泪经验:bin/目录下的locales、resources、libcef.dll必须和可执行文件放在一起,缺一个都会在启动时直接崩,而且崩得没有任何提示,属于典型的黑匣子问题。我第一次部署时漏了locales,程序静默退出,查了半天才发现。
2.3 初始化与关闭的骨架代码
CEF 的初始化必须在创建任何窗口之前完成,关闭时又要保证所有引用计数归零,顺序错了就是内存泄漏或崩溃。下面这段是精简后的骨架,省略了平台相关的窗口创建细节:
// main.cpp #include "include/cef_app.h" #include "simple_client.h" int main(int argc, char* argv[]) { // 1. 初始化 CEF,传入命令行参数和全局设置 CefMainArgs main_args(argc, argv); // 2. 创建 App 实例,负责进程级回调 CefRefPtr<SimpleApp> app(new SimpleApp()); // 3. 执行子进程逻辑,渲染进程会走这里并返回 int exit_code = CefExecuteProcess(main_args, app.get(), nullptr); if (exit_code >= 0) { return exit_code; // 子进程已结束,直接返回 } // 4. 主进程设置,关闭沙箱便于调试,生产环境应开启 CefSettings settings; settings.no_sandbox = true; settings.multi_threaded_message_loop = false; // 与消息循环集成方式有关 // 5. 初始化 CEF CefInitialize(main_args, settings, app.get(), nullptr); // 6. 创建浏览器窗口(此处省略平台窗口代码) CefRefPtr<SimpleClient> client(new SimpleClient()); CefWindowInfo window_info; CefBrowserSettings browser_settings; CefBrowserHost::CreateBrowser(window_info, client.get(), "https://example.com", browser_settings, nullptr, nullptr); // 7. 进入消息循环,直到退出 CefRunMessageLoop(); // 8. 关闭 CEF,释放资源 CefShutdown(); return 0; }逻辑说明:CefExecuteProcess这一行是关键,它让同一个可执行文件既能当主进程又能当渲染子进程,CEF 的多进程架构就靠这个分叉。settings.no_sandbox在调试阶段建议关掉,否则某些系统上会因为权限问题起不来,但上线前要评估是否开启。multi_threaded_message_loop决定 CEF 是否自己起线程跑消息循环,如果你要和 Qt、MFC 等已有消息循环集成,通常设为 false,然后手动在合适时机调用CefDoMessageLoopWork。
参数说明:CefSettings里还有cache_path(缓存目录)、log_severity(日志级别)、locale(界面语言)等常用项。调试阶段把log_severity设为LOGSEVERITY_INFO,出问题时能看到 CEF 自己的日志,比盲猜强得多。
3. 原生与网页双向通信:CEF 使用例子里最容易踩坑的部分
3.1 用 CefMessageRouter 做 JS 调用原生
原生和网页通信是 CEF 使用例子里绕不开的一环。常见做法有两种:一种是用CefMessageRouter,前端通过window.cefQuery发请求,原生在OnQuery里处理并回调;另一种是注册自定义 Scheme 或对象。前者更规范,我一般优先用它。
// 在 SimpleClient 中创建消息路由 CefRefPtr<CefMessageRouterBrowserSide> message_router_; void SimpleClient::OnAfterCreated(CefRefPtr<CefBrowser> browser) { if (!message_router_) { CefMessageRouterConfig config; config.js_query_function = "cefQuery"; // 前端调用的函数名 config.js_cancel_function = "cefQueryCancel"; message_router_ = CefMessageRouterBrowserSide::Create(config); message_router_->AddHandler(new SimpleQueryHandler(), false); } } // 处理前端请求 class SimpleQueryHandler : public CefMessageRouterBrowserSide::Handler { public: bool OnQuery(CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, int64_t query_id, const CefString& request, bool persistent, CefRefPtr<Callback> callback) override { // request 是前端传来的字符串,通常约定为 JSON if (request == "getVersion") { callback->Success("1.0.0"); // 回调结果给前端 return true; } callback->Failure(-1, "unknown command"); return true; } };前端这样调用:
// 网页侧 window.cefQuery({ request: 'getVersion', onSuccess: function(response) { console.log('原生返回:', response); }, onFailure: function(code, msg) { console.error('调用失败:', code, msg); } });逻辑说明:OnQuery返回 true 表示这个请求由你处理,CEF 不会继续往下传。callback->Success和Failure是异步回调,前端拿到结果后触发对应的 onSuccess/onFailure。注意persistent参数,如果为 true,这个 query 会保持打开,可以多次回调,适合做事件推送。
参数说明:js_query_function的名字要和前端调用的一致,改了两边都得改。AddHandler的第二个参数false表示不处理第一帧之前的请求,一般保持默认即可。
3.2 原生主动调用 JS:时机比语法更重要
反向通信是原生调 JS,用browser->GetMainFrame()->ExecuteJavaScript()。语法很简单,但时机是坑:如果浏览器还没加载完就调用,JS 函数根本不存在,调用静默失败。
// 在页面加载完成后执行 JS void SimpleLoadHandler::OnLoadEnd(CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, int httpStatusCode) { if (!frame->IsMain()) return; // 只处理主框架 // 此时页面已加载完,可以安全调用页面里的函数 frame->ExecuteJavaScript( "window.onNativeReady && window.onNativeReady('hello from native');", frame->GetURL(), 0); }逻辑说明:OnLoadEnd是页面加载完成的回调,在这里调用 JS 最稳妥。加一个window.onNativeReady &&的存在性判断,避免页面没定义这个函数时报错。ExecuteJavaScript的第二个参数是脚本来源 URL,用于调试定位,第三个参数是起始行号,一般填 0。
参数说明:如果要在页面加载中途注入,可以用OnLoadStart配合CefFrame::ExecuteJavaScript,但此时 DOM 可能还没就绪,容易翻车。我的习惯是能用OnLoadEnd就用它,实在需要更早注入就用CefRegisterExtension注册扩展脚本。
3.3 通信数据的序列化约定
原生和 JS 之间只能传字符串,复杂结构要自己序列化。我一般约定用 JSON,前端JSON.stringify,原生用 CEF 自带的CefParseJSON解析。这样两边都好调试,抓包或打日志都能直接看懂。别用自定义分隔符拼字符串,字段一多就是灾难,改一个字段要动两边解析逻辑。
4. 资源加载与自定义协议:让本地文件也能被网页访问
4.1 为什么需要自定义 Scheme
桌面程序经常要加载本地资源,比如打包在安装目录里的 HTML、图片、模型文件。直接用file://协议会有跨域限制,而且路径处理在不同系统上不一致。常见做法是注册一个自定义 Scheme,比如app://,把本地目录映射成虚拟路径,网页里就能像访问普通 URL 一样访问本地文件。
// 注册自定义 Scheme,必须在 CefInitialize 之前调用 void RegisterCustomScheme() { CefRegisterSchemeHandlerFactory( "app", // scheme 名 "local", // 域名部分 new AppSchemeHandlerFactory()); } // 工厂类,为每个请求创建处理器 class AppSchemeHandlerFactory : public CefSchemeHandlerFactory { public: CefRefPtr<CefResourceHandler> Create( CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, const CefString& scheme_name, CefRefPtr<CefRequest> request) override { return new AppResourceHandler(); // 返回自定义处理器 } };逻辑说明:CefRegisterSchemeHandlerFactory必须在CefInitialize之前调用,否则不生效。scheme 名建议避开http、https、file这些保留字。处理器里根据请求路径去本地磁盘读文件,把内容写回响应。
参数说明:CefRegisterSchemeHandlerFactory的第二个参数是域名,可以理解为这个 scheme 下的一个虚拟主机名。注册后网页里用app://local/index.html就能访问。如果要做成标准 scheme(能被 fetch、XHR 正常访问),还需要在CefSettings里配置register_scheme_options,把它标记为 standard 和 secure。
4.2 资源处理器的读取与响应
处理器要实现Open、Read、GetResponseHeaders等方法。核心是把本地文件读成字节流,分块返回。这里有个容易忽略的点:Read可能被多次调用,要维护好读取偏移量,不能每次都从头读。
bool AppResourceHandler::Read(void* data_out, int bytes_to_read, int* bytes_read, CefRefPtr<CefCallback> callback) { if (offset_ >= content_.size()) { *bytes_read = 0; return false; // 读完了 } int remaining = static_cast<int>(content_.size()) - offset_; int to_copy = (std::min)(remaining, bytes_to_read); memcpy(data_out, content_.data() + offset_, to_copy); offset_ += to_copy; *bytes_read = to_copy; return true; }逻辑说明:content_是文件全部内容,在Open阶段读入内存。offset_记录当前读取位置,每次Read往后推。返回 false 表示流结束。对于大文件,不建议一次性读入内存,可以改成边读边发,但实现复杂度会上升,小文件场景直接全读更省事。
参数说明:bytes_to_read是 CEF 期望的字节数,实际返回的bytes_read可以小于它,CEF 会继续调用Read直到返回 false。GetResponseHeaders里要设置Content-Type和Content-Length,否则浏览器可能无法正确渲染。
5. 避坑与排查:CEF 使用例子里那些没人告诉你的细节
5.1 启动即崩溃,没有任何日志
现象:程序双击后一闪而过,或者进程还在但窗口不出现,日志目录空空如也。
原因:最常见的是bin/目录下缺少libcef.dll、locales或resources。CEF 在初始化时找不到这些文件会直接终止,而且默认不写日志。另一个可能是CefInitialize之前就创建了窗口或调用了 CEF 接口。
解决:先把bin/目录和可执行文件放同一级,确认locales里有zh-CN.pak和en-US.pak。然后在CefSettings里设置log_severity = LOGSEVERITY_INFO和log_file,让 CEF 把日志写到文件。如果还是没有,用调试器挂上去看退出码。
5.2 渲染进程反复重启
现象:任务管理器里能看到多个同名进程,其中一个不断消失又出现,页面始终白屏。
原因:渲染进程崩溃后被 CEF 自动拉起,循环往复。常见诱因是显卡驱动不兼容、沙箱权限问题,或者自定义 Scheme 处理器里抛了未捕获异常。
解决:先加--disable-gpu命令行参数排除显卡因素。如果用了自定义 Scheme,检查处理器里有没有空指针或越界。调试阶段可以设no_sandbox = true看是否与沙箱有关。CEF 的崩溃日志在crash_reports目录(如果配置了),没有的话可以接CefCrashReporter。
5.3 JS 调用原生没有反应
现象:前端cefQuery发出去了,原生OnQuery不触发,或者触发了但前端收不到回调。
原因:CefMessageRouter没有正确挂到 Client 上,或者OnProcessMessageReceived没有转发给 router。另一个常见原因是页面在 iframe 里,而 router 只处理主框架。
解决:确认SimpleClient里实现了GetMessageRouter或在OnProcessMessageReceived中调用了message_router_->OnProcessMessageReceived。如果是 iframe 场景,需要在CefMessageRouterConfig里把message_router_的 handler 注册到对应框架,或者改用CefFrame::ExecuteJavaScript反向注入。
5.4 打包后体积暴涨
现象:一个简单的桌面程序,安装包从几 MB 变成一百多 MB。
原因:CEF 的二进制包本身就大,libcef.dll加上资源文件轻松过百 MB。如果还带了调试符号(.pdb),体积翻倍。
解决:发布时只带release版的libcef.dll,去掉.pdb。locales目录只保留实际用到的语言包,比如只留zh-CN.pak。如果对体积敏感,可以考虑源码编译时裁剪掉不需要的组件,但成本很高,一般项目不值得。
5.5 输入法、拖拽、快捷键行为异常
现象:网页里的输入框无法输入中文,或者 Ctrl+C 复制失效,拖拽文件到窗口没反应。
原因:CEF 把很多系统级交互交给宿主程序处理,原生窗口没有正确转发消息。比如输入法需要处理WM_IME_*消息,快捷键需要在CefKeyboardHandler里拦截。
解决:在原生窗口的消息循环里,把输入法相关消息转发给CefBrowserHost::HandleTopLevelWindow或对应的处理接口。快捷键可以在OnPreKeyEvent里判断并返回 true 拦截。拖拽需要在窗口上注册DragAcceptFiles并处理WM_DROPFILES,再转成 CEF 的拖拽事件。
6. 进阶技巧:用离屏渲染把 CEF 变成可控的渲染引擎
前面讲的都是把 CEF 当窗口控件用,但 CEF 还有一个离屏渲染(Off-Screen Rendering,OSR)模式,不创建真实窗口,而是把渲染结果输出成位图,由你自己决定画在哪里。这个模式在需要把网页内容合成到 3D 场景、做视频流、或者在无窗口环境里跑渲染时特别有用。
开启 OSR 的方式是在CefWindowInfo里设置SetAsWindowless:
CefWindowInfo window_info; window_info.SetAsWindowless(0); // 参数是父窗口句柄,0 表示无父窗口 CefBrowserSettings browser_settings; browser_settings.windowless_frame_rate = 60; // 离屏渲染帧率 CefBrowserHost::CreateBrowser(window_info, client.get(), "app://local/index.html", browser_settings, nullptr, nullptr);设置后,渲染结果不会自动显示,而是通过CefRenderHandler::OnPaint回调给你一个位图缓冲区。你在这个回调里把像素数据画到自己的目标上,比如 OpenGL 纹理、内存位图或者视频编码器。
void SimpleRenderHandler::OnPaint( CefRefPtr<CefBrowser> browser, PaintElementType type, const RectList& dirtyRects, const void* buffer, int width, int height) override { // buffer 是 BGRA 格式的像素数据,宽高由参数给出 // 这里可以上传到纹理、拷贝到内存位图,或送入编码器 if (type == PET_VIEW) { // 处理主视图,dirtyRects 标出哪些区域变了,可做局部更新 UpdateTexture(buffer, width, height, dirtyRects); } }逻辑说明:OnPaint的buffer是 BGRA 排列的像素,每行字节数是width * 4。dirtyRects告诉你哪些矩形区域发生了变化,做局部更新时只处理这些区域能省不少性能。type为PET_VIEW是主视图,PET_POPUP是下拉框等弹出层,两者要分别处理,否则下拉菜单会画不出来。
参数说明:windowless_frame_rate控制渲染帧率,默认 30,设太高会吃满 CPU。OSR 模式下鼠标和键盘事件需要你手动转发,调用browser->GetHost()->SendMouseClickEvent、SendKeyEvent等,不转发的话网页完全无法交互。这是 OSR 最麻烦的地方,等于你要自己实现一套输入映射。
我踩过的一个坑是:OSR 模式下如果OnPaint里做耗时操作,会阻塞渲染线程,导致页面卡顿。正确做法是把 buffer 拷贝出来,丢给另一个线程去处理,OnPaint里只做拷贝。另外,OSR 模式对某些 GPU 加速特性支持有限,如果页面里有 WebGL 或视频,要测试清楚是否正常。
另一个实用技巧是配合CefBrowserHost::WasResized做动态分辨率。窗口大小变化时调用它,CEF 会按新尺寸重新渲染,OnPaint里的宽高随之改变。做自适应布局的看板时,这个调用必不可少,否则画面会被拉伸变形。
最后说个习惯:我每次集成 CEF 都会先写一个最小验证程序,只加载一个空白页,确认能启动、能关闭、日志正常,再往上加业务逻辑。CEF 的问题往往出在环境和配置上,而不是代码逻辑,先把地基验平,后面省下的排查时间远超这点投入。希望帮到你。
本文还有配套的精品资源,点击获取