资讯详情

Win32画图工具实战:消息循环与GDI绘图避坑指南

📅 2026/10/20 13:35:21 | 华诺云谱 👁 阅读
Win32画图工具实战:消息循环与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目录下的exe、pdb、obj等编译产物可直接打开运行调试。目前已有432人学习下载。通过该工程可掌握MoveToEx、LineTo、Ellipse、Rectangle等绘图函数用法理解颜色对话框取值、透明笔擦除、CreateFile与WriteFile保存图像以及CreateWindowEx、ShowWindow配合消息分发实现子窗口开关的完整思路为后续构建更复杂的Windows应用打下基础。1. 从零拆一个 Win32 画图工具为什么我建议你拿它练手 Windows 消息循环很多人学完 Win32 的CreateWindowEx和WndProc之后就卡在“能弹窗但不知道下一步写什么”的阶段。这个画图工具正好补上这一环它用纯 Win32 API 实现铅笔、直线、矩形、椭圆、橡皮擦和颜色选择没有 MFC、没有 Qt一个.cpp加一个资源文件就能编译出可执行程序。适合两类人一是刚学完 Windows 消息机制想找个完整项目练手的二是需要给内部工具加个轻量绘图面板、又不想引入庞大 GUI 框架的开发者。它麻雀虽小但把 GDI 绘图、鼠标捕获、双缓冲、菜单加速键这些一线开发天天打交道的点全串了一遍。2. 项目结构与技术选型为什么用纯 Win32 而不是 MFC2.1 目录结构一览拿到源码后先别急着编译花两分钟把目录扫一遍。这个项目的文件组织非常扁平没有多余的层级典型的 Win32 小工具风格Win32Paint/ ├── Win32Paint.sln # VS 解决方案文件 ├── Win32Paint/ │ ├── Win32Paint.cpp # 主程序WinMain WndProc 绘图逻辑 │ ├── Win32Paint.h # 全局常量、结构体、函数声明 │ ├── resource.h # 资源 ID 定义菜单项、图标、加速键 │ ├── Win32Paint.rc # 资源脚本菜单、图标、加速键表 │ ├── small.ico # 程序图标 │ └── targetver.h # Windows 版本宏核心逻辑全部集中在Win32Paint.cpp里大约 600 到 800 行。Win32Paint.h里定义了绘图模式枚举、图形对象结构体和全局状态变量。资源文件负责菜单和加速键不涉及对话框模板所以没有.rc2或dialog相关文件。提示如果你用的是 MinGW 或 g 命令行编译需要把.rc文件用windres编译成.o再链接不能直接丢给 g。2.2 技术选型GDI 够用但要注意双缓冲项目用的是 GDIGraphics Device Interface不是 GDI也不是 Direct2D。为什么因为画图工具的核心需求是“响应鼠标事件、在窗口客户区画线”GDI 的MoveToEx、LineTo、Rectangle、Ellipse完全覆盖而且 API 稳定、文档齐全、不需要额外链接gdiplus.lib。但 GDI 有个经典问题直接在WM_PAINT里画到窗口 DC 上拖动窗口或切换焦点时会闪烁。这个项目用了一个很实用的技巧——双缓冲先把所有图形画到内存 DC 的位图上再一次性BitBlt到窗口 DC。代码里对应CreateCompatibleDC和CreateCompatibleBitmap的调用。// 双缓冲初始化在 WM_CREATE 中调用 void InitDoubleBuffer(HWND hwnd, int width, int height) { HDC hdc GetDC(hwnd); // 创建与窗口 DC 兼容的内存 DC g_hMemDC CreateCompatibleDC(hdc); // 创建与窗口 DC 兼容的位图尺寸与客户区一致 g_hMemBitmap CreateCompatibleBitmap(hdc, width, height); // 把位图选入内存 DC后续所有绘图操作都作用在位图上 SelectObject(g_hMemDC, g_hMemBitmap); // 用白色填充背景避免初始为黑色 PatBlt(g_hMemDC, 0, 0, width, height, WHITENESS); ReleaseDC(hwnd, hdc); }参数说明hdc是窗口 DC用来获取屏幕色彩格式width和height是客户区尺寸通常从GetClientRect获取。注意内存 DC 和位图在程序退出时要DeleteDC和DeleteObject否则会泄漏 GDI 对象。2.3 消息循环与状态机设计Win32 程序的核心是消息循环这个项目在WndProc里用switch-case处理了大约 15 个消息。和绘图直接相关的有WM_LBUTTONDOWN记录起点开始绘制WM_MOUSEMOVE如果左键按下根据当前工具类型更新图形WM_LBUTTONUP结束绘制把临时图形固化到内存位图WM_PAINT把内存位图 BitBlt 到窗口WM_COMMAND响应菜单项切换工具或颜色这里有个容易翻车的点WM_MOUSEMOVE在鼠标移出窗口后仍然会触发但坐标可能超出客户区。项目里用GetClientRect和PtInRect做了边界判断防止画到窗口外面去。case WM_MOUSEMOVE: if (g_bDrawing) { int x GET_X_LPARAM(lParam); int y GET_Y_LPARAM(lParam); RECT rc; GetClientRect(hwnd, rc); POINT pt { x, y }; if (!PtInRect(rc, pt)) break; // 超出客户区直接忽略 // 根据当前工具更新临时图形 UpdateTempShape(x, y); InvalidateRect(hwnd, NULL, FALSE); // 触发重绘 } break;GET_X_LPARAM和GET_Y_LPARAM是 Windows 提供的宏用来从lParam里提取有符号坐标比手动移位更安全。InvalidateRect的第三个参数FALSE表示不擦除背景因为我们用双缓冲自己控制背景。3. 核心绘图逻辑拆解从鼠标按下到图形固化3.1 图形对象的数据结构项目里用一个结构体保存当前正在绘制的图形以及一个std::vector保存已经固化的图形。这样设计的好处是重绘时只需要遍历 vector 重新画一遍不需要保存整个位图。对于简单画图工具图形数量不多这种“矢量重绘”方式比保存位图更省内存。// 绘图工具类型枚举 enum DrawTool { TOOL_PENCIL, // 铅笔自由绘制 TOOL_LINE, // 直线 TOOL_RECT, // 矩形 TOOL_ELLIPSE, // 椭圆 TOOL_ERASER // 橡皮擦 }; // 单个图形对象 struct Shape { DrawTool tool; // 工具类型 POINT start; // 起点 POINT end; // 终点 COLORREF color; // 颜色 int penWidth; // 线宽 }; // 全局状态 std::vectorShape g_shapes; // 已固化的图形列表 Shape g_tempShape; // 当前正在绘制的临时图形 bool g_bDrawing false; // 是否正在绘制铅笔工具比较特殊它不是两点确定一个图形而是记录一串点。项目里用std::vectorPOINT保存铅笔轨迹固化时把轨迹转成多个小线段存入g_shapes。3.2 铅笔与橡皮擦的实现差异铅笔和橡皮擦在代码上看起来很像都是跟随鼠标画线但实现上有两个关键区别第一铅笔用当前前景色橡皮擦用背景色通常是白色。第二橡皮擦的线宽通常比铅笔粗项目里默认铅笔 1 像素、橡皮擦 10 像素。void DrawPencil(HDC hdc, const Shape shape) { // 铅笔从起点到终点画一条线 HPEN hPen CreatePen(PS_SOLID, shape.penWidth, shape.color); HPEN hOldPen (HPEN)SelectObject(hdc, hPen); MoveToEx(hdc, shape.start.x, shape.start.y, NULL); LineTo(hdc, shape.end.x, shape.end.y); SelectObject(hdc, hOldPen); DeleteObject(hPen); } void DrawEraser(HDC hdc, const Shape shape) { // 橡皮擦用背景色画粗线覆盖之前的图形 HPEN hPen CreatePen(PS_SOLID, shape.penWidth, RGB(255, 255, 255)); HPEN hOldPen (HPEN)SelectObject(hdc, hPen); MoveToEx(hdc, shape.start.x, shape.start.y, NULL); LineTo(hdc, shape.end.x, shape.end.y); SelectObject(hdc, hOldPen); DeleteObject(hPen); }注意CreatePen创建的 HPEN 必须用DeleteObject释放否则每次重绘都会泄漏一个 GDI 对象。Windows 对每个进程的 GDI 对象数量有限制默认 10000 个画久了就会卡死。3.3 直线、矩形、椭圆的绘制与预览直线、矩形、椭圆都是“两点确定一个图形”区别只在 GDI 函数不同。项目里用一个统一的DrawShape函数根据tool字段分发void DrawShape(HDC hdc, const Shape shape) { HPEN hPen CreatePen(PS_SOLID, shape.penWidth, shape.color); HPEN hOldPen (HPEN)SelectObject(hdc, hPen); // 矩形和椭圆需要空画刷否则会被填充 HBRUSH hOldBrush (HBRUSH)SelectObject(hdc, GetStockObject(NULL_BRUSH)); switch (shape.tool) { case TOOL_LINE: MoveToEx(hdc, shape.start.x, shape.start.y, NULL); LineTo(hdc, shape.end.x, shape.end.y); break; case TOOL_RECT: Rectangle(hdc, shape.start.x, shape.start.y, shape.end.x, shape.end.y); break; case TOOL_ELLIPSE: Ellipse(hdc, shape.start.x, shape.start.y, shape.end.x, shape.end.y); break; default: break; } SelectObject(hdc, hOldBrush); SelectObject(hdc, hOldPen); DeleteObject(hPen); }GetStockObject(NULL_BRUSH)是关键如果不设置空画刷Rectangle和Ellipse会用默认的白色画刷填充内部把之前的图形盖住。直线不受画刷影响但统一设置也无妨。3.4 重绘流程WM_PAINT 里做了什么WM_PAINT是画图工具最核心的消息。每次窗口需要重绘时比如被遮挡后恢复、调用InvalidateRect系统都会发送这个消息。项目里的处理流程是BeginPaint获取窗口 DC把内存位图BitBlt到窗口 DC遍历g_shapes把所有已固化图形画到内存 DC如果有临时图形正在绘制中也画到内存 DCEndPaint结束绘制case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); // 第一步把内存位图拷贝到窗口 BitBlt(hdc, 0, 0, g_clientWidth, g_clientHeight, g_hMemDC, 0, 0, SRCCOPY); // 第二步重绘所有已固化图形到内存 DC for (const auto shape : g_shapes) { DrawShape(g_hMemDC, shape); } // 第三步如果有临时图形也画上去 if (g_bDrawing) { DrawShape(g_hMemDC, g_tempShape); } // 第四步再次 BitBlt把最新内容刷到窗口 BitBlt(hdc, 0, 0, g_clientWidth, g_clientHeight, g_hMemDC, 0, 0, SRCCOPY); EndPaint(hwnd, ps); break; }这里有个性能优化点如果图形数量很多每次都全量重绘会变慢。常见做法是只重绘“脏矩形”区域但项目为了简单直接全量重绘。对于几百个图形的小工具这点开销可以接受。4. 避坑与常见问题GDI 对象泄漏和闪烁怎么排查4.1 画着画着程序卡死GDI 对象泄漏现象连续画几十条线后程序响应变慢最终卡死或绘图失效。原因每次WM_PAINT都调用CreatePen创建新画笔但没有DeleteObject释放。Windows 每个进程默认最多 10000 个 GDI 对象用完就分配失败。解决所有CreatePen、CreateBrush、CreateCompatibleDC、CreateCompatibleBitmap都必须配对释放。项目里在WM_DESTROY中统一清理全局 GDI 对象在DrawShape内部用SelectObject恢复旧对象后立即DeleteObject。4.2 拖动窗口时图形闪烁现象拖动窗口或调整大小时客户区内容闪烁图形忽隐忽现。原因WM_ERASEBKGND默认会用背景色擦除客户区然后WM_PAINT再画一遍两次操作之间产生闪烁。解决在WndProc里处理WM_ERASEBKGND直接返回 1表示“背景已经擦除过了”阻止系统默认擦除。同时用双缓冲把绘制结果先画到内存 DC再一次性拷贝到窗口。case WM_ERASEBKGND: return 1; // 告诉系统背景已处理不要擦除4.3 鼠标移出窗口后图形画到外面现象按住左键拖动鼠标移出窗口图形会画到窗口外面或标题栏上。原因WM_MOUSEMOVE的坐标是相对于客户区的但鼠标移出后坐标可能为负数或超出客户区尺寸。如果没有边界判断GDI 会把这些坐标也画出来。解决在WM_MOUSEMOVE里用GetClientRect获取客户区矩形再用PtInRect判断坐标是否在矩形内。不在范围内就跳过本次绘制。另外在WM_LBUTTONDOWN里调用SetCapture捕获鼠标确保鼠标移出窗口后仍然能收到消息。4.4 调整窗口大小后图形错位现象调整窗口大小后之前画的图形位置偏移或消失。原因内存位图的大小是在WM_CREATE时根据初始客户区尺寸创建的窗口变大后位图没有跟着变大BitBlt只能拷贝一部分。解决在WM_SIZE里重新创建内存位图和内存 DC把旧位图的内容拷贝到新位图再释放旧资源。项目里用一个ResizeDoubleBuffer函数处理这个逻辑。void ResizeDoubleBuffer(HWND hwnd, int width, int height) { HDC hdc GetDC(hwnd); // 创建新的内存 DC 和位图 HDC hNewMemDC CreateCompatibleDC(hdc); HBITMAP hNewBitmap CreateCompatibleBitmap(hdc, width, height); HBITMAP hOldBitmap (HBITMAP)SelectObject(hNewMemDC, hNewBitmap); // 把旧内容拷贝到新位图 if (g_hMemDC) { BitBlt(hNewMemDC, 0, 0, width, height, g_hMemDC, 0, 0, SRCCOPY); // 释放旧资源 SelectObject(g_hMemDC, g_hOldBitmap); DeleteDC(g_hMemDC); DeleteObject(g_hMemBitmap); } g_hMemDC hNewMemDC; g_hMemBitmap hNewBitmap; g_hOldBitmap hOldBitmap; ReleaseDC(hwnd, hdc); }4.5 菜单加速键不生效现象资源文件里定义了CtrlZ撤销但按下没反应。原因加速键表需要在消息循环里用TranslateAccelerator处理如果只写了TranslateMessage和DispatchMessage加速键不会被翻译。解决在WinMain的消息循环里加载加速键表并调用TranslateAcceleratorHACCEL hAccel LoadAccelerators(hInstance, MAKEINTRESOURCE(IDR_ACCELERATOR)); MSG msg; while (GetMessage(msg, NULL, 0, 0)) { if (!TranslateAccelerator(hwnd, hAccel, msg)) { TranslateMessage(msg); DispatchMessage(msg); } }5. 二次开发改造从画图工具到标注面板的实战思路这个项目最实用的二次开发方向是把它改成一个“图片标注面板”——加载一张底图在上面画框、画线、加文字最后导出标注坐标。我拿它改过一个内部用的缺陷标注工具下面说几个关键改造点。第一加载底图。在WM_CREATE里用LoadImage加载一张 BMP 或 PNGPNG 需要 GDI然后用StretchBlt画到内存 DC 上。注意底图要画在g_shapes之前否则会盖住已画的图形。改造后的WM_PAINT顺序是底图 → 已固化图形 → 临时图形。第二增加文字标注。GDI 的TextOut或DrawText可以画文字但需要选入字体。常见做法是在WM_CREATE里用CreateFont创建一个字体对象保存为全局变量绘制文字时SelectObject选入 DC。文字内容可以用InputBox或自定义对话框获取。第三导出标注数据。在菜单里加一个“导出”项响应WM_COMMAND时遍历g_shapes把每个图形的tool、start、end、color写成 JSON 或 CSV。如果底图有缩放记得把客户区坐标换算成图片原始坐标。// 导出标注为 CSV void ExportAnnotations(const std::string filename) { std::ofstream file(filename); file tool,startX,startY,endX,endY,color\n; for (const auto shape : g_shapes) { file (int)shape.tool , shape.start.x , shape.start.y , shape.end.x , shape.end.y , (int)GetRValue(shape.color) ; (int)GetGValue(shape.color) ; (int)GetBValue(shape.color) \n; } file.close(); }第四撤销与重做。项目本身没有撤销功能但改造起来不难用一个std::stackShape保存操作历史CtrlZ时弹出最后一个图形并从g_shapes中移除然后InvalidateRect重绘。重做则用另一个栈保存被撤销的图形。第五坐标换算。如果底图有缩放或平移鼠标坐标需要反向换算。常见做法是维护一个scale和offset变量鼠标坐标先减去offset再除以scale得到图片原始坐标。这个换算在WM_LBUTTONDOWN和WM_MOUSEMOVE里都要做。注意改造时不要直接在WM_PAINT里加载底图那样每次重绘都会读磁盘性能极差。正确做法是在WM_CREATE里加载一次保存为内存位图或CImage对象。我当初改这个标注工具时最翻车的一次是忘了在WM_SIZE里重新计算底图的缩放比例结果窗口一拉大标注框全偏了。从那以后我每次改绘图相关的代码都强制走一遍“加载底图 → 画框 → 调整窗口 → 导出数据”的完整流程确认坐标换算没问题再提交。希望这个思路帮到你源码里还有很多细节值得自己动手改一遍。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