Win32手写画图工具:窗口消息循环与GDI双缓冲实战
简介基于 Win32 API 编写的简易画图工具源码包面向初学 Windows 桌面编程的开发者用于理解 Win32 消息循环、窗口管理与 GDI 图形设备接口的基本用法。资源共 87 个文件压缩包约 36.57MB主要包含 cpp/h 源代码、rc 界面资源、bmp/cur/ico 图形素材以及可直接运行的 exe 和调试信息文件便于对照源码与运行效果进行学习。已有 432 人学习下载。代码按绘图核心、工具栏、颜色选择、文件读写等模块组织清晰展示了如何创建子窗口、处理鼠标消息、调用 MoveToEx/LineTo/Ellipse 等 API 完成自由绘制并实现橡皮擦、选色和 BMP 保存功能。对想深入理解 Windows 编程基础、动手实践 GDI 绘图的人来说是一份结构完整且可直接改造的参考工程。1. 为什么现在还用win32手写画图工具一条不依赖框架的上手路线如果你问我为什么放着OpenCV、QPainter不用非要用win32 API手写一个画图工具我的答案很简单win32的窗口、消息、GDI绘图这三样东西是Windows桌面程序的底层骨架。无论你以后去用MFC、Qt还是Electron消息循环和窗口过程的逻辑都不会变。画图工具正好是最适合练手的场景——它要响应鼠标、要重绘、要保存结果、还要处理窗口尺寸变化几乎把Win32编程的核心点全踩了一遍。这篇笔记的目标读者是刚开始学Win32、想用最小代码量理解“窗口消息绘图”的开发者或者需要在内部快速搓一个截图标注小工具的人。它会讲清楚窗口怎么写、鼠标怎么画、闪烁怎么消、文件怎么存。2. 从CreateWindow到消息循环搭一个能画画的窗口需要哪些固定动作2.1 WinMain、注册类和消息循环三段代码撑起GDI画布写任何Win32程序都要先过这一关注册窗口类、创建窗口、跑消息循环。我第一次写的时候以为这只是模板代码直接复制粘贴结果连窗口都弹不出来。后来才知道窗口类里那个lpfnWndProc回调决定了一切而消息循环的写法决定了你的窗口会不会“假死”。#include windows.h LRESULT CALLBACK WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_DESTROY: PostQuitMessage(0); // 让GetMessage返回0消息循环退出 return 0; default: return DefWindowProc(hWnd, msg, wParam, lParam); } } int WINAPI WinMain(HINSTANCE hInst, HINSTANCE hPrev, LPSTR lpCmdLine, int nCmdShow) { WNDCLASS wc { 0 }; wc.lpfnWndProc WndProc; // 窗口过程函数指针 wc.hInstance hInst; wc.hCursor LoadCursor(nullptr, IDC_ARROW); wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); // 窗口默认背景色 wc.lpszClassName LSimplePaintClass; if (!RegisterClass(wc)) return -1; HWND hWnd CreateWindow(LSimplePaintClass, LWin32 Painter, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, nullptr, nullptr, hInst, nullptr); if (!hWnd) return -1; ShowWindow(hWnd, nCmdShow); UpdateWindow(hWnd); MSG msg; while (GetMessage(msg, nullptr, 0, 0) 0) { TranslateMessage(msg); DispatchMessage(msg); } return (int)msg.wParam; }这段代码的逻辑并不复杂RegisterClass把窗口类的名字和窗口过程绑定在一起CreateWindow真正创建窗口之后程序进入一个无限循环取消息。GetMessage在没有消息时会让线程休眠所以这个循环不会空转占CPU。DispatchMessage会把消息交还给窗口过程处理。我见过不少人把PeekMessage和GetMessage混用如果不需要做非阻塞动画就老老实实用GetMessage。窗口类里两个容易被忽略的参数一个是hbrBackground它决定窗口被擦除背景时用什么画刷另一个是CS_HREDRAW|CS_VREDRAW如果你希望窗口大小变化后整体重绘应该在style里加上这个。画图工具必须加否则窗口拉大时旧图边缘会花掉。这个WNDCLASS结构体里没写style系统默认不带这两个标志但不加也不会报错只是缩放窗口时会出现“黑边残留”的怪象。2.2 WM_PAINT里BeginPaint和GetDC的差别谁在决定画布的有效区窗口创建好了接下来要回答一个关键问题画图工具的根本是“内容没了要重画”那内容在哪里重画Win32把这件事全部塞进了WM_PAINT消息里。系统觉得窗口需要重绘时会给窗口过程发WM_PAINT你在里面用GDI画就行。case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); FillRect(hdc, ps.rcPaint, (HBRUSH)(COLOR_WINDOW 1)); MoveToEx(hdc, 0, 0, nullptr); LineTo(hdc, 200, 200); EndPaint(hWnd, ps); return 0; }BeginPaint有几个特点它会自动把需要重绘的“无效区域”填进ps.rcPaint它会把无效区域设为有效防止同一块区域反复触发WM_PAINT它需要和EndPaint配对使用。这里有一个新手必踩的坑有些人为了省事直接在WM_PAINT里用GetDC拿设备上下文画完又忘了ReleaseDC这样轻则闪烁重则GDI句柄泄漏。还有人会把WM_PAINT理解成“窗口上所有内容都归它管”于是把鼠标绘制也放在里面做。这不完全错但前提是你得把画过的内容保存到位图里WM_PAINT每次都把整张位图贴回去。如果你只在WM_PAINT里画几条测试线段那么窗口随便动一下画上去的东西就会消失——这也是后续章节要一直强调的问题画布数据必须有一份“持久化副本”不然每次重绘都是从零开始。3. 让鼠标在窗口上作画GDI画笔与三种基本图形的坐标逻辑3.1 按下、移动、抬起把临时线“钉”在画布上画图工具的核心交互就是鼠标三件套按下记录起点移动预览当前图形抬起把图形固定到画布。在Win32里这三个动作分别对应WM_LBUTTONDOWN、WM_MOUSEMOVE、WM_LBUTTONUP。鼠标坐标可以从lParam里拆出来低16位是x高16位是y。static POINT g_start, g_current; static bool g_bDrawing false; static int g_mode 0; // 0线, 1矩形, 2椭圆 case WM_LBUTTONDOWN: g_start { LOWORD(lParam), HIWORD(lParam) }; g_current g_start; g_bDrawing true; SetCapture(hWnd); // 确保鼠标移出窗口时仍能收到消息 break; case WM_MOUSEMOVE: if (g_bDrawing) { g_current { LOWORD(lParam), HIWORD(lParam) }; InvalidateRect(hWnd, nullptr, TRUE); // 触发重绘但这样会闪 } break; case WM_LBUTTONUP: if (g_bDrawing) { g_bDrawing false; ReleaseCapture(); } break;SetCapture这一步很重要不写的话你按住鼠标快速拖出窗口外程序就收不到移动消息了抬笔也会失败。InvalidateRect的第三个参数设为TRUE意思是先擦掉背景再重绘效果放在本章最后讲真要做完整画图工具这里会被替换成双缓冲方案。那么图形到底画在哪第一种写法是直接在WM_PAINT里读取g_start和g_current用MoveToEx和LineTo画一条临时直线。这种写法很简单但它有个致命问题只要窗口抖动一下重绘一来之前画的所有线段都没了。要让画过的内容留下来必须有一个“持久画布”。常见做法是维护一个HBITMAP把每次鼠标抬起后的最终图形往位图上画WM_PAINT只负责把位图贴出来。临时预览图形则画在“内存DC副本”上每条新线段都在上一次的副本基础上叠画这样既保留了历史内容又不会闪烁。顺带提一下窗口上的坐标是客户区坐标而WM_MOUSEMOVE拿到的lParam默认已经是客户区坐标。如果你以后要处理全屏捕获、多显示器才需要考虑ScreenToClient转换。画图工具内部不需要动它。3.2 矩形和椭圆其实是一套逻辑用同一个橡皮筋框实现三种图形如果你已经能画直线矩形和椭圆只是换一个GDI函数的问题。这里最容易犯的错是把“从起点到终点”理解成“从左上到右下”于是画出来的矩形总是差一截。正确做法是先算好规范化矩形不管鼠标往哪边拖都要保证left right、top bottom。static void NormalizeRect(RECT rc) { if (rc.left rc.right) { int tmp rc.left; rc.left rc.right; rc.right tmp; } if (rc.top rc.bottom) { int tmp rc.top; rc.top rc.bottom; rc.bottom tmp; } } case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); HPEN pen CreatePen(PS_SOLID, 2, RGB(0, 0, 0)); HGDIOBJ oldPen SelectObject(hdc, pen); if (g_bDrawing) { RECT rc { g_start.x, g_start.y, g_current.x, g_current.y }; NormalizeRect(rc); if (g_mode 0) { MoveToEx(hdc, g_start.x, g_start.y, nullptr); LineTo(hdc, g_current.x, g_current.y); } else if (g_mode 1) { Rectangle(hdc, rc.left, rc.top, rc.right, rc.bottom); } else if (g_mode 2) { Ellipse(hdc, rc.left, rc.top, rc.right, rc.bottom); } } SelectObject(hdc, oldPen); DeleteObject(pen); EndPaint(hWnd, ps); return 0; }注意这段代码里创建了HPEN用完要SelectObject换回去再DeleteObject删除否则GDI对象会累积泄漏。Rectangle和Ellipse的坐标参数是边界值画出来的矩形左右各占一个像素如果连续画多个图形边缘会互相覆盖。这个现象不算bug但当你做“擦除”功能时会明显觉得擦不干净原因就在于边界像素被半覆盖了。按下和抬起之间用WM_PAINT重绘整个窗口来做橡皮筋预览属于“能跑但会闪”的方案。闪的原因是每次InvalidateRect都会擦背景然后整个窗口重画一遍对于小窗口不明显但800x600以上时高速移动鼠标能看到明显的白色闪动。真正要消除它需要让预览绘制完全发生在内存里最后一次性复制到屏幕——这就是下一章的内容。4. 用双缓冲把闪烁降到零WM_ERASEBKGND与bitblt的后半场4.1 先画到内存DC再整块贴出三段式防闪写法双缓冲的思路是不在屏幕上直接作画而是先在内存里建一块跟窗口等大的“草稿布”把所有图形画到草稿布上最后用一次BitBlt把整块草稿布复制到窗口。屏幕只被刷新一次自然不会闪。case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hWnd, ps); // 1. 创建内存DC和兼容位图 HDC memDC CreateCompatibleDC(hdc); HBITMAP memBmp CreateCompatibleBitmap(hdc, winWidth, winHeight); HGDIOBJ oldBmp SelectObject(memDC, memBmp); // 2. 先画背景再画所有已固定的图形 FillRect(memDC, ps.rcPaint, (HBRUSH)(COLOR_WINDOW 1)); DrawAllShapes(memDC); // 你的图形列表绘制函数 // 3. 如果正在拖动把临时图形叠加在内存DC上 if (g_bDrawing) { RECT rc { g_start.x, g_start.y, g_current.x, g_current.y }; NormalizeRect(rc); if (g_mode 0) { MoveToEx(memDC, g_start.x, g_start.y, nullptr); LineTo(memDC, g_current.x, g_current.y); } else if (g_mode 1) { Rectangle(memDC, rc.left, rc.top, rc.right, rc.bottom); } else if (g_mode 2) { Ellipse(memDC, rc.left, rc.top, rc.right, rc.bottom); } } // 4. 整块贴到屏幕 BitBlt(hdc, 0, 0, winWidth, winHeight, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hWnd, ps); return 0; }这段代码里四个步骤的顺序不能乱。CreateCompatibleDC创建的DC默认只有1x1像素的位图如果不SelectObject一块真正大小匹配的位图进去画东西就会失败或者只画一点点。winWidth和winHeight可以从GetClientRect动态读取不要写死否则窗口拉大后贴图会缺一块。BitBlt的最后一个参数SRCCOPY表示完整覆盖这是最常用的光栅操作。只有双缓冲还不够你还得拦掉WM_ERASEBKGND。窗口默认在重绘前会用背景画刷擦一遍底色这个擦除动作和后续的BitBlt是分开的屏幕会出现一次白屏再出现你的画。处理办法是在窗口过程里加一个分支case WM_ERASEBKGND: return 1; // 告诉系统“背景我已经处理了”禁止它再擦WM_ERASEBKGND返回1这个写法我用了一年多才真正理解系统收到WM_ERASEBKGND后本应该拿背景画刷刷一下窗口你返回非零值等于告诉它“别擦了我自己会画”。这样一来整个重绘过程就只剩下BitBlt一次贴图动作闪烁自然消失。这里是Windows绘图优化的核心点值得多花一点时间记住。4.2 用GetDIBits把画布变成BMP从窗口到文件的退路画布数据都在内存DC里怎么把它保存成文件最直接的方式是用GetDIBits把位图数据取出来然后写一个简单的BMP文件头并把像素数据倒进去。这里要处理位图信息头和文件头两个结构体代码稍长但它是理解BMP文件格式最好的途径。bool SaveBitmapToFile(HDC hdc, HBITMAP bmp, const wchar_t* filename) { BITMAP bm; GetObject(bmp, sizeof(BITMAP), bm); BITMAPINFOHEADER bi { 0 }; bi.biSize sizeof(BITMAPINFOHEADER); bi.biWidth bm.bmWidth; bi.biHeight -bm.bmHeight; // 负高度表示自顶向下的像素行顺序 bi.biPlanes 1; bi.biBitCount 24; bi.biCompression BI_RGB; size_t dataSize ((bm.bmWidth * 3 3) ~3) * bm.bmHeight; std::vectorBYTE buf(dataSize); BITMAPINFO bmi { bi, 0 }; GetDIBits(hdc, bmp, 0, bm.bmHeight, buf.data(), bmi, DIB_RGB_COLORS); // 写BITMAPFILEHEADER BITMAPINFOHEADER 像素数据 // 注意BMP文件的像素行是从下往上存的负高度反过来 // 省略文件写入细节核心是上面这两步数据提取 return true; }保存文件有几个容易让人翻车的细节一是biHeight取负数写成正数会导致图像上下颠倒二是行字节数必须是4的倍数也就是((width*33)~3)那一步很多黑白照片转BMP发绿就是因为没做行对齐三是GetDIBits拿到的数据格式取决于biBitCount24位是最稳妥的RGB顺序但文件体积大如果你想压体积后面要自己换成PNG或JPEG编码。在真正的画图工具里与其自己写BMP文件头我一般建议直接引入GDI的保存接口两三行就能输出PNG省去自己处理文件头的琐碎。但理解GetDIBits仍然有价值因为GDI底层做的事情和这段代码完全一样。如果你后面想做滤镜、选区、边缘检测像素数据早晚在你手里过一遍。5. 避坑清单窗口闪烁、VC运行库与文件权限的真实排错现场5.1 拉伸窗口后整块画布变黑系统并不知道你的画布是个位图现象窗口画了一堆线后用鼠标拉大拉伸过程中窗口区域黑一块白一块拉完线也没了。原因窗口类没有声明CS_HREDRAW|CS_VREDRAW而WM_PAINT每次都忽略旧内容重新画系统一擦背景就只剩空白了。解决把画布内容提升为持久的HBITMAP所有鼠标绘制动作直接作用在画布上WM_PAINT只负责原封不动地贴画布大小变化时重建画布并保留旧区域内容。顺带回应一下很多人遇到这个问题喜欢到处搜索“透明窗口”“改变大小闪烁”的关键词实际上根子就是“历史内容没保存”。单独一条双缓冲只能防闪防不了丢图。5.2 换机器装不上运行库ERROR 1935卡死在“microsoft.vc80.atl”现象程序在自己电脑上跑得好好的拷到同事机器上双击没反应或者安装包执行时弹ERROR 1935. 安装程序集“Microsoft.VC80.ATL, type“win32“, version“8.0.50727”失败。原因早期用VC8VS2005编译的旧程序会引用ATL运行库目标机器若装过不完整的VC运行库版本Windows Installer会把程序集校验状态搞坏重新安装也常报这个错。解决优先改用更高版本的VC运行库比如VCRedist 2015-2022并把项目从“动态使用ATL”改成“静态链接ATL”在Visual Studio的“项目-属性-常规-ATL的使用”里选“不使用ATL”或“静态链接到ATL”。如果已经编译成了二进制最省事的临时办法是在目标机器上先卸载所有旧版VC运行库再重装最新合集。这个坑的隐蔽性在于它不属于代码逻辑问题程序本身完全正常纯粹是系统环境脏了。5.3 保存文件时SetNamedSecurityInfoW失败相对路径和ACL权限才是真元凶现象画完图点保存弹出SetNamedSecurityInfoW failed (win32 error 5)或者Access is denied但文件其实已经创建出来了只是权限信息校验失败。原因保存对话框返回的是一个相对路径或者目标目录ACL禁止修改DACL比如C:\Program Files、某些UAC保护目录当你尝试用SetNamedSecurityInfoW修改文件所有者或访问控制列表时Windows会拒绝这个操作。解决先调用GetFullPathNameW把路径转成绝对路径再检查目录写入权限如果只是想让文件能被当前用户读写别去动ACL直接用CreateFile的默认安全描述符即可。文件保存的路径选在%USERPROFILE%或GetTempPath下基本不会遇到权限问题。还有一个更隐蔽的情况文件是只读属性打开文件句柄前没去掉只读标志同样会报这个错。5.4 高分屏鼠标画线偏移DPI缩放把客户区坐标拉开现象在1366x768和4K屏上打开同一个程序鼠标点下去画的线明显偏离光标位置。原因Windows默认对GDI程序做DPI虚拟化把窗口放大显示但鼠标坐标依然按物理像素上报两边对不上于是错位。解决在程序入口调用SetProcessDPIAware或者在WM_DPICHANGED消息里手动缩放窗口尺寸。对于画图工具正确的做法是感知DPI并按真实像素工作否则画出来的图案在高分屏上会模糊。这个坑在高分屏普及之后越来越常见尤其是2024年之后新买笔记本普遍2560x1600起步不放DPI感知直接画第一笔就会让用户觉得这个工具坏了。5.5 用任务管理器当验尸官GDI对象数只增不减现象程序没闪退但运行几分钟后鼠标拖动线条变卡最后画出一些莫名其妙的黑色填色块。原因代码里每次WM_PAINT都创建画笔、画刷或位图但忘了DeleteObjectGDI对象表被吃光新建对象失败后画出来的东西不可预知。解决把CreatePen、CreateSolidBrush、CreateCompatibleBitmap的释放习惯固定成“用局部变量RAII包装或者函数结束前统一DeleteObject”。排查方式是打开任务管理器切到“详细信息”标签页选中进程后加出“GDI对象”列反复拖动鼠标绘制看计数是不是只增不减。这是最直观也最靠谱的验证方法比肉眼盯屏幕瞎猜强得多。很多老程序员把GDI对象泄漏当玄学其实原因就是贪图省事少写了释放代码。6. 把橡皮擦做成第七种绘图模式顺便用三秒验证法检查双缓冲和资源回收橡皮擦在双缓冲架构下很简单——不需要真的去擦除像素只要把画笔颜色设成背景色然后在画布上画一个矩形视觉上就完成了擦除。但如果用户后续画了新线条擦除区域会保留一片纯色无法还原下面的内容。真正的擦除需要把图形对象的访问权移交到历史栈里做“层叠撤销”这个复杂度超过了一篇笔记的范围。常见做法是先用“背景色画笔矩形覆盖”实现一个粗糙版再改成把擦除区域作为一条“擦除记录”存进图形列表重绘时跳过这些区域。对于内部小工具粗糙版已经够用。验证画图工具是否健康我有一套三秒法第一秒按住鼠标快速画一条对角线期间故意把鼠标甩出窗口再甩回来第二秒把窗口拖出屏幕边缘再拖回第三秒拉大窗口观察边缘区域是否出现白色闪动或图形消失。只要这三秒里图形始终完整、没有白屏说明双缓冲、WM_ERASEBKGND拦截、画布持久化都做对了。最后再开任务管理器看GDI对象数做一轮快速绘制如果数字稳定不掉资源回收基本没问题。这些都是我当年靠血泪经验换来的习惯尤其是“拖出边缘再拖回”这一招能立刻暴露消息捕获和重绘协调的问题。希望帮到你。本文还有配套的精品资源点击获取