1. 项目概述与核心价值
上次我们聊了用Windows API和DC(设备上下文)在C++里画个窗口和几条线,算是打了个地基。今天这篇,咱们得往深了挖,把这块地基变成能跑马的地。很多朋友跟着教程走,画是画出来了,但总觉得代码是“死”的——窗口大小不能变,一缩放内容就没了;想画个复杂点的图形,代码就乱成一团;更别提响应鼠标键盘,做个简单的交互了。这些问题,本质上都是因为对DC的工作机制,特别是它和窗口消息(尤其是WM_PAINT)的配合没吃透。
这篇文章的目标,就是带你彻底打通这个任督二脉。我们不只讲“怎么画”,更要讲清楚“为什么这时候画”以及“怎么画得高效又灵活”。你会学到如何让图形内容随着窗口大小自适应,如何组织你的绘图代码使其清晰可维护,以及如何引入简单的交互逻辑。最终,你将拥有一个结构清晰、可扩展的绘图程序框架,而不是一堆散乱的代码。无论你是想为小工具做个自定义界面,还是为游戏开发打基础,这些概念都是绕不开的核心。
2. 深入理解绘图核心:WM_PAINT与DC的生命周期
2.1 WM_PAINT消息的触发时机与处理哲学
为什么我们一改变窗口大小,之前画的东西就没了?这是因为窗口的客户区内容默认不被系统保存。为了效率和灵活性,系统采用“按需绘制”的策略。当窗口客户区的一部分或全部变为“无效”(需要重画)时,系统就会在消息队列中放置一个WM_PAINT消息。
哪些操作会导致客户区无效?
- 窗口首次显示。
- 窗口从被遮挡变为完全可见。
- 用户调整了窗口大小(默认情况下)。
- 程序主动要求重绘:通过
InvalidateRect或InvalidateRgn函数。
这里有一个关键点:WM_PAINT的优先级很低。系统会等消息队列中没有其他更高优先级的消息(如键盘、鼠标输入)时,才来处理它。这保证了用户交互的流畅性。
处理WM_PAINT的标准范式,在WndProc函数里是这样的:
case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); // 你的所有绘图代码都应该写在这里,使用这个hdc EndPaint(hWnd, &ps); } return 0;BeginPaint函数做了几件重要的事:它获取了与窗口关联的DC,并填充了PAINTSTRUCT结构,其中包含了需要重绘的区域信息(rcPaint)。更重要的是,它验证了窗口区域,告诉系统“无效区域我已经开始处理了”。EndPaint则标志着绘制结束。如果你在WM_PAINT中不调用BeginPaint和EndPaint,窗口将永远处于“无效”状态,导致WM_PAINT被疯狂地、持续地触发,消耗大量CPU资源。
注意:永远不要在
WM_PAINT消息处理之外使用GetDC获取的DC来进行需要持久显示的绘图。GetDC获得的DC用于临时绘制(比如响应鼠标移动实时画线),其内容可能在下次WM_PAINT时被覆盖。持久性绘图必须放在WM_PAINT处理中。
2.2 设备上下文(DC)的两种获取方式与用途辨析
DC是绘图的关键句柄,但获取方式不同,用途天差地别。
BeginPaint获取的DC:- 用途:专用于响应
WM_PAINT消息,进行窗口客户区的主体重绘。 - 生命周期:与
BeginPaint/EndPaint调用对绑定,仅在WM_PAINT消息处理期间有效。 - 特点:自动处理无效区域,提供裁剪区域,是持久性绘图的唯一正确入口。
- 用途:专用于响应
GetDC/GetWindowDC获取的DC:- 用途:用于在任意时刻(通常在
WM_MOUSEMOVE,WM_KEYDOWN等消息中)进行即时性、临时性的绘制。 - 生命周期:从
GetDC调用开始,到对应的ReleaseDC调用结束。必须成对出现,否则会造成资源泄漏。 - 特点:绘制的内容是“临时”的。一旦窗口区域失效(如被其他窗口遮挡后露出),这些绘制内容就会消失,除非你在
WM_PAINT中重新绘制它们。
- 用途:用于在任意时刻(通常在
一个常见的误解:认为在WM_PAINT里用GetDC也行。虽然画上去可能暂时能看到,但这破坏了窗口管理的约定,可能导致绘制闪烁或效率低下。正确的做法是始终坚持在WM_PAINT中用BeginPaint。
2.3 实现窗口缩放内容自适应
理解了WM_PAINT,让内容自适应窗口大小就很简单了。核心思路是:在WM_SIZE消息中,将整个客户区标记为无效,从而触发WM_PAINT进行重绘。
case WM_SIZE: { // 当窗口大小改变时,强制整个客户区无效,要求重画 InvalidateRect(hWnd, NULL, TRUE); // TRUE 表示擦除背景 // 注意:这里不直接绘图,只是触发WM_PAINT } return 0;在WM_PAINT的处理函数中,你的绘图代码应该基于当前窗口的尺寸来动态计算位置。你可以通过GetClientRect函数来获取变化后的客户区大小。
case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); RECT rcClient; GetClientRect(hWnd, &rcClient); // 获取当前客户区矩形 int clientWidth = rcClient.right - rcClient.left; int clientHeight = rcClient.bottom - rcClient.top; // 示例:画一个随着窗口大小变化的十字中心线 HPEN hPen = CreatePen(PS_SOLID, 2, RGB(0, 120, 215)); HPEN hOldPen = (HPEN)SelectObject(hdc, hPen); MoveToEx(hdc, clientWidth / 2, 0, NULL); LineTo(hdc, clientWidth / 2, clientHeight); MoveToEx(hdc, 0, clientHeight / 2, NULL); LineTo(hdc, clientWidth, clientHeight / 2); SelectObject(hdc, hOldPen); DeleteObject(hPen); EndPaint(hWnd, &ps); } return 0;现在,无论你怎么拖拽窗口边框,中间的十字线都会始终保持在窗口正中心。
3. 重构绘图代码:迈向模块化与可维护性
当绘图逻辑变得复杂时,把几百行代码都堆在WndProc的WM_PAINT分支里是灾难性的。我们需要将绘图逻辑分离出来。
3.1 创建独立的绘图渲染函数
我们将创建一个专门的函数,比如叫RenderScene,它接受一个HDC和窗口尺寸作为参数,负责所有具体的绘制命令。
void RenderScene(HDC hdc, int clientWidth, int clientHeight) { // 1. 绘制背景(例如,一个渐变填充) TRIVERTEX vertex[2] = {0}; vertex[0].x = 0; vertex[0].y = 0; vertex[0].Red = 0xFF00; // 顶部颜色 (浅蓝) vertex[0].Green = 0xF000; vertex[0].Blue = 0xFF00; vertex[0].Alpha = 0x0000; vertex[1].x = clientWidth; vertex[1].y = clientHeight; vertex[1].Red = 0x0000; // 底部颜色 (深蓝) vertex[1].Green = 0x0000; vertex[1].Blue = 0x8000; vertex[1].Alpha = 0x0000; GRADIENT_RECT gRect = {0, 1}; GradientFill(hdc, vertex, 2, &gRect, 1, GRADIENT_FILL_RECT_V); // 2. 绘制一个居中的矩形边框 HPEN hBorderPen = CreatePen(PS_SOLID, 3, RGB(255, 255, 0)); // 黄色边框 HBRUSH hNullBrush = (HBRUSH)GetStockObject(NULL_BRUSH); // 空画刷,使矩形中空 HPEN hOldPen = (HPEN)SelectObject(hdc, hBorderPen); HBRUSH hOldBrush = (HBRUSH)SelectObject(hdc, hNullBrush); int rectMargin = 50; Rectangle(hdc, rectMargin, rectMargin, clientWidth - rectMargin, clientHeight - rectMargin); SelectObject(hdc, hOldBrush); SelectObject(hdc, hOldPen); DeleteObject(hBorderPen); // 注意:GetStockObject返回的对象不要用DeleteObject删除 // 3. 在矩形内绘制文本 SetBkMode(hdc, TRANSPARENT); // 设置文本背景透明 SetTextColor(hdc, RGB(255, 255, 255)); // 白色文字 std::string displayText = "窗口尺寸: " + std::to_string(clientWidth) + " x " + std::to_string(clientHeight); RECT textRect = {rectMargin, rectMargin, clientWidth - rectMargin, clientHeight - rectMargin}; DrawTextA(hdc, displayText.c_str(), -1, &textRect, DT_CENTER | DT_VCENTER | DT_SINGLELINE); }这样,WM_PAINT处理就变得极其简洁:
case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); RECT rc; GetClientRect(hWnd, &rc); RenderScene(hdc, rc.right, rc.bottom); // 调用渲染函数 EndPaint(hWnd, &ps); } return 0;3.2 管理绘图状态与资源
更进一步的优化是引入一个绘图状态结构体。这个结构体保存所有需要在两次WM_PAINT调用之间记住的信息。
struct DrawingState { POINT lineStart; // 线条起点 POINT lineEnd; // 线条终点 bool isDrawing; // 是否正在绘制 COLORREF lineColor; // 当前线条颜色 std::vector<POINT> polygonPoints; // 多边形点集 }; // 在窗口类创建时(如WM_CREATE),初始化这个状态 // 可以使用`SetWindowLongPtr`/`GetWindowLongPtr`与`GWLP_USERDATA`将结构体指针附加到窗口上。在WM_PAINT中,你获取这个状态,并将其传递给RenderScene函数。RenderScene根据状态中的数据来决定画什么。这就为交互式绘图(如用鼠标画线)打下了基础。
3.3 双缓冲技术:消除绘图闪烁
当你绘制复杂场景,或者进行频繁的重绘时,可能会遇到严重的闪烁问题。这是因为屏幕是直接对着hdc绘制的,用户能看到中间的绘制过程。
双缓冲的原理是:先在内存中的一个“缓冲区DC”里把整个画面画好,然后一次性将这个缓冲区的内容“贴”到屏幕DC上。由于这个复制操作非常快,用户就看不到中间的绘制过程了。
实现步骤:
- 创建兼容DC和位图:在
WM_PAINT开始时,创建一个与屏幕DC兼容的内存DC,再创建一个与客户区同尺寸的位图选入这个内存DC。 - 在内存DC上绘制:调用你的
RenderScene函数,但传入的是内存DC的句柄。 - 一次性拷贝到屏幕:使用
BitBlt函数将内存DC的内容快速复制到BeginPaint获得的屏幕DC上。 - 清理资源:删除位图,释放内存DC。
case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); RECT rcClient; GetClientRect(hWnd, &rcClient); // 1. 创建内存DC和位图 HDC hdcMem = CreateCompatibleDC(hdc); HBITMAP hbmMem = CreateCompatibleBitmap(hdc, rcClient.right, rcClient.bottom); HBITMAP hbmOld = (HBITMAP)SelectObject(hdcMem, hbmMem); // 2. 可选:用背景色清除内存DC(相当于擦除上一帧) HBRUSH hBrBackground = CreateSolidBrush(RGB(240, 240, 240)); // 浅灰背景 FillRect(hdcMem, &rcClient, hBrBackground); DeleteObject(hBrBackground); // 3. 在内存DC上执行所有绘制命令 RenderScene(hdcMem, rcClient.right, rcClient.bottom); // 4. 将内存DC的内容一次性拷贝到屏幕DC BitBlt(hdc, 0, 0, rcClient.right, rcClient.bottom, hdcMem, 0, 0, SRCCOPY); // 5. 清理资源(顺序很重要) SelectObject(hdcMem, hbmOld); // 恢复旧位图 DeleteObject(hbmMem); // 删除内存位图 DeleteDC(hdcMem); // 删除内存DC EndPaint(hWnd, &ps); } return 0;实操心得:双缓冲是解决Windows GDI绘图闪烁的标准且有效的方法。对于简单的、绘制不频繁的应用可能感觉不到差别,但对于任何稍有动态效果或复杂图形的程序,启用双缓冲能极大提升视觉体验。记住资源清理的顺序:先
SelectObject恢复旧对象,再DeleteObject删除新创建的对象,最后DeleteDC。
4. 引入用户交互:鼠标绘图示例
静态图形没意思,我们让图形动起来,响应用户输入。我们来实现一个用鼠标左键拖拽画线的功能。
4.1 捕获鼠标消息与更新绘图状态
首先,我们需要扩展之前的DrawingState,并正确处理鼠标消息。
// 在窗口过程函数中处理鼠标消息 case WM_LBUTTONDOWN: { // 获取鼠标按下的坐标(相对于客户区) int xPos = GET_X_LPARAM(lParam); int yPos = GET_Y_LPARAM(lParam); // 假设我们通过GetWindowLongPtr获取了绘图状态指针pState DrawingState* pState = (DrawingState*)GetWindowLongPtr(hWnd, GWLP_USERDATA); if (pState) { pState->lineStart.x = xPos; pState->lineStart.y = yPos; pState->lineEnd = pState->lineStart; // 初始终点和起点相同 pState->isDrawing = true; } // 捕获鼠标,确保即使鼠标移出窗口也能收到WM_MOUSEMOVE消息 SetCapture(hWnd); } return 0; case WM_MOUSEMOVE: { if (GetCapture() == hWnd) { // 确保是我们窗口捕获的鼠标移动 DrawingState* pState = (DrawingState*)GetWindowLongPtr(hWnd, GWLP_USERDATA); if (pState && pState->isDrawing) { int xPos = GET_X_LPARAM(lParam); int yPos = GET_Y_LPARAM(lParam); // 为了实时预览,我们需要擦除旧的预览线,画上新的。 // 这属于“临时绘制”,我们用GetDC。 HDC hdc = GetDC(hWnd); // 设置光栅操作模式为R2_NOTXORPEN。这是一种神奇的画法: // 用同样的笔画两次,会完全擦除第一次画的内容,不留痕迹。 int oldRop = SetROP2(hdc, R2_NOTXORPEN); HPEN hPreviewPen = CreatePen(PS_SOLID, 1, RGB(0, 0, 0)); HPEN hOldPen = (HPEN)SelectObject(hdc, hPreviewPen); // 擦除上一次的预览线(如果是第一次移动,则从起点到起点,无效果) MoveToEx(hdc, pState->lineStart.x, pState->lineStart.y, NULL); LineTo(hdc, pState->lineEnd.x, pState->lineEnd.y); // 画上新的预览线 pState->lineEnd.x = xPos; pState->lineEnd.y = yPos; MoveToEx(hdc, pState->lineStart.x, pState->lineStart.y, NULL); LineTo(hdc, pState->lineEnd.x, pState->lineEnd.y); SelectObject(hdc, hOldPen); DeleteObject(hPreviewPen); SetROP2(hdc, oldRop); // 恢复原来的ROP模式 ReleaseDC(hWnd, hdc); } } } return 0; case WM_LBUTTONUP: { DrawingState* pState = (DrawingState*)GetWindowLongPtr(hWnd, GWLP_USERDATA); if (pState && pState->isDrawing) { pState->isDrawing = false; // 鼠标松开,确定线条终点,并将其加入永久图形列表 int xPos = GET_X_LPARAM(lParam); int yPos = GET_Y_LPARAM(lParam); pState->lineEnd.x = xPos; pState->lineEnd.y = yPos; // 将这条最终确定的线段保存到一个容器中,例如: // pState->permanentLines.push_back({pState->lineStart, pState->lineEnd}); // 标记客户区无效,触发WM_PAINT,用持久化的方式重画这条线 InvalidateRect(hWnd, NULL, TRUE); } ReleaseCapture(); // 释放鼠标捕获 } return 0;4.2 在WM_PAINT中渲染持久化图形
现在,RenderScene函数不仅要画背景等固定内容,还要遍历DrawingState中保存的永久图形列表(比如permanentLines)进行绘制。
void RenderScene(HDC hdc, int width, int height, const DrawingState& state) { // ... 绘制背景等固定内容 ... // 绘制所有永久保存的线条 HPEN hFinalPen = CreatePen(PS_SOLID, 2, RGB(255, 0, 0)); // 红色实线 HPEN hOldPen = (HPEN)SelectObject(hdc, hFinalPen); for (const auto& line : state.permanentLines) { MoveToEx(hdc, line.start.x, line.start.y, NULL); LineTo(hdc, line.end.x, line.end.y); } SelectObject(hdc, hOldPen); DeleteObject(hFinalPen); // 注意:预览线是临时性的,不应该在这里绘制。 }这样,一个基本的交互式画线功能就完成了。鼠标拖拽时有实时预览(临时绘制),松开左键后线条被保存并永久显示(通过WM_PAINT重绘)。
5. 性能优化与高级技巧
5.1 有效利用无效区域
BeginPaint获得的PAINTSTRUCT中的rcPaint成员定义了本次需要重绘的最小矩形区域。在绘制非常复杂的场景时,你可以只重绘这个区域,而不是整个客户区,这能显著提升性能。
case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); // 只重绘无效区域 ps.rcPaint // 例如,如果你的绘图是位图,可以使用 BitBlt 只拷贝这个区域 BitBlt(hdc, ps.rcPaint.left, ps.rcPaint.top, ps.rcPaint.right - ps.rcPaint.left, ps.rcPaint.bottom - ps.rcPaint.top, hdcMem, // 你的内存DC ps.rcPaint.left, ps.rcPaint.top, SRCCOPY); EndPaint(hWnd, &ps); }对于复杂的矢量图形,判断哪些图形在无效区域内再进行绘制,逻辑会复杂很多,通常只在性能瓶颈非常明显时才考虑。
5.2 使用库存对象与资源管理
GDI对象(笔、刷子、字体、位图等)是系统资源,必须妥善管理。基本原则是:
- 创建(Create)* 后必须删除(DeleteObject)。
- 选入(SelectObject)DC后,要保存旧对象句柄,并在使用完毕后选回(SelectObject)旧对象。
- 库存对象(GetStockObject)不需要删除。
- 在
WM_PAINT中创建和删除GDI对象对于频繁重绘的场景效率低下。更好的做法是在WM_CREATE中创建所需对象,在WM_DESTROY中删除,在绘图时直接使用。
5.3 常见问题与调试技巧
绘图不显示或一闪而过:
- 检查点:确保绘图代码在
BeginPaint和EndPaint之间(对于持久性绘图)。检查WM_PAINT消息处理是否return 0了,而不是DefWindowProc。 - 检查点:确认没有在
WM_ERASEBKGND消息中返回TRUE(这会在WM_PAINT前擦除背景,可能覆盖你的绘制)。如果你自己处理全部绘制,可以在WM_ERASEBKGND中直接return 1来阻止系统擦除。
- 检查点:确保绘图代码在
程序运行后资源(GDI)持续增长,最终崩溃或绘图异常:
- 检查点:这是典型的GDI资源泄漏。使用工具(如任务管理器-详细信息-添加“GDI对象”列)监控程序的GDI对象数量。确保每一个
CreatePen,CreateSolidBrush,CreateCompatibleDC等都有对应的DeleteObject或DeleteDC,并且SelectObject后正确恢复了旧对象。
- 检查点:这是典型的GDI资源泄漏。使用工具(如任务管理器-详细信息-添加“GDI对象”列)监控程序的GDI对象数量。确保每一个
鼠标交互时绘图残留或闪烁严重:
- 检查点:临时绘制(如鼠标预览)是否使用了正确的ROP码(如
R2_NOTXORPEN)来擦除?或者是否在绘制新内容前先清除了旧内容? - 检查点:对于复杂的交互预览,考虑为预览也启用双缓冲。即在
WM_MOUSEMOVE中,先在内存DC画好整个场景(包括永久图形和预览图形),再一次性BitBlt到屏幕。
- 检查点:临时绘制(如鼠标预览)是否使用了正确的ROP码(如
绘制文本乱码或位置不对:
- 检查点:使用
DrawText或TextOut时,注意坐标是文本的左上角。使用DT_CENTER等标志时,要提供正确的矩形区域。 - 检查点:如果使用宽字符(Unicode),确保项目字符集设置为Unicode,并使用
DrawTextW、TextOutW和L字符串前缀。
- 检查点:使用
走到这里,你已经不再是那个只会画静态矩形的初学者了。你理解了Windows图形编程的核心循环:用户/系统触发无效区域 -> 产生WM_PAINT-> 在BeginPaint/EndPaint间进行持久性重绘。你也掌握了用双缓冲消除闪烁,用状态管理分离逻辑,用鼠标消息实现交互。这套模式,是几乎所有传统Win32桌面图形程序的骨架。你可以在此基础上,添加更复杂的图形数据结构、更多的绘图命令(贝塞尔曲线、多边形填充、位图操作),甚至引入简单的动画循环。记住,关键是把变化的数据保存在状态里,然后在WM_PAINT中根据这些数据把画面画出来。当你觉得纯GDI满足不了需求时,那就是开始探索GDI+、Direct2D甚至OpenGL的时候了,但它们的底层思想,依然与今天所学的息息相关。