ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CEF入门到实战:桌面应用嵌入Chromium浏览器的完整指南

CEF入门到实战:桌面应用嵌入Chromium浏览器的完整指南

简介:面向微软MFC/C++程序员的谷歌CEF浏览器控件集成实例,解决Windows桌面应用嵌入Chromium内核、呈现现代网页与交互功能的需求。示例以完整工程为主线,覆盖CEF初始化、窗口尺寸变化、浏览器实例创建、网页加载回调及多线程调度等关键环节,并演示生命周期处理器、加载处理器、请求处理器等接口的接入方式,能有效避开线程上下文和资源释放的常见问题。压缩包共450个文件,约166.98MB,以250个头文件、116个pak资源文件、14个动态库为主体,配套C++源码、导入库、可执行程序与解决方案工程。头文件与动态库用于搭建CEF编译和运行环境,pak资源文件用于网页本地化及内置资源,源码与工程可直接重新编译学习。目前已有1210人学习,包内附带完整的浏览器运行时资源与调试生成文件,工程中的回调接口实现可直接替换,适合需要快速上手、按自身业务定制桌面端浏览器的开发人员。

1. CEF浏览器控件:桌面软件里嵌浏览器的首选之一

CEF(Chromium Embedded Framework)即Chromium嵌入式框架,是桌面软件中嵌入浏览器控件时绕不开的一套开源库。它的价值在于把整个Chromium内核放进普通窗体,让HTML/CSS/JS与C++直接对话,常见于客户端网页壳、混合开发、数据可视化面板。适合两类人:一是给既有Win32/MFC程序补网页界面,二是需要精确控制浏览器版本的桌面团队。它比WebView2更可控,比Electron更轻,但前提是你能接受首次编译的复杂度。看完这篇,你应该能跑通第一个CEF窗口,并知道与进程相关的坑在哪。

2. 方案对比与资源账:WebView2、Electron和CEF怎么选

2.1 三种嵌入方案的实际边界

很多开发者一开始纠结的是用WebView2、Electron还是CEF。我接手的某跨平台桌面工具里,因为加载远程Web页面又要求C++调用JS,三种方案都做过预研,结论很明确:没有绝对优劣,只有适不适合当前工程。

方案内核打包体积C++接入成本典型场景
WebView2系统提供的Edge内核小,但运行时依赖系统安装低简单管理界面、登录页
Electron自带Chromium+Node大(约200MB起步)高,需引入Node侧全新桌面应用、前后端同构
CEF自带Chromium中等(约100MB)中,纯C++接口既有C++程序嵌网页、需要精确控制内核版本

WebView2最大的不确定性在于客户机器上有没有装对应运行时。即使微软允许离线打包,版本更新策略仍取决于系统更新,内存占用也受系统Edge的回收策略影响。Electron的优势是生态成熟,劣势是它本质上是整套Node运行时加浏览器的叠加,对传统MFC/Win32项目来说侵入感太强,要把业务逻辑从C++迁到JavaScript也不现实。CEF则直接以库形式存在,你还是写C++,只不过多了一个浏览器窗口,这正好满足“只用浏览器做界面,业务还在C++”的需求。

2.2 CEF的多进程模型与资源开销

CEF沿用Chromium的多进程架构,启动后至少会出现浏览器主进程、GPU进程、网络进程和若干渲染进程。这里有一个容易误判的地方:很多人以为CEF只多一个进程,实际打开任务管理器会发现至少三到五个进程,页面越多渲染进程越多。每个渲染进程的内存基线在80MB到200MB之间,GPU进程在复杂页面上也会占到50MB以上。

所以在选型阶段就要掂量清楚:目标用户机器如果只有4GB内存,CEF不是好选择;如果是一个运行在工控机或开发机上的内部工具,CEF很划算。多进程也带来一个排错思路——白屏时先看进程列表,渲染进程崩溃了主窗口还在,这在第五章节里细说。调试时还可以通过命令行参数--remote-debugging-port开调试端口,用Chromium的DevTools查看渲染进程内部状态,这是排查前端问题的核心手段。

2.3 版本选型与二进制获取

CEF的官方构建版本是二进制分发包(Automated Build),同时也有源码仓库。官方分发包会标明对应Chromium版本、CEF版本号和平台。我一般直接下载分发包,不自己编译源码,因为一次完整编译需要拉取十几GB的depot_tools仓库,耗时数小时甚至十几个小时,中途网络或磁盘一出问题就要重来,非必要不值得。

