双缓冲核心机制:内存DC与设备DC

双缓冲这名字听起来挺唬人,说白了就是「先在后台画好,再一次性贴到屏幕上」。我早年做嵌入式界面移植时,被闪烁问题折磨得够呛,后来才明白——闪烁的根源,就是你在屏幕上边画边擦,用户全看见了。

双缓冲的核心思路很简单:你准备一块内存画布,把所有绘图操作都在上面完成,最后把整块画布的内容一次性复制到屏幕上。这样用户看到的永远是完整的画面,没有中间过程。

内存DC与设备DC

Windows GDI 里有两个关键概念:设备DC(Device Context)和内存DC(Memory DC)。

  • 设备DC:直接对应物理显示设备。你往设备DC上画东西,立刻就显示在屏幕上。
  • 内存DC:一块内存中的虚拟画布。它不直接对应任何硬件,所有绘图操作都在内存里完成。

我习惯把设备DC比作「黑板」,内存DC比作「草稿纸」。你想想看,谁会直接在黑板上打草稿?肯定先在草稿纸上算好了,再誊写到黑板上。双缓冲就是这个道理。

核心关系:内存DC必须与设备DC兼容才能正常工作。创建内存DC时,需要传入一个设备DC句柄作为参考,这样内存DC的像素格式、颜色深度才能与屏幕匹配。

创建内存DC的典型流程:

// 1. 获取窗口设备DC
HDC hdc = GetDC(hWnd);

// 2. 创建兼容内存DC
HDC memDC = CreateCompatibleDC(hdc);

// 3. 创建兼容位图并选入内存DC
HBITMAP hBitmap = CreateCompatibleBitmap(hdc, width, height);
HBITMAP hOldBitmap = (HBITMAP)SelectObject(memDC, hBitmap);

// 4. 在memDC上绘图...

// 5. 将内存DC内容复制到设备DC
BitBlt(hdc, 0, 0, width, height, memDC, 0, 0, SRCCOPY);

// 6. 清理
SelectObject(memDC, hOldBitmap);
DeleteObject(hBitmap);
DeleteDC(memDC);
ReleaseDC(hWnd, hdc);

嗯,这里要注意:SelectObject 返回的旧对象一定要保存,最后恢复回去。我见过有人忘了这步,导致内存泄漏,程序跑几个小时就崩了。

BitBlt 与 StretchBlt 函数

这两个函数是双缓冲的「搬运工」。它们负责把内存DC里的像素数据,整块搬到设备DC上。

函数 作用 缩放能力 性能
BitBlt 按像素一一对应复制 无缩放 快(直接内存拷贝)
StretchBlt 复制时进行缩放 支持拉伸/压缩 慢(需要插值计算)

BitBlt 的签名:

BOOL BitBlt(
  HDC   hdcDest,    // 目标DC
  int   xDest,      // 目标左上角x
  int   yDest,      // 目标左上角y
  int   wDest,      // 目标宽度
  int   hDest,      // 目标高度
  HDC   hdcSrc,     // 源DC
  int   xSrc,       // 源左上角x
  int   ySrc,       // 源左上角y
  DWORD dwRop       // 光栅操作码
);

最后一个参数 dwRop 决定了像素如何合并。最常用的是 SRCCOPY——直接覆盖。还有 SRCAND、SRCPAINT 等,做透明效果时会用到。

我的经验:在嵌入式设备上,StretchBlt 的性能开销非常明显。如果目标尺寸和源尺寸一致,永远用 BitBlt。我曾经在一个 ARM 平台上用 StretchBlt 做 1:1 复制,帧率掉了 30%,换成 BitBlt 后立刻恢复。

StretchBlt 的签名几乎一样,多了个 dwStretchMode 参数:

BOOL StretchBlt(
  HDC   hdcDest, int xDest, int yDest, int wDest, int hDest,
  HDC   hdcSrc,  int xSrc,  int ySrc,  int wSrc,  int hSrc,
  DWORD dwRop
);

缩放模式通过 SetStretchBltMode 设置:

  • BLACKONWHITE:取并集,适合黑白图
  • WHITEONBLACK:取交集,适合黑白图
  • COLORONCOLOR:直接采样,速度最快,质量一般
  • HALFTONE:半色调插值,质量最好,速度最慢

我个人建议:界面移植场景下,用 COLORONCOLOR 就够了。除非你的 UI 有精细的渐变或图片,否则 HALFTONE 带来的质量提升不值得那点性能损耗。

离屏绘制与交换

离屏绘制(Off-screen Rendering)是双缓冲的完整流程。我把它拆成三步:

  1. 准备离屏缓冲区:创建内存DC和兼容位图
  2. 在离屏缓冲区上绘制:所有绘图操作都对着内存DC做
  3. 交换到屏幕:用 BitBlt 把内存DC内容复制到设备DC

为什么会这样设计?你想想看,如果直接在设备DC上绘图,每次画一个控件都要擦除、重绘,屏幕就会闪烁。离屏绘制把所有操作集中到内存里,用户只看到最终结果。

我曾经踩过的坑:在 WM_PAINT 消息里每次都创建和销毁内存DC。这会导致频繁的内存分配和释放,性能极差。正确的做法是:在窗口初始化时创建内存DC,在窗口大小变化时重建位图,在窗口销毁时释放资源。

下面这张图展示了双缓冲的完整数据流:

双缓冲核心流程 双缓冲离屏绘制与交换流程 应用程序绘图指令 内存DC(离屏缓冲区) CreateCompatibleDC + CreateCompatibleBitmap 离屏绘制(所有GDI操作) BitBlt / StretchBlt 交换 设备DC(屏幕显示) 关键点 • 内存DC不直接显示 • 所有绘图在内存完成 • 一次性复制到屏幕 性能提示 • 避免频繁创建DC • 位图尺寸匹配窗口 • 优先使用BitBlt

从图中可以看到,整个流程是单向的:应用程序 → 内存DC → 离屏绘制 → BitBlt → 设备DC。没有走回头路,也没有中间状态暴露给用户。

一个实用技巧:如果你的窗口需要频繁更新(比如视频播放或动画),可以考虑双缓冲池——维护两个内存DC,一个用于绘制当前帧,一个用于准备下一帧。这样能进一步减少等待时间。我在做嵌入式仪表盘时用过这个方案,帧率从 15fps 提升到了 30fps。

最后提一下光栅操作码。除了 SRCCOPY,还有几个常用的:

  • SRCAND:源与目标做按位与,常用于掩码操作
  • SRCPAINT:源与目标做按位或,用于合并
  • SRCINVERT:源与目标做异或,用于反转
  • NOTSRCCOPY:复制源的反色

嗯,这些在普通界面移植中很少用到。但如果你要做自定义形状的窗口或异形控件,SRCAND 和 SRCPAINT 配合掩码位图,可以实现完美的透明效果。我当年移植一个圆形仪表盘控件,就是用这个方案搞定的。