简介这套示例工程面向需要在 C# WinForm 中嵌入 Chromium 浏览器的开发者基于 VS2010 与 .NET Framework 4.0围绕 CefSharp.WinForms 49.0.1 提供可运行的集成范例适合初、中级 .NET 桌面开发人员验证内置浏览器与网页交互。压缩包共 319 个文件约 189.28MB含 7 个 cs 源码、2 个 csproj 及 sln 解决方案另有 52 个 dll、174 个 pak 等 CefSharp 运行依赖以及 xml、config、bin、exe 等配套内容兼顾源码查阅与调试。已有 3433 人学习下载。工程覆盖窗体加载浏览器、导航状态监听、执行 JavaScript 获取结果、CefSettings 自定义和退出时关闭 CefSharp 等环节可用于排查初始化顺序、依赖缺失与资源释放问题也便于扩展请求拦截、脚本注入等场景。1. 老 WinForm 项目要嵌现代网页CefSharp 这条路的真实成本维护一套跑在 .NET 4.0 上的 WinForm 管理端客户突然要求内嵌一个已经用现代前端框架重写过的报表页这种场面我遇到过不止一次。系统自带的 WebBrowser 控件走的是老 IE 内核页面一打开就是满屏错位加一堆脚本报错前端那边改不动只能从宿主程序这边想办法。CefSharp.WinForms 就是把 Chromium 内核塞进 WinForm 的最短路径一个 ChromiumWebBrowser 控件贴到窗体上剩下的渲染、JS 引擎、调试工具都由内核自己扛。这份示例代码的价值在「最小可运行骨架」——一个窗体、一次完整的 Cef 初始化与关闭、几个 C# 调 JS 和 JS 调 C# 的按钮。它对准两类人手上是被钉死在 VS2010 .NET 4.0 的老项目、没有升级余地只能找兼容版本的人以及第一次接触 CefSharp、想先看一套能跑的完整链路再动手改的人。2. 技术栈与目录结构从版本天花板到一份能编译的依赖清单2.1 .NET 4.0 把版本选择锁死在一条线上CefSharp 的版本和 .NET Framework 版本是强绑定的。按我踩过的坑49.x 那条线是最后能落在 .NET 4.0 上的版本再往上的版本把最低要求提到了 .NET 4.5.2装到 .NET 4.0 项目里会在编译期就报一堆找不到类型和成员的错。所以第一步不是急着写代码是先确认手上的工程到底能吃哪个版本。判断方法很简单看项目属性里的目标框架再对着 CefSharp 包里的 lib 目录看有没有 net40 这个文件夹。只有 net40 或者 net45 之前的目录名才说明这个包在这个框架下是能用的。如果 NuGet 上最新版本只给你 net45 以上的目录那就别硬上往下翻历史版本。VS2010 这边还有个额外的坎。VS2010 自带的 NuGet 客户端版本很老CefSharp 包里带的 install.ps1 脚本大概率执行失败表现是包显示安装成功但项目引用里什么都没多出来输出目录也没有 libcef.dll。我一般的做法是绕开自动安装把 nupkg 当 zip 下载下来解压手动把需要的 DLL 引用进项目。!-- 手动引用时 csproj 里的写法HintPath 指向解压出来的目录 -- ItemGroup Reference IncludeCefSharp HintPath..\packages\CefSharp.Common.49.0.1\lib\net40\CefSharp.dll/HintPath PrivateTrue/Private /Reference Reference IncludeCefSharp.Core HintPath..\packages\CefSharp.Common.49.0.1\lib\net40\CefSharp.Core.dll/HintPath PrivateTrue/Private /Reference Reference IncludeCefSharp.WinForms HintPath..\packages\CefSharp.WinForms.49.0.1\lib\net40\CefSharp.WinForms.dll/HintPath PrivateTrue/Private /Reference /ItemGroupPrivate必须为 True意思是把 DLL 复制到输出目录否则本机调试能过、拷到别人机器上就找不到程序集。三个 DLL 缺一不可CefSharp.dll是公共接口和设置类CefSharp.Core.dll是对 libcef 的托管封装CefSharp.WinForms.dll才是那个能拖到窗体上的控件。目录名里的版本号按你实际解压出来的改别照抄。2.2 一份示例工程该有的目录长什么样示例工程的源码部分其实很薄重头在运行时文件。我把它拆成「源码目录」和「输出目录」两块看后者才是决定能不能跑起来的地方。CefSharpDemo/ ├── CefSharpDemo.sln ├── CefSharpDemo/ │ ├── Program.cs # 入口Cef.Initialize / Cef.Shutdown 都在这 │ ├── MainForm.cs # 主窗体持有 ChromiumWebBrowser 实例 │ ├── MainForm.Designer.cs │ ├── BoundObject.cs # 暴露给 JS 调用的 C# 对象 │ ├── App.config # supportedRuntime 声明 v4.0 │ ├── packages.config # 手动引用时可以留空 │ └── Properties/ └── packages/ ├── CefSharp.Common.49.x/ └── CefSharp.WinForms.49.x/编译之后 bin 目录里会多出一大坨东西这部分是 libcef 本体少一个都可能白屏文件 / 目录作用缺失后的表现libcef.dllChromium 内核本体几十 MB启动即报找不到 DLLCefSharp.BrowserSubprocess.exe渲染 / GPU 子进程宿主主窗体出来但页面全白icudt.dll国际化数据部分页面编码乱码cef.pak / cef_100_percent.pak资源包界面控件样式丢失devtools_resources.pak开发者工具资源F12 调试面板打不开locales/语言包目录指定 Locale 时报错swiftshader/、libEGL.dll、libGLESv2.dll软件渲染回退无独显机器上黑屏d3dcompiler_47.dll着色器编译视频、Canvas 相关页面异常手动引用的时候这些文件不会自动复制得在项目里加一个生成后事件或者干脆把它们当成「内容」文件加进项目并设置成「如果较新则复制」。2.3 平台目标必须写死 x86 或 x64这是新手最容易翻车的一处。VS2010 新建的 WinForm 项目默认平台目标是 AnyCPU而 libcef.dll 是分架构的原生库不存在一份能同时被 32 位和 64 位进程加载的版本。AnyCPU 在 64 位系统上会以 64 位进程启动加载 32 位的 libcef.dll 直接崩报的是那句经典的「An attempt was made to load a program with an incorrect format」。改法是在项目属性里把平台目标固定下来或者直接改 csprojPropertyGroup Condition $(Configuration)|$(Platform) Debug|AnyCPU PlatformTargetx86/PlatformTarget !-- .NET 4.0 没有 Prefer32Bit 这个开关只能靠 PlatformTarget 硬指定 -- OutputPathbin\Debug\/OutputPath DebugSymbolstrue/DebugSymbols /PropertyGroup选 x86 还是 x64取决于你引用的 libcef 是哪个架构的两者要一致。老项目周边依赖一堆 32 位 COM 组件的话一律 x86 最省事。这里还有个和版本相关的细节如果你在别的机器上看到有人用Prefer32Bit解决过这个问题那是因为 .NET 4.5 才引入这个属性.NET 4.0 项目里写上去不生效还是老老实实写PlatformTarget。还有运行库。CefSharp 的托管层依赖 VC 运行时目标机器上没装的话启动时会抛「无法加载 DLL libcef.dll」或者「找不到指定的模块」。开发机上通常因为装了完整开发环境而不缺一到客户机器上就现原形。稳妥做法是把对应架构的运行库安装包一起打进部署包或者干脆用静态链接运行库的版本。3. 核心代码拆解浏览器实例从初始化到销毁的完整链路3.1 CefSettings 与 Cef.Initialize一次配明白那些黑匣子参数整个程序里有且只有一次 Cef 初始化位置必须在创建任何浏览器实例之前通常就放在Program.Main里。这套参数配错了后面调多久都是白费。using System; using System.IO; using System.Windows.Forms; using CefSharp; using CefSharp.WinForms; namespace CefSharpDemo { static class Program { [STAThread] // WinForm CefSharp 必须去掉后部分对话框和拖拽会异常 static void Main() { string baseDir AppDomain.CurrentDomain.BaseDirectory; CefSettings settings new CefSettings(); // 缓存目录不指定的话会落到系统临时目录多开时互相踩 settings.CachePath Path.Combine(baseDir, Cache); // 日志是排查白屏的第一手材料出问题先看这个文件 settings.LogFile Path.Combine(baseDir, cef.log); settings.LogSeverity LogSeverity.Info; settings.Locale zh-CN; // 单进程消息循环由 WinForm 的 Application.Run 驱动 settings.MultiThreadedMessageLoop true; // 老机器显卡驱动兼容性差先禁 GPU 能救回一大半白屏 settings.CefCommandLineArgs.Add(disable-gpu, 1); // 旧版异步绑定在 .NET 4.0 下不完整先开回旧绑定模式 CefSharpSettings.LegacyJavascriptBindingEnabled true; // 第二个参数为 true 时会检查运行库依赖缺 VC 运行时在这就抛 if (!Cef.Initialize(settings, true, null)) { MessageBox.Show(Cef 初始化失败请检查 libcef.dll 与运行库是否齐全。); return; } Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); // 必须在消息循环结束之后顺序反了会留下常驻进程 Cef.Shutdown(); } } }几个参数值得单独说。CachePath影响 Cookie、localStorage、HTTP 缓存落在哪示例里放在程序目录正式项目建议放到%LocalAppData%下否则程序装在 Program Files 里会因为没有写权限导致缓存失效表现是登录状态每次重启都丢。LogFile只有在出问题的时候才觉得香我现在的习惯是不管三七二十一先打开出事了直接翻日志比盲猜快得多。disable-gpu是个后悔药级别的开关某些老机器上开着 GPU 加速就是一片白关掉立刻好代价是页面滚动和动画会肉一点。Cef.Initialize的返回值要判断第二个参数performDependencyCheck传 true 会让它在缺依赖时自己抛异常而不是等到渲染阶段才崩。第三个参数是browserProcessHandler示例里用不到传 null 即可。3.2 ChromiumWebBrowser 装载Dock 顺序和 Load 时机控件本身的用法不复杂但装载顺序有个小坑先设 Dock 再加进 Controls或者先加进去再设 Dock在旧版本上会导致浏览器初始尺寸是 0×0页面加载完了也不渲染看起来就是一块灰。稳妥的写法是先构造、再设 Dock、再 Add最后才 Load。using System; using System.Windows.Forms; using CefSharp; using CefSharp.WinForms; namespace CefSharpDemo { public partial class MainForm : Form { private ChromiumWebBrowser browser; public MainForm() { InitializeComponent(); InitBrowser(); } private void InitBrowser() { // 先给一个空白页避免构造时就发起网络请求导致异常无处捕获 browser new ChromiumWebBrowser(about:blank); browser.Dock DockStyle.Fill; this.Controls.Add(browser); // 事件挂在 Load 之前否则第一次加载的状态就漏掉了 browser.LoadingStateChanged OnLoadingStateChanged; browser.ConsoleMessage OnConsoleMessage; // 本地页面优先示例工程自带的 html 放在输出目录的 web 子目录下 string localPage System.IO.Path.Combine( AppDomain.CurrentDomain.BaseDirectory, web, index.html); browser.Load(localPage); } private void OnLoadingStateChanged(object sender, LoadingStateChangedEventArgs e) { // 这个回调在 Cef 的线程上动 UI 必须切回主线程 if (!e.IsLoading) { this.BeginInvoke(new Action(() { this.Text 加载完成 - browser.Address; })); } } private void OnConsoleMessage(object sender, ConsoleMessageEventArgs e) { // 把页面 console.log 转到 Debug 输出调前端时省一半时间 System.Diagnostics.Debug.WriteLine( string.Format([JS {0}:{1}] {2}, e.Source, e.Line, e.Message)); } } }LoadingStateChanged里的BeginInvoke不是可选项。CefSharp 的回调线程不是 UI 线程直接在里面改控件属性会抛跨线程异常运气好只是异常运气不好就是整个进程静默退出。判断标准很简单只要回调签名里没有明确的 UI 上下文就当成后台线程处理。ConsoleMessage这个事件我觉得是被低估的把页面的 console 输出接到Debug.WriteLine前端同学报「我这边明明打了日志」的时候你能直接在自己 IDE 的输出窗口里看到省去来回扯皮。3.3 收尾Cef.Shutdown 和窗体关闭的先后顺序Cef 的子进程是一组独立的进程如果主进程退出时没有走完关闭流程任务管理器里就会残留几个进程下次启动的时候缓存目录被占用直接起不来。顺序是窗体关闭 → 浏览器控件 Dispose → 消息循环结束 →Cef.Shutdown()。private void MainForm_FormClosing(object sender, FormClosingEventArgs e) { if (browser ! null) { // 先摘事件避免 Dispose 过程中回调打到已经销毁的控件上 browser.LoadingStateChanged - OnLoadingStateChanged; browser.ConsoleMessage - OnConsoleMessage; browser.Dispose(); browser null; } } // Program.Main 里 Application.Run(new MainForm()); Cef.Shutdown(); // 放在 Run 之后不能在 FormClosing 里调这里有个反直觉的点Cef.Shutdown()不能写在FormClosing里。窗体关闭的时候消息循环还在跑这时候把 Cef 关掉剩下的消息处理会打到已经销毁的内核对象上表现就是退出时弹一个异常框或者干脆无响应。放在Application.Run之后等循环彻底退出再关才是干净的顺序。4. 双向通信C# 调 JS、JS 调 C# 的两种落地写法4.1 C# 调 JSEvaluateScriptAsync 与不要用 .ResultC# 主动执行页面脚本靠EvaluateScriptAsync返回的是TaskJavascriptResponse。这里最大的坑是在 UI 线程上写.Result或者.Wait()等结果——脚本在渲染进程里执行执行完要回主线程投递结果你在主线程上堵着等就是标准的死锁现场界面直接卡死。private void btnReadTitle_Click(object sender, EventArgs e) { // 传一段表达式返回值的类型会被序列化成 object var task browser.EvaluateScriptAsync((function(){ return document.title; })()); // .NET 4.0 没有 Task.Run用 ContinueWith 续接 task.ContinueWith(t { if (t.IsFaulted || t.Result null || !t.Result.Success) { string msg t.IsFaulted ? t.Exception.GetBaseException().Message : 脚本执行失败; this.BeginInvoke(new Action(() MessageBox.Show(msg))); return; } // Result 是 object字符串、数字、bool、数组都可能按需转换 string title Convert.ToString(t.Result.Result); this.BeginInvoke(new Action(() this.Text title)); }); }JavascriptResponse.Success一定要判断脚本抛异常的时候它不会让 Task 进入 Faulted而是把 Success 置 false 并把错误信息放在Message里。只看IsFaulted会漏掉一大类失败。另外EvaluateScriptAsync的执行结果是经过 JSON 序列化的DOM 节点、函数这类对象传不回来只能传基础类型和普通对象。需要传参的时候别拼字符串用带参数的写法示例里简单场景可以直接拼但我习惯用 JSON 序列化后注入避免引号和特殊字符把脚本搞坏。4.2 JS 调 C#RegisterJsObject 与线程边界JS 调 C# 靠注册一个绑定对象页面上通过window.绑定名.方法名()调用。.NET 4.0 这条线上要走旧绑定模式也就是前面LegacyJavascriptBindingEnabled true那个开关。using System; using System.Windows.Forms; namespace CefSharpDemo { // 暴露给页面的对象方法必须是 public 实例方法 public class BoundObject { private readonly MainForm owner; public BoundObject(MainForm form) { this.owner form; } public string GetUserName() { return Environment.UserName; } // 页面调这个方法来改窗体标题必须切回 UI 线程 public void SetTitle(string text) { if (owner ! null !owner.IsDisposed) { owner.BeginInvoke(new Action(() owner.Text text)); } } } } // 在 InitBrowser 里Load 之前注册 browser.RegisterJsObject(bound, new BoundObject(this));页面那边这样调// 页面加载完成后绑定对象才可用稳妥做法是等 bound 出现再操作 window.addEventListener(load, function () { var user window.bound.GetUserName(); document.getElementById(user).innerText user; document.getElementById(btn).addEventListener(click, function () { window.bound.SetTitle(来自页面的标题); }); });绑定方法的执行线程不是 UI 线程这一点和事件回调一样凡是碰到控件的地方都要BeginInvoke。另外RegisterJsObject的注册时机必须在页面脚本执行之前如果在页面加载完成之后才注册页面里第一次访问window.bound就是 undefined。刷新页面会重新注册不用担心。4.3 请求拦截RequestHandler 与资源本地化示例代码里如果带了自定义的RequestHandler那多半是为了两件事拦截特定 URL 走本地资源或者拦截导航防止用户点链接跳出应用。public class LocalSchemeHandler : IRequestHandler { public bool OnBeforeBrowse(IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request, bool isRedirect) { // 返回 true 表示拦下这次导航不交给内核处理 if (request.Url.StartsWith(file:///blocked)) { return true; } return false; } public bool GetAuthCredentials(IWebBrowser browserControl, IBrowser browser, IFrame frame, bool isProxy, string host, int port, string realm, string scheme, IAuthCallback callback) { return false; // 需要处理 401 弹窗时在这里回填账号 } }设置方式是在创建浏览器之后赋值browser.RequestHandler new LocalSchemeHandler();。这个接口在版本之间方法签名变化比较大升级版本时这里是编译错误的高发区改动之前先看对应版本的接口定义。5. 避坑排查从白屏到进程残留的五个高频现场5.1 白屏类问题日志文件是第一现场现象窗体出来了浏览器区域一片白或者一片灰程序不报任何错。原因通常有三种。一是架构不匹配AnyCPU 项目加载了错误架构的 libcef.dll内核根本没起来二是CefSharp.BrowserSubprocess.exe没复制到输出目录子进程拉不起来页面自然渲染不了三是 GPU 加速在某些老显卡驱动上直接挂掉。解决顺序是固定的先看 bin 目录下cef.log的最后几十行里面会明确写「Failed to load」或者子进程启动失败再看项目平台目标是不是 x86/x64 而不是 AnyCPU最后在CefSettings.CefCommandLineArgs里加上disable-gpu试一次。三步走完还不行的基本就是依赖文件缺失对着 2.2 那张表一个个核。5.2 启动即崩VC 运行时和 32/64 位错配现象双击 exe 直接弹「无法加载 DLL libcef.dll找不到指定的模块」或者「试图加载格式不正确的程序」。原因分两种。提示「找不到指定的模块」其实不是 libcef.dll 本身不在而是 libcef.dll 依赖的 VC 运行库不在Windows 的加载器报错永远指向最外层那个 DLL把真正的缺失藏起来了。提示「格式不正确」则是位数不匹配。解决先把运行库装齐开发机和目标机都装位数跟你的平台目标一致位数问题就统一 csproj 里的PlatformTarget同时确认引用的 libcef.dll 是同一个架构。判断当前进程位数可以在启动时打一行IntPtr.Size4 是 32 位、8 是 64 位比猜快。5.3 关闭窗口后进程残留现象主窗体关了任务管理器里还挂着主 exe 和几个同名子进程下次启动提示缓存目录被占用。原因是Cef.Shutdown()没被调用或者被调用的位置不对。放在FormClosing里是典型错误那时候消息循环还在跑。解决Cef.Shutdown()挪到Application.Run之后窗体关闭时只负责browser.Dispose()。如果程序里有多个窗体、多条退出路径比如托盘退出用一个静态标志保证Cef.Shutdown()只执行一次重复调用也会出问题。5.4 VS2010 下调试看不到浏览器进程现象断点能命中 C# 代码但页面里的 JS 断点、网络请求完全看不到F12 也打不开开发者工具。原因有两个层面。一是 VS2010 的调试器不认识 Chromium 的多进程模型托管调试附加不到渲染子进程上二是CefSharp.BrowserSubprocess.exe路径不对BrowserSubprocessPath没配或者配错了。解决JS 那段别指望 VS2010直接用页面里的 F12 打开内置开发者工具或者在初始化时加settings.RemoteDebuggingPort 8088用浏览器访问本机这个端口做远程调试。托管侧想调试子进程得手动「附加到进程」选那个CefSharp.BrowserSubprocess.exe并且调试类型选托管。这套组合拳我用过很多次虽然笨但有效。5.5 NuGet 安装成功但引用为空现象在 VS2010 里通过包管理器装了 CefSharppackages 目录里文件齐全但项目引用、输出目录什么都没有。原因是 VS2010 的 NuGet 客户端执行不了包里的 PowerShell 安装脚本脚本本来负责改 csproj、加引用、复制原生文件。解决放弃自动安装手动解压 nupkg按 2.1 的写法把三个 DLL 引用进去再把原生文件作为内容复制到输出目录。顺手在项目里加一条生成后事件把 packages 目录下的原生文件同步过去比每次手动拷靠谱。:: 生成后事件里用把原生文件同步到输出目录 xcopy /Y /D $(SolutionDir)packages\CefSharp.Common.49.0.1\lib\net40\*.dll $(TargetDir) xcopy /Y /D /E /I $(SolutionDir)runtimes\locales $(TargetDir)locales/D表示只复制比目标新的文件避免每次编译都全量拷几十 MB/E /I处理目录。路径按实际解压位置改。6. 二次开发改造把示例骨架改成能干活的外壳示例代码跑通只是起点真正落到项目里一般要做三件事多个页面并存、下载和弹窗的接管、和本地业务数据打通。这三块的改造点不太一样我按自己的习惯列一下。改造场景关键改动主要 API多标签页浏览每个 Tab 一个 ChromiumWebBrowser注意销毁TabControlbrowser.Dispose()下载接管默认下载没有进度提示要自己接IDownloadHandler.OnBeforeDownload弹窗接管页面window.open默认没反应ILifeSpanHandler.OnBeforePopup本地数据打通用绑定对象暴露业务方法RegisterJsObject登录态持久化固定 CachePath别用临时目录CefSettings.CachePath多标签这一块最容易出问题的是销毁。关掉一个 Tab 的时候必须Dispose对应的浏览器实例否则进程数和内存只增不减开十几个标签之后整机就卡了。我的做法是在 Tab 的Tag上挂浏览器引用关闭事件里取出来 Dispose并且顺手把事件订阅摘掉。下载接管需要实现IDownloadHandlerOnBeforeDownload里决定保存路径OnDownloadUpdated里更新进度。注意这两个回调都不在 UI 线程更新进度条要BeginInvoke。默认不接管的话点击下载链接没有任何反应用户只会以为程序坏了这是最容易被投诉的一个点。弹窗接管同理不实现ILifeSpanHandler的话页面里的window.open直接被吞掉。常见做法是在OnBeforePopup里返回 true 拦下来然后拿 URL 新开一个 Tab用户体验上更符合桌面程序的习惯。绑定对象那边我一般会把它拆成一个纯数据接口加一个 UI 适配层。纯数据接口只返回字符串、数字、JSON 字符串这类能安全过序列化的东西所有碰控件的逻辑都放在适配层里走BeginInvoke。这样页面那边调用简单C# 这边也不会因为线程问题出玄学 bug。参数和返回值一律用 JSON 字符串传别指望复杂对象能自动映射序列化边界上的坑比省下来的几行代码值钱。验证改造是否生效我的固定流程是先在页面里console.log打一行确认ConsoleMessage事件收到了再在绑定方法里打日志确认调用链通了最后才是看 UI 有没有变化。三步都过说明通信没问题剩下的就是业务逻辑的事。跨线程的问题一律用这个顺序定位比在代码里撒Invoke快得多。还有一个细节值得单独提这套代码必须全部用 C# 5 之前的语法。$字符串插值、?.空传播、nameof在 VS2010 里统统编译不过Task.Run也是 .NET 4.5 才有的异步统一用Task.Factory.StartNew或者ContinueWith。我第一次把新项目里的代码片段往老工程里贴的时候就是这么翻车的编译器报了一屏错最后老老实实全改成string.Format。从那以后我每次往老框架项目里移植代码都强制先把语法版本在脑子里过一遍再动手贴。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?