下载时注意三点:第一,选择stable分支而不是beta或者canary,稳定分支的API变更少;第二,确认平台是Windows 64位还是32位,这和你的目标应用一致;第三,解压后要保管好版本号,后续升级时头文件和libcef.dll必须完全匹配,混用高版本头文件和低版本dll会出现莫名其妙的崩溃,这在CEF里属于“血泪经验”。

3. 从下载二进制到跑通首屏:最小CEF工程与CMake生成VS项目

3.1 二进制分发包的目录结构与必需文件

解压CEF分发包后,核心文件分布在几个目录下:include/放C++头文件,libcef_dll_wrapper/是官方封装层源码,Debug/和Release/分别存放对应配置的libcef.dll和导入库,Resources/里是icudtl.dat、v8_context_snapshot.bin、*.pak等资源文件。工程运行时,这些文件必须完整放在exe同级目录或指定路径下,特别是缺少icudtl.dat时程序会在启动阶段直接闪退,没有任何友好提示。

把libcef_dll_wrapper源码加入工程这一步很多新手会漏。它的作用是封装CEF的C API成C++接口,如果不链接它,代码里CefRefPtr这类类模板都用不了。官方示例工程里用CMake添加这个子目录,我推荐沿用官方方式,而不是手动把几十个cpp文件拖进VS工程,否则后面升级CEF版本时维护成本会非常大。

3.2 用CMake生成Visual Studio工程

官方分发包自带cmake/目录,包含构建所需的CMake脚本。我习惯新建一个独立目录存放自己的CMakeLists.txt,与CEF解压目录分开,这样能随时切换CEF版本。以下是一个最小CMake配置:

cmake_minimum_required(VERSION 3.16) project(cef_demo) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 改成你自己的CEF解压路径 set(CEF_ROOT "D:/cef/cef_binary_130.0.1") # 引入CEF的CMake定义,会生成libcef_dll_wrapper等目标 add_subdirectory(${CEF_ROOT}/cmake cef) add_executable(cef_demo WIN32 main.cpp) # 链接CEF封装层和基础库 target_link_libraries(cef_demo libcef_dll_wrapper)

用CMake生成VS工程时,在构建目录执行命令:

cmake -G "Visual Studio 17 2022" -A x64 .. cmake --build . --config Release

-A x64指定64位架构,这里和下载的CEF分发包位数必须一致。--config Release决定最终链接Release模式的libcef.dll,调试时可以用--config Debug,但Debug版本体积大且堆栈信息多,运行速度明显慢。我一般只保留Release构建,前端问题通过DevTools定位,C++问题用日志定位,这样发布和调试接近一致。

3.3 初始化CEF并显示页面:最小C++代码骨架

CEF的初始化流程有固定顺序:先创建CefMainArgs,再设置CefSettings,然后调用CefInitialize,最后进入消息循环。下面这段代码是Windows窗口程序的最小骨架:

#include "include/cef_browser.h" #include "include/cef_command_line.h" #include "include/cef_sandbox_win.h" class SimpleApp : public CefApp, public CefBrowserProcessHandler { public: void OnContextInitialized() override { CefWindowInfo window_info; // 将浏览器窗口作为子窗口嵌入宿主窗口 RECT rect = {0, 0, 1024, 768}; window_info.SetAsChild(GetActiveWindow(), rect); CefBrowserSettings browser_settings; std::string url = "file:///D:/cef_demo/index.html"; // 创建浏览器实例 CefBrowserHost::CreateBrowser( window_info, nullptr, url, browser_settings, nullptr, nullptr); } IMPLEMENT_REFCOUNTING(SimpleApp); }; int main(int argc, char* argv[]) { CefMainArgs main_args; CefSettings settings; settings.no_sandbox = true; // 先关掉沙箱,避免权限问题 settings.multi_threaded_message_loop = false; CefRefPtr<SimpleApp> app(new SimpleApp()); CefInitialize(main_args, settings, app.get(), nullptr); // 运行CEF消息循环 CefRunMessageLoop(); CefShutdown(); return 0; }

这段代码里的OnContextInitialized是浏览进程上下文初始化完成的回调,CEF官方推荐在此时创建浏览器窗口。window_info.SetAsChild把页面嵌入你的窗口句柄,这里为了演示直接用了GetActiveWindow(),实际工程里应换成你主窗口的HWND。CefRunMessageLoop会阻塞当前线程直到调用CefQuitMessageLoop,这是一个需要特别注意的约束——你的程序不能再用自己的消息循环,否则键盘和鼠标事件会有延迟。

3.4 消息循环与线程模型的硬约束

