十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MFC项目嵌入CEF浏览器:从配置到踩坑的全流程实践

MFC项目嵌入CEF浏览器:从配置到踩坑的全流程实践 简介在MFC微软基础类库中嵌入CEFChromium嵌入式框架浏览器的完整示例项目由Code Project社区专家编写主要面向需要在桌面应用中集成现代Web渲染能力的C/MFC开发者。内容围绕CEF与MFC的整合展开演示了从初始化CEF运行环境、创建承载浏览器视图的子窗口到处理消息循环并通过生命周期处理器、加载处理器等接口实现页面加载与交互控制的完整流程适用于从入门到进阶的MFC开发者参考学习。压缩包共26个文件以头文件与C源文件为主体辅以项目配置、资源脚本、图标、位图以及一个HTML测试页面整体体积仅81KB结构紧凑便于定位关键代码。源码中清晰划分了视图、客户端处理、文档等模块已有1225人学习读者可据此快速理解CEF嵌入MFC的窗口创建与消息协作机制并基于此示例扩展自定义协议、本地资源处理或JavaScript调用功能。 在MFC项目里嵌入浏览器这个需求我这些年看过太多次了。早期大家清一色用WebBrowser控件本质是IE内核后来发现这东西兼容性实在太折腾——CSS3动画卡顿、JavaScript新语法不支持、用户机器上IE版本千奇百怪崩溃起来还直接拖垮整个主程序。于是很多人开始找替代方案最后目光都落在CEFChromium Embedded Framework上。本文就把我基于MFC对话框工程嵌入CEF做浏览器demo的完整过程写出来包括版本选型、工程配置、代码骨架、生命周期管理以及我实际踩过的坑。适合有MFC基础、想在老项目里引入现代Web渲染能力的开发者参考。1. 为什么MFC项目嵌入网页偏偏都选CEF而不是WebBrowser控件先说结论能用CEF就别用WebBrowser控件除非你对系统兼容性和渲染能力没有任何要求。1.1 WebBrowser控件的三个致命痛点我最早在MFC里做内嵌网页用的是CHtmlView和WebBrowser2当时项目需求是显示一个带复杂图表的数据大屏。开发阶段一切正常发布后麻烦就来了。首先是渲染引擎版本锁定WebBrowser控件使用的是用户操作系统自带的IE内核MSHTMLWin7默认IE8Win10即便装了IE11WebBrowser控件默认文档模式也可能被策略限制在IE7。客户机器上页面直接乱掉明明本地调试完美。其次是JS性能。现在的前端框架动辄几十MB的包Vue、React这类库在IE的旧版JScript引擎上跑性能可以用惨烈来形容。数据一刷新页面就卡死用户反馈白屏很久才出内容。再加CSS3的flex布局、渐变、圆角、动画这些特性在IE老内核上的渲染结果跟Chrome完全两回事。最后是崩溃隔离。WebBrowser控件和主进程跑在同一个地址空间只要网页脚本触发一个未处理的资源错误或者加载了个有问题的ActiveX插件整个MFC程序就跟着崩了。我在一个客户现场遇到过反复崩溃问题最后定位到是页面里某个第三方统计脚本在特定网络环境下造成死锁。这种问题你几乎无法在代码层面防御。1.2 CEF能带来什么CEF把Chromium整个渲染引擎封装成库嵌入到你的应用程序里。这就意味着渲染一致性你用Chrome DevTools调试什么效果CEF里就是什么效果不再依赖用户系统中的浏览器版本。性能底座V8引擎的JS执行速度对老IE是数量级碾压页面再复杂也不至于把主程序拖死。进程隔离CEF默认多进程架构渲染进程Render进程崩溃后主程序依然存活还可以通过CefRenderProcessHandler的OnProcessCrashed回调捕获崩溃事件做恢复。可控性强自定义协议、JS与C双向调用、请求拦截、缓存控制、Cookie管理都是API级的支持WebBrowser控件想做这些事基本要靠乱七八糟的注册表和COM接口操作。用一句话概括WebBrowser解决的是网页能显示出来CEF解决的是按现代Web标准把复杂页面稳定地跑起来。2. 准备CEF运行库版本、目录结构与VS工程配置这部分容易被轻视实际上坑不少。2.1 版本获取与命名规则CEF官方发布页提供二进制包命名类似cef_binary_107.1.12ge1ec25fchromium-107.0.5304.68_windows64.tar.bz2。这串名字里有两个关键信息前面是CEF自己的版本号后面是Chromium版本号。选版本时主要看Chromium版本它决定你的渲染能力基线。我的建议32位还是64位看你的MFC程序目标平台。很多老项目是Win32x86编译的那就选X86 32位包。虽然32位进程内嵌Chromium会受内存限制影响但至少保证兼容性。对应VS版本下载页会标明该版本用什么版本的Visual Studio编译的。CEF官方对VS版本比较敏感比如用VS2015编译的包在VS2019工程里可能会遇到运行时库冲突。实际使用中选择与你的开发环境匹配的CEF发布版可以省掉很多莫名其妙的链接错误。解压后你会看到这些核心内容libcef.dll—— 核心引擎库体积通常上百MBcef.pak、devtools_resources.pak、icudtl.dat—— 资源文件、DevTools资源、ICU国际化数据v8_context_snapshot.bin—— V8引擎快照libcef.libinclude/—— 导入库和头文件Resources/目录 —— 一些扩展资源Release/目录 —— 子进程所需的exe主要是chrome_elf.dll、libEGL.dll、libGLESv2.dll、snapshot_blob.bin2.2 工程包含目录和库依赖在VS里创建或打开MFC对话框工程后做三件事项目属性 - C/C - 常规 - 附加包含目录填入CEF解压目录下的include路径。链接器 - 常规 - 附加库目录填入CEF解压目录。链接器 - 输入 - 附加依赖项加入libcef.lib。另外有个容易漏掉的细节CEF的头文件依赖Windows SDK较新版本。如果你的VS是老版本比如VS2010可能编译时头文件报错提示缺少某些定义。这种时候建议直接把开发环境升级到VS2015或更高别在头文件兼容性上浪费时间。还有一个字符集问题CEF的头文件和API使用CefString它内部可以处理UTF-8和UTF-16转换。你的MFC工程如果是使用Unicode字符集通常默认是这样那没问题如果老工程用的是多字节字符集需要额外注意字符串转换否则传入的中文路径、中文URL极易变乱码。我强烈建议把工程切换成Unicode。2.3 运行时文件别放错位置CEF的dll、pak、bin等文件最终要和你的exe在同一个目录或者能被系统找到的位置。特别是子进程exeCEF默认会从主程序模块所在目录查找子进程模块。如果你在开发环境跑却看不到页面先检查是不是缺少release版本的运行库文件。另外libcef.dll对GPU进程、网络进程等子进程文件也有依赖发布时最好直接把整个Release目录内容原样拷贝到exe同级目录。3. 进程模型先搞懂再说改造CEF的多进程架构与MFC的搭配方式很多人在MFC里集成CEF失败不是因为API不会用而是没理解它的进程模型和消息循环机制。3.1 谁是谁的子进程启动一个带CEF的程序后打开任务管理器你会看到多个进程一个主进程Browser进程就是你的MFC程序本体。若干子进程包括Render进程网页渲染和JS执行、GPU进程硬件加速、Network进程网络请求、Utility进程各种辅助任务。这种架构的灵感最早来源于Chromium的沙盒安全设计每个页面在独立进程里即使页面崩溃也不影响浏览器主体。嵌入到应用后最直观的好处就是页面崩溃不会拖垮MFC主程序。但代价是你需要在进程启动早期就告诉CEF当前进程是Browser还是Renderer。代码里是这样的CefMainArgs main_args(hInstance); CefApp* app new MyCefApp(); // 关键入口根据进程类型返回不同的CefApp实现 int exit_code CefExecuteProcess(main_args, app, nullptr); if (exit_code 0) { // 子进程执行到这里就会自行退出不会走MFC的InitInstance return exit_code; } // 只有Browser进程会继续执行到这里 CefInitialize(main_args, settings, app, nullptr);这句话很重要CefExecuteProcess在子进程里会阻塞并处理该进程的消息循环完成后直接返回退出码。所以在CefExecuteProcess返回非负值时你的InitInstance里必须直接return绝对不能再往下执行MFC初始化。否则同一个代码会在所有子进程里重复执行造成一连串的窗口、资源初始化冲突。3.2 消息循环集成三种姿势CEF有三种消息循环模式对应集成到MFC的三种策略独立消息循环模式设置settings.multi_threaded_message_loop trueCEF自己在内部线程跑消息循环。这种模式最简单MFC这边完全不用管CEF的消息泵。缺点是有些CEF回调比如某些需要在Browser线程执行的操作需要你小心线程切换而且沙盒相关的一些行为有差异。我做Demo时一般先用这种模式跑通功能再考虑细调。CefDoMessageLoopWork模式保持multi_threaded_message_loop false然后在MFC的OnIdle或者定时器比如SetTimer(1, 10, NULL)里频繁调用CefDoMessageLoopWork()。这相当于把CEF的消息处理塞进MFC主线程的消息循环里。好处是回调线程模型统一坏处是如果定时器间隔太大或主线程被阻塞CEF的UI响应会卡顿。CefRunMessageLoop模式直接调用CEF的CefRunMessageLoop()接管整个消息循环。这个模式不适合MFC因为MFC自身的消息分发PreTranslateMessage、命令路由、OnCmdMsg会被绕过去。除非你写的是纯SDK程序否则别用。我实际项目里优先推荐方案1multi_threaded_message_loop true。MFC程序经常有耗时的业务操作会阻塞主线程独立消息循环可以让CEF即使在MFC主线程卡顿的情况下依然保持页面响应比如显示加载动画。4. Demo落地全流程从对话框工程到第一屏网页显示这里我按一次完整实践来写每一步都是可以直接照抄的。4.1 新建工程并放一个容器控件打开VS新建一个基于对话框的MFC工程名字随便起比如CefDemo。建议选在静态库中使用MFC发布时少带一堆DLL依赖。在资源编辑器里往对话框上拖一个Picture ControlID为IDC_CEF_CONTAINER把它调整成你想要的大小。这个控件不会被CEF真正使用只是用来提供一个稳定的窗口句柄位置和尺寸基准。你完全可以直接拿对话框的GetSafeHwnd()但是用Picture Control可以更精确控制显示区域后续OnSize调整尺寸也方便。把Picture Control的Notify属性改成True这样它能收到鼠标消息否则它只是一个静态控件点击事件不会传给父窗口。4.2 初始化CEF的完整代码骨架在CefDemoApp::InitInstance()里加入如下初始化逻辑#include include/cef_app.h #include include/cef_client.h #include include/cef_browser.h #include include/cef_scheme.h // 1. 设置CEF主参数 CefMainArgs main_args(m_hInstance); // CWinApp的m_hInstance // 2. 封装CefApp主要用于进程类型区分 // 建议单独写一个类继承CefApp CefRefPtrCefApp app new CefDemoApp(); // 3. 执行子进程分流必须在CefInitialize之前 int exit_code CefExecuteProcess(main_args, app, nullptr); if (exit_code 0) { // 注意这里是子进程的出口不要在这里做MFC的事 return exit_code; } // 4. 配置CEF全局设置 CefSettings settings; settings.no_sandbox true; // 开发阶段建议关闭沙盒 settings.multi_threaded_message_loop true; // 独立消息循环 settings.log_severity LOGSEVERITY_DEFAULT; // 设置缓存目录便于debug CefString(settings.cache_path) L.\\cef_cache\\; CefString(settings.log_file) L.\\cef_debug.log; // 设置语言为中文 CefString(settings.locale) Lzh-CN; // 设置用户代理的扩展名 // 注意大多数情况下不需要改User-Agent这里只是示意 // 5. 初始化CEF if (!CefInitialize(main_args, settings, app.get(), nullptr)) { AfxMessageBox(_T(CEF初始化失败)); return FALSE; }然后重写ExitInstance()在程序退出时释放CEFint CCefDemoApp::ExitInstance() { // 这里不能直接调CefShutdown需要确保所有CefRefPtr都被释放 CefShutdown(); return CWinApp::ExitInstance(); }这里有个细节特别容易踩CefShutdown必须等你代码里所有的CefRefPtr对象析构之后调用。如果你在对话框类里保存了CefRefPtrCefBrowser之类的成员变量务必在CefShutdown之前把它们置空或让它们随对话框销毁。我见过很多人在ExitInstance里先CefShutdown然后对话框析构时又去释放CEF对象导致程序退出时崩溃。4.3 创建Browser窗口并挂接到对话框初始化CEF之后创建设置里的CefSettings还不能直接创建浏览器窗口你需要先准备一个CefClient对象。首先编写一个CefClient派生类class CCefClientApp : public CefClient, public CefLifeSpanHandler, public CefLoadHandler { public: CCefClientApp(CWnd* pWnd) : m_pParentWnd(pWnd) {} // CefClient基类需要实现这些接口返回this即可 virtual CefRefPtrCefLifeSpanHandler GetLifeSpanHandler() override { return this; } virtual CefRefPtrCefLoadHandler GetLoadHandler() override { return this; } // CefLifeSpanHandler virtual void OnAfterCreated(CefRefPtrCefBrowser browser) override; virtual bool DoClose(CefRefPtrCefBrowser browser) override; virtual void OnBeforeClose(CefRefPtrCefBrowser browser) override; // CefLoadHandler virtual void OnLoadError(CefRefPtrCefBrowser browser, CefRefPtrCefFrame frame, ErrorCode errorCode, const CefString errorText, const CefString failedUrl) override; virtual bool IsReady() const { return m_browser.get() ! nullptr; } private: CWnd* m_pParentWnd; CefRefPtrCefBrowser m_browser; IMPLEMENT_REFCOUNTING(CCefClientApp); };在OnAfterCreated里把browser保存起来void CCefClientApp::OnAfterCreated(CefRefPtrCefBrowser browser) { m_browser browser; }接下来在对话框的OnInitDialog里创建BrowserBOOL CCefDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 获取Picture Control的窗口矩形客户区坐标 CWnd* pContainer GetDlgItem(IDC_CEF_CONTAINER); CRect rect; pContainer-GetWindowRect(rect); ScreenToClient(rect); // 创建CefClient实例 m_client new CCefClientApp(this); // 设置窗口信息把CEF窗口挂接为Picture Control的子窗口 CefWindowInfo window_info; window_info.SetAsChild(pContainer-GetSafeHwnd(), CefRect(rect.left, rect.top, rect.Width(), rect.Height())); CefBrowserSettings browser_settings; // 如果需要加载本地文件这里设置file协议访问权限 // browser_settings.file_access_from_file_urls STATE_ENABLED; // browser_settings.universal_access_from_file_urls STATE_ENABLED; // 创建浏览器 CefBrowserHost::CreateBrowser(window_info, m_client.get(), CefString(Lhttps://www.baidu.com), browser_settings, nullptr, nullptr); return TRUE; }注意SetAsChild的RECT是CEF窗口相对父窗口客户区的坐标。如果你直接从Picture Control拿到的GetWindowRect是全屏坐标需要ScreenToClient转换。这一步做错了页面就会出现在对话框左上角或者根本看不到。4.4 处理尺寸变化对话框的OnSize里要同步调整CEF窗口大小让它始终铺满整个Picture Control区域void CCefDemoDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (m_client m_client-IsReady()) { CWnd* pContainer GetDlgItem(IDC_CEF_CONTAINER); if (pContainer pContainer-GetSafeHwnd()) { CRect rect; pContainer-GetWindowRect(rect); ScreenToClient(rect); HWND hwnd m_client-GetBrowser()-GetHost()-GetWindowHandle(); if (hwnd ::IsWindow(hwnd)) { ::SetWindowPos(hwnd, NULL, rect.left, rect.top, rect.Width(), rect.Height(), SWP_NOZORDER); } } } }这一步不做的话窗口一拉伸页面周围就会出现大片白边体验很差。到这一步你已经能看到一个MFC窗口里正常渲染出百度首页了。5. 踩坑实录崩溃、白屏、键盘失灵与进程残留Demo能跑起来只是第一步把它做到稳定可发布才是真正的开始。下面这些坑我在不同项目里几乎每次都会碰到。5.1 白屏原因五花八门白屏是CEF嵌入最常见的现象通常有几种原因原因一CEF运行环境不完整。缺icudtl.dat或cef.pak、v8_context_snapshot.bin会导致页面渲染不出来。排查方法CEF的debug日志会明确告诉你资源加载失败先看log_file。原因二沙盒问题。早期版本在Windows下如果开启沙盒而不做任何配置子进程可能启动失败。开发阶段直接把no_sandbox true省心。如果必须在沙盒模式下运行需要单独写沙盒的Hook配置非常繁琐我一般不建议在老MFC项目里折腾。原因三GPU加速兼容问题。某些显卡驱动和Chromium的GPU进程有兼容性问题白屏或者闪屏。设置settings.disable_gpu true可以禁用GPU加速。这个是保底方案大部分情况用软件渲染也能接受。原因四Url加载策略被拦截。如果你加载的是file://协议路径CEF默认对本地文件的访问限制很严。需要设置browser_settings里的file_access_from_file_urls和universal_access_from_file_urls为STATE_ENABLED否则本地HTML引用的JS、CSS、图片会被当成跨域请求拦截。5.2 键盘输入失灵焦点管理问题CEF窗口创建成功后如果对话框上有编辑框或你点了一下空白处再点回CEF页面发现文本框无法输入文字。这是因为键盘焦点没有正确传递给CEF窗口。解决办法是在对话框的PreTranslateMessage里把键盘消息主动发给当前有焦点的CEF窗口BOOL CCefDemoDlg::PreTranslateMessage(MSG* pMsg) { if (m_client m_client-IsReady()) { HWND hwnd m_client-GetBrowser()-GetHost()-GetWindowHandle(); if (hwnd ::IsWindow(hwnd) ::GetFocus() hwnd) { // 如果键盘焦点在CEF窗口内就不让MFC做默认翻译 // 直接调用CEF的消息处理 ::TranslateMessage(pMsg); ::DispatchMessage(pMsg); return TRUE; } } return CDialogEx::PreTranslateMessage(pMsg); }另外CEF窗口获得焦点时最好在OnSetFocus事件里调用SetFocus否则从CEF切换回MFC对话框再切回来时焦点会丢失。5.3 关闭程序时子进程残留MFC对话框点右上角关闭主程序退出了任务管理器里还挂着一堆CefDemo.exe的子进程。这个问题几乎必现原因是关闭窗口时没有正确通知CEF关闭浏览器。正确流程在对话框的OnClose或OnDestroy里调用browser-GetHost()-CloseBrowser(true)。在CefLifeSpanHandler::DoClose里返回true告诉CEF我已经处理了关闭并且不要销毁宿主窗口。等OnBeforeClose回调触发后表示浏览器已完全关闭此时再销毁对话框窗口。具体代码void CCefDemoDlg::OnClose() { if (m_client m_client-IsReady()) { m_client-GetBrowser()-GetHost()-CloseBrowser(true); } // 注意不能立即CDialogEx::OnClose() // 要等OnBeforeClose回调里再销毁 } bool CCefClientApp::DoClose(CefRefPtrCefBrowser browser) { // 告诉CEF不要自动关闭宿主窗口我们自己控制 return true; } void CCefClientApp::OnBeforeClose(CefRefPtrCefBrowser browser) { m_browser nullptr; // 在这里通知对话框销毁窗口 if (m_pParentWnd) ::PostMessage(m_pParentWnd-GetSafeHwnd(), WM_CEF_BROWSER_CLOSED, 0, 0); }对话框收到WM_CEF_BROWSER_CLOSED后再调用DestroyWindow()完成退出。这个流程略繁琐但很关键。省略任何一步都会造成子进程残留或程序退出时崩溃。5.4 渲染进程崩溃恢复CEF的渲染进程可能因为页面内存暴涨、JS代码死循环等原因崩溃。默认行为是整个浏览器关闭体验比较差。实际上通过CefLoadHandler的OnRenderProcessTerminated回调可以感知到崩溃并通过browser-Reload()重新加载void CCefClientApp::OnRenderProcessTerminated(CefRefPtrCefBrowser browser, TerminationStatus status) { // 记录日志或弹提示 // 简单方案重新加载 browser-Reload(); }不过要注意防止崩溃后反复加载导致死循环。实际项目里我会做一个简单的计数器比如连续崩溃3次就停止自动加载并弹窗提示用户。6. JS双向交互与本地资源访问把Demo推向实用浏览器能显示页面只是第一步真正落到业务上是页面和MFC程序相互调用。这里说两种最常用的交互方式。6.1 注册本地协议用cef://加载本地页面在资源和网页都在本地的情况下我习惯注册一个自定义协议比如http://local然后通过CefSchemeHandlerFactory拦截这个协议的请求返回本地文件内容。这样做的好处是页面里的fetch、XHR可以走同样的协议避免file://的跨域限制和路径映射混乱。最小注册代码CefRegisterSchemeHandlerFactory(http, local, new LocalSchemeHandlerFactory());其中LocalSchemeHandlerFactory继承CefSchemeHandlerFactory重写CreateHandler返回一个自定义的CefResourceHandler在ProcessRequest里读取本地HTML文件并写出。这套机制比较绕但它能让你完全掌控什么URL映射到哪个磁盘文件。如果只是demo演示不打算加复杂逻辑也可以直接用file://加允许跨域配置5分钟跑通就够了。6.2 C调用JS直接调用浏览器当前页面里的JS函数void CCefDemoDlg::CallJavaScript(const CefString functionName, const CefString arg) { CefRefPtrCefFrame frame m_client-GetBrowser()-GetMainFrame(); if (!frame) return; CefString script functionName L( arg L);; frame-ExecuteJavaScript(script, frame-GetURL(), 0); }这是最简单的字符串拼接方式。参数复杂时建议用JSON序列化避免转义地狱。6.3 JS调用C绑定原生函数CEF推荐的方式是通过CefV8Handler或CefMessageRouter。CefMessageRouter是官方示例里最常用的方案它封装了消息路由支持页面和C之间双向调用。基本用法创建一个CefMessageRouterConfig查询JS对象名为cefQuery取消函数名为cefQueryCancel。创建CefMessageRouterBrowserSide实例注册到你的CefClient的OnProcessMessageReceived里。注册一个CefMessageRouterBrowserSide::Handler来处理JS发来的请求。页面端调用window.cefQuery({ request: getUserInfo, onSuccess: function(response) { ... }, onFailure: function(err, msg) { ... } });C端在Handler里解析request字符串调用MFC的业务代码通过Callback::Success()把结果返回给页面。整个链路是异步的所以页面端一定要用回调方式接收结果。6.4 一个小踩坑中文乱码JS和C交互时最常见的坑就是中文编码。C端如果是Unicode字符串CString默认UTF-16传到JS端会变成乱码或直接出错。处理方式很统一所有跨C/JS边界的字符串都走UTF-8。C端转换CStringA utf8Str CW2A(cszUnicode, CP_UTF8);JS端正常使用。反过来JS传中文给C收到的request在C侧可能也是UTF-8转成UTF-16再给MFC逻辑用。我在一个项目中遇到过页面表单提交的中文用户名传到C后变成一堆问号排查半天才发现是某一步用了W2A默认转换使用系统的ANSI代码页中文Windows下是GBK改成CW2A(str, CP_UTF8)后立马正常。最后再提醒三件事第一CEF的初始化对象CefApp、CefClient实例必须是全局或成员变量保底绝不能是栈上的临时对象。CefRefPtr内部引用计数如果引用计数归零对象被销毁CEF运行时再回调就会变成野指针程序直接崩溃。第二发布程序时CEF相关运行库体积不小但别为了省空间只拷贝dll不拷贝pak等资源文件我见过太多线上白屏案例都源于文件缺失。第三如果你的业务页面有大量本地文件访问、跨域请求、自定义交互尽量把自定义方案封装好而不是把所有逻辑都堆在对话框的OnInitDialog里——这程序后面一定会继续长大基础结构越干净后患越少。本文还有配套的精品资源点击获取
返回列表