
简介这份资源是一套基于 Win32 API 实现的简易画图工具完整工程面向正在学习 Windows 编程、GDI 绘图与消息机制的初学者及进阶开发者帮助读者通过可运行项目理解窗口管理、图形绘制与文件操作等核心概念。压缩包为 rar 格式共 87 个文件约 36.57MB包含 9 个 cpp 源文件与 11 个 h 头文件构成主要逻辑22 个 bmp 与 5 个 cur、2 个 ico 提供界面素材另有 sln、vcxproj 等工程配置及 exe、pdb 等编译产物目录结构清晰便于直接打开调试。目前已有 432 人学习下载。工程覆盖颜色选择对话框、MoveToEx 与 LineTo 线条绘制、Ellipse 与 Rectangle 基本图形、橡皮擦透明笔实现、BMP/PNG 图像保存以及 CreateWindowEx 子窗口动态开关与消息循环处理读者可据此掌握 GDI 绘图流程与 Win32 消息驱动模型为构建更复杂的 Windows 应用打下实践基础。1. 用 Win32 裸 API 写一个画图工具为什么它比框架更值得练手很多人第一次接触 Windows 桌面开发都是从拖控件开始的。但如果你真想搞明白「窗口是怎么画出来的」「鼠标按下到屏幕上出现一条线中间到底发生了什么」绕开 MFC、Qt、Electron 这些封装层直接用 Win32 裸 API 写一个简单画图工具是最短路径。这个标题说的不是做一个功能齐全的绘图软件而是用最原始的CreateWindowEx、WM_PAINT、WM_MOUSEMOVE这套消息机制实现铅笔、直线、矩形、椭圆、橡皮擦和清屏这几个基础功能。它适合两类人一类是刚学完 C 语言、想找个能看见成果的练手项目的新手另一类是从 Web 或移动端转过来、想补一补 Windows 消息循环这块底层认知的熟手。画图工具的好处是反馈即时——你画的每一条线都是对消息处理逻辑的直接验证逻辑错了立刻翻车没有中间层帮你兜底。2. 窗口骨架与消息循环把画布立起来2.1 注册窗口类时最容易忽略的hbrBackgroundWin32 窗口的绘制分两层一层是系统根据窗口类里的背景刷自动擦除另一层是你在WM_PAINT里自己画。很多人第一次写画图工具发现鼠标拖动时画面疯狂闪烁或者之前画的线莫名其妙消失根因就在背景刷和重绘逻辑没配合好。注册窗口类时WNDCLASS的hbrBackground如果设成(HBRUSH)GetStockObject(WHITE_BRUSH)系统会在每次WM_ERASEBKGND时用白色刷子把整个客户区刷白。对于画图工具这恰恰是你想要的——因为你的图形数据存在内存里每次重绘都应该先清背景再重画所有图元。但如果你把hbrBackground设成NULL又不处理WM_ERASEBKGND窗口就会残留上一帧的内容拖动时出现拖影。// 注册窗口类背景刷决定重绘时的擦除行为 WNDCLASS wc {0}; wc.style CS_HREDRAW | CS_VREDRAW; // 宽高变化时强制重绘 wc.lpfnWndProc WndProc; wc.hInstance hInstance; wc.hCursor LoadCursor(NULL, IDC_CROSS); // 十字光标更适合画图 wc.hbrBackground (HBRUSH)GetStockObject(WHITE_BRUSH); wc.lpszClassName LSimplePaintWnd; RegisterClass(wc);CS_HREDRAW | CS_VREDRAW这两个样式很关键。没有它们你拖动窗口边缘改变大小时客户区不会触发完整重绘画布上会出现撕裂的残影。加上之后每次尺寸变化系统都会发WM_PAINT你就有机会按新尺寸重建内存 DC。hCursor设成IDC_CROSS是画图工具的惯例十字准星比箭头更容易定位像素点。2.2 消息循环里GetMessage和PeekMessage的选择标准 Win32 程序用GetMessage阻塞等待消息这对画图工具够用。但如果你想在空闲时做点别的事比如自动保存草稿就得换成PeekMessage轮询。对新手来说先用GetMessage把主循环跑通别一上来就搞复杂。// 标准消息循环阻塞直到收到消息 MSG msg; while (GetMessage(msg, NULL, 0, 0)) { TranslateMessage(msg); // 把 WM_KEYDOWN 转成 WM_CHAR DispatchMessage(msg); // 分发给 WndProc } return (int)msg.wParam;TranslateMessage对画图工具不是必须的因为你不处理字符输入。但保留它没坏处以后想加键盘快捷键比如 CtrlZ 撤销时就用得上。DispatchMessage是真正把消息送到WndProc的地方所有绘制逻辑都从这里进去。2.3 双缓冲解决闪烁绕不开的一步直接在窗口 DC 上画图每画一个图元就刷一次屏鼠标快速移动时闪烁会非常明显。标准解法是双缓冲先在内存 DC 上把整幅画好再一次性BitBlt到窗口 DC。这样用户看到的永远是完整的一帧。// 在 WM_PAINT 里使用双缓冲 case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); // 创建内存 DC 和兼容位图 HDC memDC CreateCompatibleDC(hdc); HBITMAP memBmp CreateCompatibleBitmap(hdc, clientW, clientH); HBITMAP oldBmp (HBITMAP)SelectObject(memDC, memBmp); // 1. 填充白色背景 RECT rc {0, 0, clientW, clientH}; FillRect(memDC, rc, (HBRUSH)GetStockObject(WHITE_BRUSH)); // 2. 重画所有已保存的图元 RedrawAllShapes(memDC); // 3. 一次性拷贝到窗口 BitBlt(hdc, 0, 0, clientW, clientH, memDC, 0, 0, SRCCOPY); // 4. 清理资源 SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hWnd, ps); return 0; }这里有个血泪经验CreateCompatibleBitmap必须用窗口 DC 来创建不能用内存 DC。如果你写成CreateCompatibleBitmap(memDC, ...)得到的是单色位图画出来全是黑白噪点。这个坑我见过不止一个新手踩进去排查半天以为是绘图逻辑错了其实是位图格式不对。另外memBmp和memDC每次WM_PAINT都要重新创建和销毁不要缓存成全局变量——窗口尺寸变化后旧位图就失效了缓存反而容易出内存泄漏。3. 图元数据结构与鼠标交互让线条跟着鼠标走3.1 用数组存图元简单但够用的方案画图工具的核心是「记录用户画了什么」。最直接的做法是定义一个图元结构体用一个动态数组存起来。每次重绘时遍历数组按顺序重画。这个方案在图形数量几百到几千时完全够用不需要上链表或四叉树。// 图元类型枚举 typedef enum { SHAPE_LINE, SHAPE_RECT, SHAPE_ELLIPSE, SHAPE_FREEHAND } ShapeType; // 单个图元 typedef struct { ShapeType type; POINT start; // 起点 POINT end; // 终点自由画时为当前点 COLORREF color; // 颜色 int width; // 线宽 POINT points[512]; // 自由画的轨迹点 int pointCount; } Shape; // 全局图元数组 #define MAX_SHAPES 4096 Shape g_shapes[MAX_SHAPES]; int g_shapeCount 0;自由画铅笔比较特殊它是一串连续的点不是简单的起点终点。我用固定大小的points数组存轨迹上限 512 个点。如果一次笔画超过 512 个点就截断——实际使用中很少有人一笔画超过这个数。如果你要支持超长笔画改成动态分配或者分段存储。3.2 鼠标消息的捕获与状态机画图工具的交互逻辑是一个典型的状态机鼠标左键按下进入「绘制中」状态移动时更新当前图元的终点抬起时把图元固化到数组里。关键是要用SetCapture把鼠标捕获住否则鼠标移出窗口后你就收不到WM_MOUSEMOVE了线条会断掉。// 全局状态 BOOL g_drawing FALSE; Shape g_currentShape; case WM_LBUTTONDOWN: { SetCapture(hWnd); // 捕获鼠标移出窗口也能收到消息 g_drawing TRUE; g_currentShape.type g_currentTool; // 当前工具铅笔/直线/矩形/椭圆 g_currentShape.start.x GET_X_LPARAM(lParam); g_currentShape.start.y GET_Y_LPARAM(lParam); g_currentShape.end g_currentShape.start; g_currentShape.color g_currentColor; g_currentShape.width g_currentWidth; g_currentShape.pointCount 0; return 0; } case WM_MOUSEMOVE: { if (!g_drawing) return 0; int x GET_X_LPARAM(lParam); int y GET_Y_LPARAM(lParam); if (g_currentShape.type SHAPE_FREEHAND) { // 自由画追加轨迹点 if (g_currentShape.pointCount 512) { g_currentShape.points[g_currentShape.pointCount].x x; g_currentShape.points[g_currentShape.pointCount].y y; g_currentShape.pointCount; } } else { // 直线/矩形/椭圆只更新终点 g_currentShape.end.x x; g_currentShape.end.y y; } // 触发重绘显示预览 InvalidateRect(hWnd, NULL, FALSE); return 0; } case WM_LBUTTONUP: { if (!g_drawing) return 0; ReleaseCapture(); g_drawing FALSE; // 把当前图元固化到数组 if (g_shapeCount MAX_SHAPES) { g_shapes[g_shapeCount] g_currentShape; } InvalidateRect(hWnd, NULL, FALSE); return 0; }GET_X_LPARAM和GET_Y_LPARAM这两个宏来自windowsx.h它们从lParam里提取有符号的坐标值。如果你直接用LOWORD(lParam)在负坐标比如鼠标拖到窗口左上方外面时会得到错误的正值。这个细节在画图工具里很关键因为用户经常会把鼠标拖出客户区。3.3 重绘函数把图元数组翻译成 GDI 调用RedrawAllShapes是连接数据结构和 GDI 的桥梁。它遍历图元数组根据类型调用对应的 GDI 函数。注意自由画要用Polyline而不是逐点SetPixel前者性能好得多。void RedrawAllShapes(HDC hdc) { for (int i 0; i g_shapeCount; i) { Shape *s g_shapes[i]; HPEN pen CreatePen(PS_SOLID, s-width, s-color); HPEN oldPen (HPEN)SelectObject(hdc, pen); switch (s-type) { case SHAPE_LINE: MoveToEx(hdc, s-start.x, s-start.y, NULL); LineTo(hdc, s-end.x, s-end.y); break; case SHAPE_RECT: // 注意Rectangle 画的是填充矩形需要选空刷子 SelectObject(hdc, GetStockObject(NULL_BRUSH)); Rectangle(hdc, s-start.x, s-start.y, s-end.x, s-end.y); break; case SHAPE_ELLIPSE: SelectObject(hdc, GetStockObject(NULL_BRUSH)); Ellipse(hdc, s-start.x, s-start.y, s-end.x, s-end.y); break; case SHAPE_FREEHAND: if (s-pointCount 1) Polyline(hdc, s-points, s-pointCount); break; } SelectObject(hdc, oldPen); DeleteObject(pen); } // 如果正在绘制把当前图元也画出来作为预览 if (g_drawing) { // 同样的绘制逻辑画 g_currentShape } }Rectangle和Ellipse默认会用当前画刷填充内部。如果你只想画边框必须先把画刷设成NULL_BRUSH否则画出来的矩形是实心的把下面的内容全盖住。这个坑很隐蔽因为第一次画的时候看起来「没问题」等图形重叠时就发现全乱了。4. 工具切换与颜色线宽把参数暴露给用户4.1 用菜单和快捷键切换工具工具切换最省事的做法是加一个菜单栏用WM_COMMAND处理。菜单资源可以在.rc文件里定义也可以纯代码用CreateMenu动态创建。对练手项目我建议用.rc文件改起来直观。// 在 WM_COMMAND 里处理工具切换 case WM_COMMAND: { switch (LOWORD(wParam)) { case ID_TOOL_PENCIL: g_currentTool SHAPE_FREEHAND; break; case ID_TOOL_LINE: g_currentTool SHAPE_LINE; break; case ID_TOOL_RECT: g_currentTool SHAPE_RECT; break; case ID_TOOL_ELLIPSE: g_currentTool SHAPE_ELLIPSE; break; case ID_TOOL_ERASER: g_currentTool SHAPE_FREEHAND; g_currentColor RGB(255,255,255); break; case ID_FILE_CLEAR: g_shapeCount 0; InvalidateRect(hWnd, NULL, FALSE); break; } return 0; }橡皮擦的实现很取巧它本质上就是白色的自由画。把颜色设成白色画出来的轨迹就把下面的内容「擦掉」了。但注意这只是视觉上的擦除图元数组里仍然存着原来的图形和白色的擦除轨迹。如果你以后要做「撤销」这种实现方式会让撤销逻辑变复杂。更严谨的做法是橡皮擦直接删除被覆盖的图元但那样需要做碰撞检测对简单工具来说不值得。4.2 颜色和线宽的参数传递颜色用CHOOSECOLOR对话框让用户选线宽用几个预设值1、2、4、8 像素通过菜单或工具栏切换。关键是要把当前颜色和线宽存成全局变量在WM_LBUTTONDOWN时写入g_currentShape。// 颜色选择对话框 CHOOSECOLOR cc {0}; static COLORREF customColors[16]; cc.lStructSize sizeof(cc); cc.hwndOwner hWnd; cc.lpCustColors customColors; cc.Flags CC_FULLOPEN | CC_RGBINIT; cc.rgbResult g_currentColor; if (ChooseColor(cc)) { g_currentColor cc.rgbResult; }customColors必须是static或全局的因为对话框关闭后系统仍然会引用这块内存来保存用户的自定义颜色。如果把它定义成局部变量函数返回后栈内存被回收下次打开对话框时自定义颜色就丢了甚至可能崩溃。这个细节在文档里写得不清不楚但实际项目中必须这么写。4.3 线宽对 GDI 图元的影响CreatePen的第二个参数是线宽单位是像素。但要注意宽线在画矩形和椭圆时边框会向外扩展导致图形比鼠标拖出的范围大一圈。如果你需要精确的边界得自己算偏移量。对画图工具来说这点偏差可以接受不用过度设计。线宽值适用场景注意事项1精细线条、铅笔默认值性能最好2常规绘图视觉上明显加粗4强调线条矩形边框会外扩约 2px8粗标记小图形会被边框盖住5. 避坑与排查画图工具最容易翻车的 5 个地方5.1 拖动时闪烁严重线条像在抖现象鼠标移动时之前画的线一闪一闪快速拖动时几乎看不清预览线。原因每次WM_MOUSEMOVE都调用InvalidateRect(hWnd, NULL, TRUE)第三个参数TRUE表示擦除背景系统先用背景刷把整个客户区刷白再触发WM_PAINT。这一擦一画之间就是闪烁的来源。解决把InvalidateRect的第三个参数改成FALSE并且在WM_PAINT里用双缓冲一次性画完。如果还有轻微闪烁在WM_ERASEBKGND里直接返回 1告诉系统「背景我已经在WM_PAINT里处理了你别管」。5.2 鼠标移出窗口后线条断掉现象画自由画时鼠标快速移到窗口外面再移回来轨迹中间缺了一段。原因没有调用SetCapture鼠标移出客户区后WM_MOUSEMOVE不再发到你的窗口。解决在WM_LBUTTONDOWN里调用SetCapture(hWnd)在WM_LBUTTONUP里调用ReleaseCapture()。注意SetCapture之后即使鼠标在窗口外面消息也会发过来坐标可能是负值所以必须用GET_X_LPARAM而不是LOWORD。5.3 窗口最小化再恢复后图形全没了现象最小化窗口再恢复画布变成一片空白。原因最小化时窗口尺寸变成 0WM_SIZE触发后你的clientW和clientH被更新成 0恢复时没有重新计算双缓冲位图创建失败。解决在WM_SIZE里用LOWORD(lParam)和HIWORD(lParam)更新客户区尺寸并且判断如果宽高为 0 就跳过重绘。恢复时系统会发WM_PAINT用新的尺寸重建内存 DC 即可。5.4 图形数量多了以后卡顿现象画了几百个图元后每次鼠标移动都明显掉帧。原因RedrawAllShapes每次都遍历全部图元而且每个图元都CreatePen/DeleteObject一次GDI 对象创建销毁的开销累积起来很可观。解决把画笔按「颜色线宽」缓存起来不要每次重画都创建。简单做法是用一个固定大小的画笔池或者按颜色分组。另一个优化是只重画「脏区域」但这对简单工具来说复杂度太高不如直接限制图元数量上限。5.5 橡皮擦擦不干净留下白色痕迹现象用橡皮擦在彩色背景上擦擦过的地方是白色而不是「透出下面的内容」。原因橡皮擦实现成了白色自由画它画的是白色线条不是真正的擦除。解决如果背景不是白色橡皮擦需要改成「删除被覆盖的图元」或者用PatBlt配合PATINVERT做异或擦除。对白色背景的画图工具白色橡皮擦够用但要在文档里说清楚这个限制。6. 进阶技巧用WM_SETCURSOR和GetStockObject打磨细节6.1 让光标随工具变化默认情况下窗口类里设的hCursor对所有区域生效。但你可以用WM_SETCURSOR消息做更细的控制当鼠标在客户区时显示十字在菜单栏时显示箭头。这个技巧能让工具用起来更顺手。case WM_SETCURSOR: { if (LOWORD(lParam) HTCLIENT) { // 根据当前工具选择光标 LPCWSTR cursorName (g_currentTool SHAPE_FREEHAND) ? IDC_CROSS : IDC_CROSS; SetCursor(LoadCursor(NULL, cursorName)); return TRUE; // 告诉系统我们已经处理了 } break; // 其他区域交给默认处理 }返回TRUE表示「光标我已经设好了系统不用再管」。如果返回FALSE系统会用窗口类的默认光标覆盖你的设置。这个返回值很容易写错导致设了光标但没生效。6.2 用GetStockObject减少 GDI 对象泄漏每次CreatePen、CreateBrush都要对应DeleteObject漏掉一个就泄漏一个 GDI 对象。Windows 对每个进程的 GDI 对象数量有限制默认 10000 个画图工具长时间运行很容易触顶。除了严格配对创建和删除另一个技巧是尽量用GetStockObject获取系统预定义的对象比如WHITE_BRUSH、BLACK_PEN、NULL_BRUSH。这些对象不需要你删除用起来更安全。// 安全做法优先用 stock object HPEN pen CreatePen(PS_SOLID, width, color); // ... 使用 ... DeleteObject(pen); // 必须配对 // 更安全用 stock object不需要删除 HPEN stockPen (HPEN)GetStockObject(BLACK_PEN);但 stock object 的线宽和颜色是固定的不能自定义。所以实际项目中是混合使用默认黑色细线用 stock pen用户自定义颜色和线宽时才CreatePen。6.3 一个验证 GDI 泄漏的简单方法在任务管理器里打开「详细信息」选项卡右键列标题勾选「GDI 对象」。然后反复画图、清屏、切换工具观察 GDI 对象数量是否持续上涨。如果画一百条线后 GDI 对象数从 50 涨到 500 且不回落说明有泄漏。正常情况应该在重绘后回落到一个稳定值。这个方法比装性能分析工具快得多适合日常开发时随手检查。我自己的习惯是每次写完一个涉及 GDI 创建的函数立刻在脑子里过一遍「这个对象谁负责删」。如果答案不明确就说明设计有问题宁可多写几行代码把生命周期理清楚也不要留到后面用「后悔药」去补。画图工具虽然简单但它是理解 Windows 图形编程最好的入口——把这一套消息机制和 GDI 对象管理吃透后面看任何 UI 框架的源码都会觉得似曾相识。希望帮到你。本文还有配套的精品资源点击获取