多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

WinForms自定义绘制防闪烁:深入BufferedGraphics双缓冲机制

WinForms自定义绘制防闪烁:深入BufferedGraphics双缓冲机制 现实中做 WinForms 自定义绘制我最怕听到一句话“你画的东西闪得厉害。”标签控件闪烁、上位机实时曲线闪得让人头晕这些闪烁几乎都和 GDI 的双缓冲功能有关。很多人第一时间会想到DoubleBuffered true在小场景下确实管用但一旦你的绘图逻辑重了、数据刷新频率又高这个属性就不够用了而 C# 自带的 GDI 还提供了一整套更底层的双缓冲 APIBufferedGraphics 和 BufferedGraphicsContext。这篇文章就把这套内置双缓冲机制从头到尾讲清楚包含代码、踩坑细节和选型建议适合用 WinForms 开发上位机、图表控件、自绘界面以及一切和屏幕重绘打交道的开发者。1. 先把双缓冲这件事想明白1.1 闪烁的根源闪烁的根源不在于 GDI 画线速度有多慢而在于屏幕上同一区域会在极短时间内被“擦除”和“重画”两个动作交替控制。控件收到 WM_ERASEBKGND 消息时系统会默认用背景色把客户区刷一遍收到 WM_PAINT 之后才会执行你的 Paint 事件里的绘制代码。如果背景擦除完整执行了而绘制又要一点一点展开中间就有足够时间让眼睛看见底色这就是闪烁。在 WinForms 里这个现象被封装成了 OnPaintBackground 和 OnPaint 两个方法。默认情况下OnPaintBackground 会调用背景刷子把控件清一遍OnPaint 再做真正的绘制。如果你的 OnPaint 里绘制代码执行超过几十毫秒用户看到的就是“闪白—出现内容—闪白—出现内容”的交替。这个在实时数据监控、仪表盘刷新、温度曲线滚动这些场景里尤其明显因为每秒钟要重绘几十次每次重绘都先擦后画人眼对高频亮度变化极其敏感闪烁感会被成倍放大。1.2 Windows 绘图消息与双缓冲的位置理解双缓冲之前要先知道 WinForms 的重绘链路。控件失效后系统产生无效区域等消息循环把 WM_PAINT 交给窗口过程时WinForms 内部会依次触发 OnPaintBackground 和 OnPaint。传统单缓冲方案下这两步操作都是直接作用于屏幕上的设备上下文所以屏幕上可以观察到中间状态。双缓冲位置很巧妙它把 OnPaintBackground 和 OnPaint 里的绘制动作从“直接画屏幕”改为“画到一块内存储备区”等所有绘图方法执行完再把完整画面一次性复制到屏幕上。对系统来说显示设备上下文只被触达一次屏幕上的内容始终是一整帧不会出现半截画面。这就是双缓冲能消除大部分 GDI 闪烁的根本原因。Control.DoubleBuffered 内部其实就是这么做的但它的缓冲对象由 WinForms 自动管理使用者拿不到中间 Graphics也无法控制缓冲区是在什么时候创建、以多大尺寸创建。想要精细控制就需要手动站出来使用 BufferedGraphicsContext 去分配 BufferedGraphics。这也是我写这篇文章的初衷不是为了绕开框架而是为了在框架默认方案不够时能把双缓冲的主动权拿回来。1.3 BufferedGraphics 解决问题的机制BufferedGraphics 的核心机制相当于把绘制拆成了“后台填色”和“一次性交付”两步。它先申请一块离屏内存所有 DrawLine、DrawString、DrawImage 操作都在这块内存里执行等整帧完毕再通过 BitBlt 把结果交给目标 DC。内存里绘制无论多花时间用户始终只看到最终合成结果从视觉上消除了闪烁。用生活化的例子解释好比写作业先在草稿纸上把整版写好再誊到正式卷子上你不会把写了一半的草稿交上去。这看起来很简单但实际实现还要处理设备兼容、尺寸变化、资源释放等问题。后文会用可运行代码把整个流程走一遍顺带把高频踩坑点都指出来。2. 核心类解析与准备工作2.1 认识两个主角BufferedGraphicsContext 主要负责缓冲区的管理。进程内默认的上下文通过BufferedGraphicsManager.Current取得这个静态属性返回一个全局共享的 BufferedGraphicsContext 实例。WinForms 内部自带的双缓冲逻辑也是拿这个上下文去分配缓冲图形所以你在自己的代码里复用同一个 context并不会造成额外负担。BufferedGraphics 是每次调用 Allocate 后返回的缓冲对象它有一个 Graphics 属性你可以像用普通 Graphics 一样在这块内存画布上操作。完成绘图之后调用 Render 方法将内存画布输出到目标。释放它是必不可少的步骤因为它内部持有不可托管的 HBITMAP、HDC 等资源不释放会慢慢吃光句柄。这里有个高频坑很多人会以为 BufferedGraphicsContext 自带静态 Current 属性其实这个类没有真正的入口是 BufferedGraphicsManager.Current。写代码时记错很容易编译期不会报错因为两个类型名相近但一旦写成 BufferedGraphicsContext.Current编译器会提醒你找不到该属性。我早期在这个名字上卡了半小时后来直接把这两行写进工具类里再也没纠结过。2.2 初始化最小缓冲区最简单的用法是这样private BufferedGraphicsContext _context; private BufferedGraphics _buffer; private Size _bufferSize; private void EnsureBuffer() { if (_buffer ! null _bufferSize ClientSize) return; _buffer?.Dispose(); _buffer null; _bufferSize Size.Empty; if (ClientSize.Width 0 || ClientSize.Height 0) return; using (Graphics target CreateGraphics()) { _buffer _context.Allocate( target, new Rectangle(Point.Empty, ClientSize)); _bufferSize ClientSize; } }这里有三个点需要注意。第一Allocate 的第一个参数不是用来绘制的东西它只告诉缓冲区目标设备的颜色深度等信息所以传入用 CreateGraphics 新建的临时 Graphics 即可分配完立刻释放。第二Rectangle 参数才是缓冲画布大小必须和窗口客户区大小一致否则 Render 之后要么拉伸、要么被裁剪。第三EnsureBuffer 不能每帧都先去 Dispose 再分配要用 _bufferSize 判断尺寸否则分配释放的消耗比绘制本身还大。2.3 完整生命周期管理与常见误解对象生命周期分成三阶段。初始化阶段获得 BufferedGraphicsManager.Current在控件尺寸确定后创建 BufferedGraphics。持续使用阶段在需要绘制的地方取 buffer.Graphics 绘制最终 Render 到真实 Graphics。清理阶段在 Resize 时先释放旧的 buffer 再重新分配在控件 Dispose 时释放 buffer并停掉相关的定时器和事件订阅。如果跳过第二步直接 Dispose会抛 InvalidOperationException 或导致画面空白。最稳妥的做法是在 OnResize 里也先释放原有 buffer再按新尺寸分配。很多人只在 OnPaint 里判断 buffer 为空却不处理窗口尺寸变化等到窗口拉大以后扑面而来的就是大片不可用区域或者绘制范围被裁剪到旧缓冲区边界内。还要澄清一个误解BufferedGraphics 并不会自动跟随控件的 Handle 变化。如果控件被重新创建了句柄之前的 buffer 可能仍指向旧 HDC轻则绘制失效重则引发 ObjectDisposedException。所以在需要动态重建句柄的控件里建议监听 HandleCreated 事件并重新 EnsureBuffer把缓冲状态和窗口句柄的生命周期绑定在一起。3. 实操完整实现一个支持缓冲的绘制控件3.1 先做一个可运行的示例下面给出一个完整示例功能是一个不断运动的“气泡”自绘控件。它自己维护一个列表定时器每 16ms 触发一次动画并 Invalidate 控件。核心部分全部通过 BufferedGraphics 画到内存里再一次性输出。public class BubblePanel : Control { private BufferedGraphicsContext _context; private BufferedGraphics _buffer; private Size _bufferSize; private readonly ListBubble _bubbles new(); private readonly Random _rand new(); private readonly System.Windows.Forms.Timer _timer; public BubblePanel() { // 我们手动管理双缓冲这里再让系统缓冲一层就没有意义 SetStyle(ControlStyles.OptimizedDoubleBuffer, false); SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true); _context BufferedGraphicsManager.Current; for (int i 0; i 50; i) { _bubbles.Add(new Bubble { X _rand.Next(0, 300), Y _rand.Next(0, 300), Radius _rand.Next(4, 16), SpeedX (float)(_rand.NextDouble() * 4 - 2), SpeedY (float)(_rand.NextDouble() * 4 - 2) }); } _timer new System.Windows.Forms.Timer { Interval 16 }; _timer.Tick (_, _) { UpdateBubbles(); Invalidate(); }; _timer.Start(); } private void UpdateBubbles() { foreach (Bubble b in _bubbles) { b.X b.SpeedX; b.Y b.SpeedY; if (b.X 0 || b.X ClientSize.Width) b.SpeedX -b.SpeedX; if (b.Y 0 || b.Y ClientSize.Height) b.SpeedY -b.SpeedY; } } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); EnsureBuffer(); if (_buffer null) return; Graphics g _buffer.Graphics; g.Clear(Color.White); foreach (Bubble b in _bubbles) { g.FillEllipse(Brushes.SteelBlue, b.X - b.Radius, b.Y - b.Radius, b.Radius * 2, b.Radius * 2); } _buffer.Render(e.Graphics); } protected override void OnResize(EventArgs e) { base.OnResize(e); EnsureBuffer(); Invalidate(); } protected override void Dispose(bool disposing) { if (disposing) { _timer?.Dispose(); _buffer?.Dispose(); _buffer null; } base.Dispose(disposing); } private void EnsureBuffer() { if (_buffer ! null _bufferSize ClientSize) return; _buffer?.Dispose(); _buffer null; _bufferSize Size.Empty; if (ClientSize.Width 0 || ClientSize.Height 0) return; using (Graphics target CreateGraphics()) { _buffer _context.Allocate( target, new Rectangle(Point.Empty, ClientSize)); _bufferSize ClientSize; } } private class Bubble { public float X, Y, Radius, SpeedX, SpeedY; } }在窗体上放一个 BubblePanel运行就能看到一个基本不闪烁的气泡动画。这里我用 _bufferSize 缓存了缓冲区尺寸OnPaint 时调用 EnsureBuffer 几乎是零开销只有尺寸变化或 buffer 为空时才真正执行 Allocate。OnResize 里再调用一次是为了在尺寸变化后立刻拿到新的缓冲区避免下一帧到来时临时分配导致卡顿。3.2 在真实业务场景中如何调整气泡示例只是演示机制。实际项目里的绘图往往更复杂比如上位机要绘制温度曲线、告警列表报表控件要画表格。这个时候有几个常用的改进点。第一把复杂且不变化的背景提前渲染。例如背景网格线完全可以在初始化时单独创建一个 Bitmap 画好然后每帧用 DrawImageUnscaled 贴进 _buffer.Graphics。不要小看这个操作画 100 条网格线需要调用 100 次 DrawLine高频下足以贡献明显 CPU 开销换成一次贴图则性能可以轻松提升数倍。缓冲区里的内容并不要求每帧全部重新绘制能省略的绘制操作坚决省略。第二合理控制无效区域。如果只有鼠标附近的十字线变化可以调用 Invalidate(Rectangle) 表示局部区域但前提是缓冲区要能支持部分重绘。对 BufferedGraphics 来说我建议保持整帧重绘因为 Render 本质上就是整块 BitBlt局部更新的收益有限加上 GDI 的 Clip 还要增加额外计算。如果极端追求效率更好的方案是拆分图层先在一层缓存背景再在上层画动态内容而不是用裁剪区域去抠局部刷新。第三处理高 DPI 环境。不要把 ClientSize 想当然当作像素坐标现代系统下如果设置 PerMonitorV2 缩放缓冲区尺寸、绘制坐标都要结合 DeviceDpi。通用做法是绘制前调用 g.ScaleTransform 或直接使用逻辑坐标并在 Resize 时把缓冲区尺寸取为 ClientSize 的实际像素尺寸。这部分不处理好就会看到文字发糊、线条断裂双缓冲救不了渲染错位的界面。3.3 缓冲区大小与 MaxBufferSize 的权衡如果你研究过 BufferedGraphicsContext会发现它有 MaxBufferSize 属性。WinForms 内部为了避免每次分配都重新向系统申请内存会在 MaxBufferSize 范围内缓存一块足够大的缓冲区。如果每次 Allocate 的尺寸小于 MaxBufferSizecontext 可以直接复用内存如果超过就可能丢弃已有缓存、重新分配。这个机制对使用体验影响很大所以它建议我们在布局相对稳定的控件里复用固定尺寸的缓冲区而不是频繁变动。我遇到过比较极端的场景一个全屏窗口里面放大量自绘控件每个控件在 Resize 事件中都调用 context.Allocate。结果内存占用很高因为每个控件都按全屏区域分配了一块后备缓冲。后来我改成只对需要动画特效的控件使用 BufferedGraphics其余静态控件用普通双缓冲或者干脆不缓冲内存下降明显动画帧率反而更高。做任何性能优化都要考虑实际分配量BufferedGraphics 不是越用越快的魔法它只是把分配和绘制开销重新组合。4. 常见问题与排查技巧4.1 用了 BufferedGraphics 还是闪烁怎么查这种情况我会先怀疑背景刷新路径没有关干净。检查控件是否启用了 AllPaintingInWmPaint。如果没启用Windows 仍然可能在绘制前发送 WM_ERASEBKGND把客户区背景擦成白色随后你再把整帧缓冲 Render 上来虽然频率很低但依然可能看到明显闪白。解决方案是显式设置SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true);另一个常见原因是父容器的背景闪烁。子控件自己处理得再好父容器刷新时如果没有双缓冲滚动或切换时同样会闪。对 Form 或 Panel 可以在构造函数里设置 DoubleBuffered true或者在代码里用 SetStyle 给父容器打开双缓冲。如果父容器内部有大量子控件设置 OptimizedDoubleBuffer 通常能解决大部分整体闪烁问题。还有一种情况是代码里调用了 this.Update() 或 Refresh()。Invalidate 只是把无效区域加入消息队列等系统安排时间绘制而 Update 会立即强制重绘等于打乱了缓冲区的节奏极端情况下会让 Render 执行两次。我自己的原则是动画循环里只调用 Invalidate除非特殊情况需要立刻反馈否则不要使用 Update 强制刷新。4.2 异常与资源释放问题排查常见异常是 ObjectDisposedException大多因为 buffer 已被 Dispose 又在 OnPaint 中使用。比如 OnResize 里销毁了旧的 buffer随后立刻触发了重绘但 OnPaint 的 EnsureBuffer 又没有重新分配成功此时就会踩到空引用或 ObjectDisposedException。解决办法是把 buffer 的创建统一收敛到 EnsureBuffer 方法里并且在 OnPaint 开头先调用一次其他任何地方不要私自 Dispose 或直接给 _buffer 赋值。另一个常见问题是程序退出时抛出内存访问异常。这是因为定时器还在运行窗口句柄已经销毁Tick 事件里又触发了 Invalidate。正确做法是让控件实现 IDisposable 模式在 Dispose 中先停止定时器再释放 buffer。释放顺序也很重要先 _timer.Dispose()再 _buffer.Dispose()最后 base.Dispose这样可以避免窗体销毁过程中异步重绘的竞争问题。再来一个跨平台提醒。.NET Core 3.0 之后的 System.Drawing.Common 在非 Windows 平台默认不可用除非项目显式启用 EnableUnixSupport但 GDI 底层依赖 libgdiplus不同发行版表现差异很大。如果你的跨平台应用需要稳定绘制建议认真评估是否切到 SkiaSharp如果只是做 Windows 部署的上位机System.Drawing.Common 完全够用BufferedGraphics 也就能放心用。4.3 性能优化经验绘制大量图形时尽量合并操作。例如用 GraphicsPath 把多个三角形、圆形路径一次性绘制比多次 FillPolygon 循环快很多这个对单帧绘制时间影响非常明显。绘制文字时用 TextRenderer.DrawText 比 Graphics.DrawString 在某些高频场景下更快但前者走 GDI 而不是 GDI两者混用会影响字体渲染一致性界面大量绘制中文时要先定一种方案。还有一点容易被忽略提前固定 Graphics.SmoothingMode 和 TextRenderingHint。每次切换模式都会让 GDI 重建内部状态几十个图形画下来额外开销很大。最佳做法是每帧开头设置一次整帧内不再修改。对静态内容则干脆用默认模式减少抗锯齿计算。性能评估也不能只看“感觉”。我在每次 OnPaint 里用一个 Stopwatch 统计绘制和 Render 时间把单帧耗时写到标题栏调到 60fps 时单帧耗时低于 16ms 就是健康的。这个数据能帮你区分瓶颈在绘制算法还是缓冲分配还是屏幕刷新频率本身。5. 与 Control.DoubleBuffered 等方案的对比选型5.1 几个双缓冲实现路线的区别方案可控性性能适合场景Control.DoubleBuffered 属性低中简单列表、静态界面SetStyle(OptimizedDoubleBuffer)低中大多数 WinForms 绘制控件BufferedGraphicsContext BufferedGraphics高中高自绘控件、动画、需要手动合成自建 Bitmap 后备缓冲高中低需要多层合成、图像处理注意表格里说的性能中高不意味着 BufferedGraphics 一定更快。它本身还是要通过 GDI 做软件绘制并不会因为用了双缓冲就让千万级 Path 变快。BufferedGraphics 真正优化的是显示一致性而不是减少 CPU 总工作量。很多新人对这点有误解觉得上了双缓冲程序就流畅了结果发现 CPU 还是跑满其实瓶颈在绘制算法本身。5.2 我的选型经验如果只是给一个 Panel 设置双缓冲直接 DoubleBuffered true 是最省事的。如果开发的是继承 Control 的仪表盘控件希望在动画期间把底图和动态层分开绘制那用 BufferedGraphics 会更顺手。如果界面大量使用 Canvas 绘制且要频繁重绘我会建议先做基准测试不要盲目上双缓冲。用 BufferedGraphics 前先确保单帧绘制函数耗时已经优化到可接受范围否则即使缓冲机制再完善也只是把“卡”从闪烁变成掉帧。从 WinForms 生态来看BufferedGraphics 是 C# 自带而且相对稳定的选项。只要按照“重用 context按需 Allocate使用完 DisposeResize 时重建”的节奏它完全能扛住普通自定义控件的高频刷新场景。我个人的习惯是把它封装到一个 ManualBufferedControl 基类中后续再需要自定义绘制控件时只要重写 DrawScene(Graphics g) 方法即可不用重复写分配、销毁、尺寸同步的代码。最后再分享一个小技巧如果项目里既有 WinForms 又偏向高性能绘制可以在自绘控件中先拿 BufferedGraphics 做 GDI 合成然后接入 SkiaSharp 的 SKControl 做硬件加速。两个方案还可以并存根据渲染内容拆分。这也是我做复杂上位机界面时比较推荐的组合思路稳定的用 GDI 缓冲高频的用硬件加速让每一层都干自己最擅长的事。
返回列表