CEF对线程模型有非常强制的约束:CefInitialize必须在主线程调用,浏览器相关操作必须在IO线程(TID_IO)或UI线程(TID_UI)执行,渲染进程的事只能在渲染线程做。最常见的问题是开发者把CEF初始化放在自己封装的线程类里,或者在OnContextInitialized里执行阻塞式等待,这会导致白屏或直接不创建窗口。

设置里有一个multi_threaded_message_loop选项,官方默认是false。如果设成true,CEF会自己起一个线程跑消息循环,你的主线程可以继续做别的,但对既有MFC程序来说,这会让界面句柄的时序变得不好控制。我建议保持false,直接用CefRunMessageLoop,简单可靠。退出时注意:CefShutdown必须在CefQuitMessageLoop返回后再调用,并且只能调用一次,否则进程会卡住或崩溃。

4. 前端与C++的双向通道:CefQuery、V8Handler与自定义Scheme

4.1 CefQuery实现JS异步请求C++再回调

页面和C++的通信,最推荐的方式是CefQuery。它类似AJAX请求,只是请求的接收方从服务器变成了C++代码。使用前要加载消息路由器,主进程和渲染进程都要各创建一个。

主进程里创建路由器和处理器:

#include "include/wrapper/cef_message_router.h" class MyQueryHandler : public CefMessageRouterBrowserSide::Handler { public: bool OnQuery(CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, const CefString& request, bool persistent, CefRefPtr<CefMessageRouterBrowserSide::Callback> callback) override { if (request == "get:user") { // 模拟从C++侧读取数据 std::string json = "{\"name\":\"demo\",\"age\":20}"; callback->Success(json); return true; // 返回true表示该请求已处理 } return false; // 返回false则继续传给其他Handler } };

同时在SimpleApp里注册路由器:

