ARTICLE DETAIL

资讯详情

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

从零手搓Win32画图工具:避开MFC陷阱,掌握GDI绘图与双缓冲

从零手搓Win32画图工具:避开MFC陷阱,掌握GDI绘图与双缓冲 简介这是一份面向Windows编程初学者与Win32 API入门者的画图工具源码工程基于GDI实现颜色选择、自由曲线与直线绘制、椭圆矩形等基本图形、橡皮擦及BMP/PNG图像保存并演示子窗口的创建、显示与消息循环处理适合作为理解窗口管理、图形设备接口与文件操作的实践案例。压缩包为rar格式共87个文件、约36.57MB包含9个cpp源文件与11个h头文件构成的核心模块如drawwnd、drawfun、toolbox、colorselectdlg、graphicfile等另有22个bmp位图、5个cur光标、2个ico图标等界面素材以及sln、vcxproj工程文件与Debug目录下的obj、pdb、exe等编译产物可直接用Visual Studio打开运行。目前已有431人学习下载。读者可从中获得一套结构完整的Win32绘图程序参考实现涵盖绘图函数调用、颜色对话框、文件读写与消息分发等关键环节便于对照调试与二次开发为后续构建更复杂的Windows应用打下基础。1. 从零手搓一个 win32 简单功能的画图工具为什么我不建议你直接抄 MFC很多人第一次接触 Windows 桌面开发都是从「画图工具」这个需求开始的。它看起来简单——鼠标按下、拖动、松开屏幕上留下一条线但真正动手写才发现消息循环、GDI 对象、双缓冲、坐标映射每一个都能让你卡上半天。我见过太多人一上来就打开 Visual Studio 新建 MFC 工程拖几个控件结果遇到重绘闪烁、线条断裂、内存泄漏最后项目烂尾。其实用纯 win32 API 加 GDI 手写一个简单功能的画图工具代码量并不大核心逻辑不到 500 行而且你能完全掌控每一条消息的走向。这篇文章面向的是有 C/C 基础、想搞懂 Windows 图形编程底层机制的开发者我会把窗口创建、鼠标消息处理、GDI 绘图、双缓冲防闪烁、撤销栈这几个关键环节拆开讲每一步都给出可编译的代码和参数说明。读完你不仅能得到一个能画线、画矩形、画圆的工具还能理解为什么某些参数设错就会翻车。2. 窗口与消息循环win32 画图工具的骨架怎么搭2.1 注册窗口类时最容易忽略的 cs 参数win32 程序的入口是 WinMain但真正决定窗口行为的是 WNDCLASSEX 结构体。很多人直接复制网上的模板把 cs 设成 CS_HREDRAW | CS_VREDRAW 就完事结果窗口拉伸时整个画布被清空之前画的线全没了。这是因为这两个标志会强制窗口在水平或垂直尺寸变化时重绘整个客户区。对于画图工具我一般会去掉它们改用 WM_SIZE 消息里手动处理画布尺寸变化。另一个关键参数是 hbrBackground如果你设成 (HBRUSH)(COLOR_WINDOW1)系统会用白色刷子擦除背景但每次重绘都会闪一下。更好的做法是设成 NULL然后在 WM_ERASEBKGND 里直接返回 1告诉系统「背景我自己处理」配合后面的双缓冲就能彻底消除闪烁。WNDCLASSEX wc {0}; wc.cbSize sizeof(WNDCLASSEX); wc.style CS_HREDRAW | CS_VREDRAW; // 先保留后面在 WM_SIZE 里优化 wc.lpfnWndProc WndProc; wc.hInstance hInst; wc.hCursor LoadCursor(NULL, IDC_CROSS); // 画图工具用十字光标 wc.hbrBackground (HBRUSH)GetStockObject(WHITE_BRUSH); wc.lpszClassName LSimplePaint; RegisterClassEx(wc);上面这段代码里IDC_CROSS 是系统预定义的十字光标适合绘图场景。hbrBackground 用白色库存刷子省去自己创建和销毁的麻烦。注意 cbSize 必须赋值否则 RegisterClassEx 会失败这是新手最常见的翻车点之一。2.2 消息循环里为什么要用 PeekMessage 而不是 GetMessage标准 win32 程序用 GetMessage 阻塞等待消息但对于画图工具鼠标移动消息WM_MOUSEMOVE在拖动时会产生大量消息如果处理不及时线条就会卡顿。我一般会在消息循环里用 PeekMessage 加一个空闲处理分支当没有消息时做一些后台重绘或状态更新。不过对于简单画图工具GetMessage 也够用真正影响流畅度的是 WM_MOUSEMOVE 里的绘图逻辑。这里有个血泪经验不要在 WM_MOUSEMOVE 里直接往窗口 DC 上画线因为每次重绘都会丢失而且闪烁严重。正确的做法是把所有图形数据存到一个结构体数组里每次 WM_PAINT 时统一重绘。MSG msg; while (GetMessage(msg, NULL, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); } return (int)msg.wParam;这段消息循环是标准写法TranslateMessage 负责把按键消息转换成字符消息DispatchMessage 把消息分发给窗口过程。对于画图工具你不需要处理键盘输入所以这两行可以保留但不用额外加逻辑。2.3 窗口过程里必须处理的三个消息WndProc 是 win32 程序的核心对于画图工具必须处理 WM_LBUTTONDOWN、WM_MOUSEMOVE、WM_LBUTTONUP 这三个消息来实现绘图交互。WM_LBUTTONDOWN 记录起点WM_MOUSEMOVE 在拖动时更新终点并触发重绘WM_LBUTTONUP 完成一次绘制并把图形存入数组。这里有个参数细节lParam 的低 16 位是 x 坐标高 16 位是 y 坐标需要用 GET_X_LPARAM 和 GET_Y_LPARAM 宏来提取直接强转 short 在负坐标时会出错。另外WM_MOUSEMOVE 只在鼠标移动时触发如果你按住左键快速拖动消息可能会丢失所以最好在 WM_LBUTTONDOWN 里调用 SetCapture在 WM_LBUTTONUP 里调用 ReleaseCapture确保鼠标移出窗口也能收到消息。case WM_LBUTTONDOWN: SetCapture(hwnd); startPt.x GET_X_LPARAM(lParam); startPt.y GET_Y_LPARAM(lParam); isDrawing TRUE; break; case WM_MOUSEMOVE: if (isDrawing) { endPt.x GET_X_LPARAM(lParam); endPt.y GET_Y_LPARAM(lParam); InvalidateRect(hwnd, NULL, FALSE); // 触发重绘不擦背景 } break; case WM_LBUTTONUP: ReleaseCapture(); isDrawing FALSE; // 把当前图形存入数组 shapes[shapeCount].type currentTool; shapes[shapeCount].start startPt; shapes[shapeCount].end endPt; shapeCount; InvalidateRect(hwnd, NULL, FALSE); break;InvalidateRect 的第三个参数 FALSE 表示不擦除背景这样配合双缓冲就不会闪烁。如果设成 TRUE每次重绘都会先用背景刷子擦一遍闪得你怀疑人生。3. GDI 绘图实战从画线到画圆参数怎么设才不翻车3.1 用 MoveToEx 和 LineTo 画线的三个坑GDI 画线用 MoveToEx 设置起点LineTo 画到终点。看起来简单但有几个坑第一LineTo 画的是不包括终点的线段也就是说从 (0,0) 到 (100,100) 画线终点 (100,100) 不会被画上这是 GDI 的历史遗留设计。第二MoveToEx 和 LineTo 使用的是当前 DC 的画笔如果你没创建自定义画笔默认是黑色 1 像素实线。第三每次 LineTo 之后当前点会移动到终点所以连续画线时只需要第一次 MoveToEx。对于画图工具我一般会创建一个 HPEN 对象设置颜色和线宽用完记得 DeleteObject否则 GDI 对象泄漏会导致程序崩溃。HPEN hPen CreatePen(PS_SOLID, 2, RGB(255, 0, 0)); // 红色 2 像素实线 HPEN hOldPen (HPEN)SelectObject(hdc, hPen); MoveToEx(hdc, startPt.x, startPt.y, NULL); LineTo(hdc, endPt.x, endPt.y); SelectObject(hdc, hOldPen); // 恢复旧画笔 DeleteObject(hPen); // 删除自定义画笔CreatePen 的第一个参数是画笔样式PS_SOLID 表示实线PS_DASH 是虚线但只在 1 像素宽时有效。第二个参数是线宽单位是像素。第三个参数是颜色用 RGB 宏。注意 SelectObject 之后一定要恢复旧对象否则 DC 会被破坏。3.2 画矩形和椭圆Rectangle 与 Ellipse 的边界问题Rectangle 和 Ellipse 的用法类似都是传入左上角和右下角坐标。但有个细节Rectangle 画的矩形边框是包含在坐标范围内的而 Ellipse 画的是内切椭圆。如果你用鼠标拖拽画矩形起点和终点就是左上角和右下角但要注意坐标顺序如果终点在起点左边或上边需要交换坐标否则画不出来。我一般会写一个归一化函数确保 left right 且 top bottom。void NormalizeRect(LONG* x1, LONG* y1, LONG* x2, LONG* y2) { if (*x1 *x2) { LONG t *x1; *x1 *x2; *x2 t; } if (*y1 *y2) { LONG t *y1; *y1 *y2; *y2 t; } } // 使用 LONG left startPt.x, top startPt.y, right endPt.x, bottom endPt.y; NormalizeRect(left, top, right, bottom); Rectangle(hdc, left, top, right, bottom);这个归一化函数虽然简单但能避免 90% 的「画不出图形」问题。另外Rectangle 和 Ellipse 都会用当前画刷填充内部如果你只想画边框需要把画刷设成 NULL_BRUSH 或者 HOLLOW_BRUSH。3.3 双缓冲防闪烁CreateCompatibleDC 和 BitBlt 的正确用法闪烁是 GDI 绘图的头号敌人。每次 InvalidateRect 触发 WM_PAINT如果你直接往窗口 DC 上画系统会先用背景刷子擦除再画图形两个步骤之间会有短暂的白屏看起来就是闪烁。解决方案是双缓冲先在内存 DC 上画好整幅图再用 BitBlt 一次性拷贝到窗口 DC。内存 DC 用 CreateCompatibleDC 创建位图用 CreateCompatibleBitmap 创建大小和客户区一致。注意每次窗口大小变化时要重新创建位图否则拷贝区域不对。case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); RECT rc; GetClientRect(hwnd, rc); int width rc.right - rc.left; int height rc.bottom - rc.top; HDC memDC CreateCompatibleDC(hdc); HBITMAP memBmp CreateCompatibleBitmap(hdc, width, height); HBITMAP oldBmp (HBITMAP)SelectObject(memDC, memBmp); // 填充白色背景 HBRUSH hWhite (HBRUSH)GetStockObject(WHITE_BRUSH); FillRect(memDC, rc, hWhite); // 重绘所有图形 for (int i 0; i shapeCount; i) { DrawShape(memDC, shapes[i]); } // 绘制当前正在拖拽的图形 if (isDrawing) { Shape temp {currentTool, startPt, endPt}; DrawShape(memDC, temp); } BitBlt(hdc, 0, 0, width, height, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hwnd, ps); break; }这段代码里CreateCompatibleDC 创建的内存 DC 默认只有 1x1 像素的位图必须用 CreateCompatibleBitmap 选入一个和窗口一样大的位图才能画。BitBlt 的 SRCCOPY 表示直接拷贝。最后一定要恢复旧位图并删除所有 GDI 对象否则内存泄漏。这个双缓冲方案能把闪烁降到几乎不可见是 win32 画图工具的标配。4. 避坑与排查win32 画图工具最常见的 5 个翻车现场4.1 现象窗口拉伸后画布内容消失只剩一片白原因WNDCLASSEX 的 style 设了 CS_HREDRAW | CS_VREDRAW窗口尺寸变化时系统强制重绘整个客户区但你的图形数据没有重新绘制或者重绘时坐标没适配新尺寸。解决去掉这两个标志在 WM_SIZE 里更新画布尺寸并调用 InvalidateRect。如果图形需要缩放还得按比例调整所有坐标。4.2 现象画线时线条断断续续快速拖动会丢点原因WM_MOUSEMOVE 消息在鼠标快速移动时会被合并或丢弃系统只保证消息队列不溢出不保证每个像素都产生消息。解决不要依赖每个 WM_MOUSEMOVE 都画一个点而是记录起点和当前终点每次重绘时画整条线。如果要做自由画笔需要在两个消息点之间插值补点用 LineTo 连接。4.3 现象程序运行一段时间后崩溃GDI 对象数暴涨原因每次 WM_PAINT 都 CreatePen、CreateBrush、CreateCompatibleDC但没有 DeleteObject。Windows 每个进程的 GDI 对象上限默认是 10000超过就崩溃。解决所有 Create 出来的对象必须配对 DeleteSelectObject 后要恢复旧对象。可以用任务管理器的「GDI 对象」列监控正常应该稳定在几十以内。4.4 现象鼠标移出窗口后松开左键画图状态卡住原因没有调用 SetCapture鼠标移出客户区后 WM_LBUTTONUP 消息发给了其他窗口你的程序收不到isDrawing 一直为 TRUE。解决在 WM_LBUTTONDOWN 里调用 SetCapture(hwnd)在 WM_LBUTTONUP 里调用 ReleaseCapture()。注意 SetCapture 后所有鼠标消息都会发到你的窗口包括移出区域后的移动。4.5 现象画出来的图形颜色不对或者线宽无效原因CreatePen 的线宽参数只在 PS_SOLID、PS_GEOMETRIC 等样式下有效如果用了 PS_DASH 且线宽大于 1系统会自动改成实线。另外RGB 宏的参数顺序是红绿蓝不是蓝绿红。解决检查画笔样式和线宽组合虚线只支持 1 像素。颜色用 RGB(r, g, b)每个分量 0-255。5. 撤销栈与工具切换让简单画图工具真正好用5.1 用数组模拟撤销栈比链表更省心画图工具没有撤销功能就像手机没有返回键。实现撤销最简单的方式是用一个固定大小的数组存图形数据每次画完一个图形就 push 进去撤销时把栈顶元素弹出并重绘。数组大小设成 1000 足够用每个元素存图形类型、起点、终点、颜色、线宽。用链表虽然动态扩展但遍历和删除更麻烦对于简单工具数组加一个 top 指针就够了。注意撤销后重做功能需要另一个栈但简单工具可以不做。#define MAX_SHAPES 1000 Shape shapes[MAX_SHAPES]; int shapeCount 0; void Undo() { if (shapeCount 0) { shapeCount--; InvalidateRect(hwnd, NULL, FALSE); } }这个 Undo 函数只做了一件事减少计数并触发重绘。因为所有图形都在数组里重绘时只画前 shapeCount 个自然就实现了撤销。参数 MAX_SHAPES 根据内存和需求调整每个 Shape 结构体大约 32 字节1000 个才 32KB完全不用担心。5.2 工具切换用枚举和按钮消息驱动工具切换可以用工具栏按钮也可以直接用键盘快捷键。我一般会在窗口顶部画几个矩形当按钮在 WM_LBUTTONDOWN 里判断点击位置是否在按钮区域内如果是就切换 currentTool否则才开始绘图。工具枚举定义成 TOOL_LINE、TOOL_RECT、TOOL_ELLIPSE、TOOL_FREEHAND。自由画笔需要特殊处理它不是存起点终点而是存一个点数组每个 WM_MOUSEMOVE 都追加一个点。typedef enum { TOOL_LINE, TOOL_RECT, TOOL_ELLIPSE, TOOL_FREEHAND } ToolType; ToolType currentTool TOOL_LINE; // 在 WM_LBUTTONDOWN 里判断工具栏区域 if (y TOOLBAR_HEIGHT) { if (x 60) currentTool TOOL_LINE; else if (x 120) currentTool TOOL_RECT; else if (x 180) currentTool TOOL_ELLIPSE; else currentTool TOOL_FREEHAND; InvalidateRect(hwnd, NULL, FALSE); return 0; }工具栏高度设成 40 像素每个按钮宽 60 像素。这个逻辑简单直接不需要创建真正的按钮控件省去很多消息映射的麻烦。注意判断工具栏区域要在处理绘图逻辑之前否则点击工具栏也会画图。5.3 验证方法用 Spy 看消息流用 GDI 对象数查泄漏调试 win32 程序Spy 是神器。打开 Spy找到你的窗口可以实时看到所有消息的流向和参数。比如你发现 WM_MOUSEMOVE 的 lParam 坐标不对用 Spy 一看就知道是坐标提取错了。另一个工具是任务管理器添加「GDI 对象」列正常画图工具应该稳定在 20-50 之间如果持续增长说明有对象没删除。我习惯每加一个 Create 就马上写对应的 Delete形成肌肉记忆。最后如果你发现双缓冲后画面还是闪检查是不是在 WM_ERASEBKGND 里返回了 0应该返回 1 告诉系统不用擦背景。这些技巧都是踩坑踩出来的希望帮到你。本文还有配套的精品资源点击获取
返回列表