void SimpleApp::OnContextInitialized() override { // 创建并注册消息路由器(主进程侧) CefMessageRouterConfig config; config.js_query_function = "cefQuery"; config.js_cancel_function = "cefCancel"; message_router_ = CefMessageRouterBrowserSide::Create(config); message_router_->AddHandler(new MyQueryHandler()); // 创建浏览器窗口的代码略,同上 }

渲染进程侧需要把路由器挂到V8上下文里,实现CefRenderProcessHandler:

class SimpleRenderApp : public CefApp, public CefRenderProcessHandler { public: void OnContextCreated(CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, CefRefPtr<CefV8Context> context) override { if (message_router_) { message_router_->OnContextCreated(browser, frame, context); } } void OnContextReleased(CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, CefRefPtr<CefV8Context> context) override { if (message_router_) { message_router_->OnContextReleased(browser, frame, context); } } CefRefPtr<CefRenderProcessHandler> GetRenderProcessHandler() override { return this; } private: CefRefPtr<CefMessageRouterRendererSide> message_router_; };

JS端调用形式与AJAX非常接近:

window.cefQuery({ request: 'get:user', persistent: false, onSuccess: function(response) { const data = JSON.parse(response); console.log(data.name); // 输出demo }, onFailure: function(err, msg) { console.error('请求失败', err, msg); } });

persistent参数很关键,它决定回调是否长期保留。设置为false时,这次请求的上下文在回调完成后释放,适合一次性查询;频繁调用的接口建议设置true复用回调上下文,避免反复创建V8对象带来的性能损耗。如果同一页面存在多个Handler,OnQuery里返回true表示该请求被自己处理了,返回false会交给下一个Handler。

4.2 用CefV8Handler把C++函数绑到window对象

CefQuery能满足大多数异步场景,但有些计算密集的纯工具函数,比如字符串处理、数据转换,用V8Hanler会更直接。它的原理是在渲染进程里把C++函数包装成JS函数,挂到全局对象上。下面是把一个getVersion函数挂到window.capi对象上的示例:

class MyV8Handler : public CefV8Handler { public: bool Execute(const CefString& name, CefRefPtr<CefV8Value> object, const CefV8ValueList& arguments, CefRefPtr<CefV8Value>& retval, CefString& exception) override { if (name == "getVersion") { retval = CefV8Value::CreateString("1.2.3"); return true; } exception = "未知函数"; return false; } };

绑定需要拿到CefV8Context,所以这段代码只能写在渲染进程的OnContextCreated里:

void SimpleRenderApp::OnContextCreated(...) { // 先创建对象,再挂函数,最后挂到全局window.capi CefRefPtr<CefV8Value> obj = CefV8Value::CreateObject(nullptr, nullptr); obj->SetValue("getVersion", CefV8Value::CreateFunction("getVersion", new MyV8Handler()), V8_PROPERTY_ATTRIBUTE_NONE); context->GetGlobal()->SetValue("capi", obj, V8_PROPERTY_ATTRIBUTE_NONE); }

前端调用时就像调用普通JS函数:

console.log(window.capi.getVersion()); // 输出1.2.3

这里一个很容易踩的坑是,V8Handler所在的线程是渲染线程,所以执行耗时的C++逻辑会直接卡住页面。比如在getVersion里做文件读取或者数据库查询,页面就会出现明显的闪烁和失去响应。我一般只用V8Handler做轻量同步函数,涉及磁盘、网络或线程等待的场景一律用CefQuery。

4.3 自定义Scheme拦截请求让资源从本地加载

如果你的页面要加载加密资源或者想把HTML打包进exe,自定义Scheme是CEF提供的一个讨巧手段。所谓Scheme就是URL前缀,比如http://、file://,你完全可以注册一个自己的前缀,比如local://demo/index.html。CEF在渲染进程请求这类URL时,会回调到主进程的CefSchemeHandlerFactory,由C++代码决定返回什么数据。

注册自定义Scheme需要实现两个类:一个处理请求,一个提供工厂实例。

class LocalSchemeHandler : public CefResourceHandler { public: bool ProcessRequest(CefRefPtr<CefRequest> request, CefRefPtr<CefCallback> callback) override { // 这里可以统一返回一个内存中的HTML response_data_ = "<html><body><h1>Local Demo</h1></body></html>"; callback->Continue(); return true; } void GetResponseHeaders(CefRefPtr<CefResponse> response, int64_t& response_length, CefString& redirect_url) override { response->SetMimeType("text/html; charset=utf-8"); response->SetStatus(200); response_length = response_data_.size(); } bool ReadResponse(void* data_out, int bytes_to_read, int& bytes_read, CefRefPtr<CefCallback> callback) override { // 把数据拷贝给调用方 int available = static_cast<int>(response_data_.size()) - offset_; bytes_read = (available > bytes_to_read) ? bytes_to_read : available; if (bytes_read > 0) { memcpy(data_out, response_data_.c_str() + offset_, bytes_read); offset_ += bytes_read; return true; } return false; } private: std::string response_data_; int offset_ = 0; };

在OnContextInitialized里注册这个Scheme,同时定义一个工厂类返回上述Handler:

class LocalSchemeHandlerFactory : public CefSchemeHandlerFactory { public: CefRefPtr<CefResourceHandler> Create(...) override { return new LocalSchemeHandler(); } }; // 注册到CEF CefRegisterSchemeHandlerFactory("local", "demo", new LocalSchemeHandlerFactory());

注册后,前端访问local://demo/index.html就会走这段C++代码。实际工程里,我会把前端打包后的dist目录整个映射到自定义Scheme,再做一层压缩解压,这样发布时前端资源就被封装进了exe,不会散落一堆文件。不过要留意的是,自定义Scheme里的Cookie和LocalStorage行为与http不同,涉及到需要持久化存储的场景要另行处理。

5. CEF使用高频问题排查:进程崩溃、中文乱码、白屏与证书拦截

5.1 渲染进程反复崩溃:先关沙箱再往下查

现象:页面打开后短暂显示内容,几秒后整个窗口变灰白,或者弹出“页面已崩溃”的提示,任务管理器里能看到渲染进程不断重启。

原因:CEF在Windows上默认启用沙箱,但沙箱依赖系统安全策略正确配置。如果程序以管理员权限运行又没正确处理沙箱,或者你的程序用到了CreateProcess之类的自启动逻辑,渲染进程很容易被误杀。还有一种常见情况是下载的libcef.dll与头文件版本不一致,导致IPC消息格式错乱,进程频繁崩溃。

解决:最直接的办法是在初始化里设置settings.no_sandbox = true,这是排除沙箱干扰最快的方式。确认崩溃不是沙箱引起后,再把no_sandbox去掉测试。同时检查分发包的头文件和dll是否来自同一个版本,替换时严格要求整个include/目录和libcef.dll一起更换。我遇到过只有dll被误覆盖、头文件还是旧版本的情况,表现为代码能编译但运行时每次打开新页面都崩溃,花费了一天时间才定位到是版本串了。

5.2 中文乱码与JSON字符串转义

现象:页面显示的内容出现类似测试的乱码,或者C++传给JS的中文变成问号;反过来,JS提交的中文在C++侧接收后打印出来全是乱码。

原因:CEF内部统一使用UTF-8编码处理URL和页面内容,但C++侧的CefString::ToString()默认按UTF-8转换,如果原始字符串是GBK/GB2312编码,转换后当然是乱的。另一个常见原因是页面HTML缺少<meta charset="utf-8">声明,浏览器按本地系统编码解析,中文直接变成乱码。

解决:HTML里务必添加<meta charset="utf-8">,CSS、JS文件本身也统一保存为UTF-8无BOM格式。C++传给前端的数据,统一先转成UTF-8字符串再交给CefQuery的callback->Success()。对于JSON字段,在C++侧直接用第三方JSON库序列化,避免手工拼接转义。前端拿到的response用JSON.parse解析,不要用eval,否则特殊字符会出问题。如果是从本地文件读取内容再回传,读取后显式做一次编码转换,不要假设文件就是UTF-8。

5.3 白屏与加载失败

现象:窗口创建成功,但浏览器区域一直空白,或者第一次显示时闪一下白才出内容。断点看代码发现OnContextInitialized没有被调用,或者调用了但页面没渲染出来。

原因:白屏有三个常见来源。第一个是初始化时序问题,CefInitialize之后立即调用CefBrowserHost::CreateBrowser,但渲染进程还没就绪,窗口只能黑屏或白屏。第二个是资源文件缺失,尤其是Resources/目录里的icu文件和*.pak文件没放在exe同级目录或路径不对。第三个是URL加载失败,页面请求404或证书错误,页面区域显示白屏,但窗口边框还在。

解决:第一,确认CefInitialize的返回值和日志,启动失败时通常有错误输出。第二,开发时在命令行参数里加上--remote-debugging-port=9222,然后用本机浏览器打开http://localhost:9222查看渲染进程的console和network。这一步能直接看到页面是加载失败还是渲染崩溃,比猜快得多。第三,确认设定的URL可以单独在浏览器中打开,排除页面自身问题。如果加载的是本地文件,注意file://协议的路径需要绝对路径,相对路径不会正确解析。

5.4 证书错误页拦截

现象:访问内网自签证书的HTTPS接口或页面时,CEF会显示“您的连接不是私密连接”的拦截页,用户无法继续操作,即使内网环境本就信任该证书。

原因:Chromium的安全策略不受CEF的直接控制,内网IP或自签证书默认不受信任。这种拦截在实际内网工具中非常常见,尤其是在通过IP地址访问测试环境时。

解决:在开发或内网部署阶段,给CEF增加命令行参数--ignore-certificate-errors关闭证书校验。更推荐在CefRequestHandler的OnCertificateError回调中处理,这样可以在代码里指定哪些域名忽略证书错误,哪些继续保持校验,不会全局放松安全策略。实现方法是在CefRequestHandler中重写OnCertificateError并调用callback->Continue(true)。注意--ignore-certificate-errors只适合内部测试,给客户交付的产品不要全局加这个参数,安全审计会一票否决。

6. 让CEF在项目中更顺手的三件事:缓存隔离、右键菜单精简与退出清理

6.1 缓存目录隔离

默认情况下CEF会把缓存写在用户目录下的某个自动生成文件夹里,程序卸载后缓存还在磁盘上。多开应用时,多个CEF实例抢同一缓存目录会造成数据紊乱。我在实际项目中强制设置独立的缓存路径:

settings.cache_path = "D:/cef_cache/" + app_name;

每一项主进程启动前,根据当前用户和模块名拼接唯一路径,确保不同模块的缓存互不影响。验证是否生效可以打开DevTools的Application面板,查看Storage类型下的根目录路径。

6.2 精简右键菜单

CEF默认右键菜单包含“刷新”“后退”“检查元素”等选项,对内网生产工具有时会引发误操作。我实现CefContextMenuHandler,在OnBeforeContextMenu里把菜单模型清空,只保留一个“打开调试台”项:

void OnBeforeContextMenu(CefRefPtr<CefBrowser> browser, CefRefPtr<CefFrame> frame, CefRefPtr<CefContextMenuParams> params, CefRefPtr<CefMenuModel> model) override { model->Clear(); model->AddItem(1, "打开调试台"); }

6.3 退出清理

程序退出时不按顺序清理会让进程卡住数秒。我在退出流程里固定走:先CefQuitMessageLoop(),消息循环退出后再CefShutdown(),并且只执行一次。从那以后我每次提测都强制走一遍退出流程,确认任务管理器里没有任何残留进程,再放给测试。这也算是一个给自己省麻烦的习惯,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进