资讯详情

资讯详情

从零手搓Win32画图工具:GDI双缓冲与消息循环实战

简介这是一份面向Windows编程初学者与Win32 API入门者的画图工具源码工程基于GDI实现颜色选择、自由曲线与直线绘制、椭圆矩形等基本图形、橡皮擦及图像文件保存并演示子窗口的创建、显示与消息处理机制适合作为理解窗口管理与图形设备接口的教学案例。压缩包为rar格式共87个文件约36.57MB包含9个cpp源文件与11个h头文件构成核心逻辑22个bmp与5个cur、2个ico提供界面与绘图素材另有sln、vcxproj等工程配置及exe、pdb等编译产物目录按模块划分清晰。目前已有432人学习下载。通过该工程可掌握MoveToEx、LineTo、Ellipse、Rectangle等绘图函数用法理解颜色对话框、文件读写与消息循环的协作方式为构建更复杂的Windows应用打下基础。1. 从零手搓一个 win32 简单功能的画图工具为什么它比想象中更值得做很多人第一次接触 Windows 原生开发都是从「写一个能画线的窗口」开始的。标题里的 win32 简单功能的画图工具说的就是用纯 Win32 API、不依赖 MFC 和 Qt靠一个 WinMain 加一个 WndProc把鼠标轨迹变成屏幕上的线条。它看起来像玩具但真正动手你会发现这里面藏着 GDI 对象管理、消息循环、双缓冲、坐标映射这些绕不开的基本功。适合谁适合想搞懂 Windows 窗口底层机制的人适合被框架封装太久、想回头补一课的人也适合需要一个轻量级标注小工具、不想为画几条线就拖进一整个运行时的场景。我见过太多人卡在「窗口能弹出来但一画就闪、一缩放就糊」这篇就把这些坑一个个填平。2. 窗口骨架与消息循环把第一根线画出来2.1 最小可运行窗口的四个必备部分一个能画图的 Win32 程序骨架其实就四块注册窗口类、创建窗口、跑消息循环、处理窗口过程。缺一块窗口都出不来。注册窗口类时WNDCLASS里的lpfnWndProc是灵魂所有鼠标、键盘、重绘消息都从这里进来。创建窗口用CreateWindowEx注意dwStyle里带上WS_OVERLAPPEDWINDOW否则没有标题栏和关闭按钮。消息循环用GetMessage配TranslateMessage、DispatchMessage这是标准三件套。窗口过程里至少处理WM_DESTROY调PostQuitMessage(0)不然关掉窗口进程还在后台挂着。// 最小 Win32 窗口骨架能显示但还不能画 #include windows.h LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { switch (msg) { case WM_DESTROY: PostQuitMessage(0); // 关闭窗口时退出消息循环 return 0; } return DefWindowProc(hwnd, msg, wp, lp); // 其余消息交默认处理 } int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int nCmdShow) { const wchar_t CLASS_NAME[] LSimplePaintWnd; WNDCLASS wc {}; wc.lpfnWndProc WndProc; // 消息回调入口 wc.hInstance hInst; wc.lpszClassName CLASS_NAME; wc.hCursor LoadCursor(NULL, IDC_CROSS); // 十字光标画图更顺手 RegisterClass(wc); HWND hwnd CreateWindowEx( 0, CLASS_NAME, L简单画图工具, WS_OVERLAPPEDWINDOW, // 标准可缩放窗口 CW_USEDEFAULT, CW_USEDEFAULT, 900, 600, NULL, NULL, hInst, NULL); ShowWindow(hwnd, nCmdShow); MSG msg; while (GetMessage(msg, NULL, 0, 0)) { // 阻塞取消息 TranslateMessage(msg); DispatchMessage(msg); } return 0; }逻辑说明WndProc是回调系统把消息投递进来你只处理关心的其余DefWindowProc兜底。参数上hCursor设成IDC_CROSS是为了画图时定位准默认箭头在像素级操作时很难受。CreateWindowEx的宽高是窗口外框尺寸客户区会小一圈后面画图坐标要以客户区为准别用外框尺寸算。2.2 用 WM_LBUTTONDOWN / WM_MOUSEMOVE 采集轨迹画线的本质是记录鼠标按下到抬起之间的点序列。按下时记起点移动时把上一个点和当前点连成线段抬起时结束这一笔。这里有个关键选择是每次移动都InvalidateRect触发重绘还是直接在当前 DC 上画。前者干净但闪后者快但窗口被遮挡后内容就没了。常见做法是维护一个线段列表重绘时统一画这样窗口最小化再恢复内容还在。// 在 WndProc 里追加鼠标处理配合一个全局线段容器 #include vector struct Segment { POINT a, b; }; static std::vectorSegment g_lines; // 所有已画线段 static bool g_drawing false; static POINT g_last {}; case WM_LBUTTONDOWN: g_drawing true; g_last.x GET_X_LPARAM(lp); // 取客户区坐标 g_last.y GET_Y_LPARAM(lp); SetCapture(hwnd); // 捕获鼠标拖出窗口也不丢 return 0; case WM_MOUSEMOVE: if (g_drawing) { POINT cur { GET_X_LPARAM(lp), GET_Y_LPARAM(lp) }; g_lines.push_back({ g_last, cur }); // 记录这一段 g_last cur; InvalidateRect(hwnd, NULL, FALSE); // 请求重绘FALSE 不擦背景 } return 0; case WM_LBUTTONUP: g_drawing false; ReleaseCapture(); // 释放鼠标捕获 return 0;逻辑说明GET_X_LPARAM/GET_Y_LPARAM从lParam里拆出有符号坐标比手动位运算安全。SetCapture保证鼠标拖出客户区仍能收到移动消息否则一划出边界线就断了。InvalidateRect第三个参数传FALSE表示不擦除背景能明显减少闪烁但代价是旧内容不会自动清所以重绘逻辑必须自己把整幅画重画一遍。2.3 WM_PAINT 里重画全部线段重绘是画图工具的核心。WM_PAINT里先BeginPaint拿到 DC然后遍历线段列表逐条MoveToExLineTo。这里必须用BeginPaint/EndPaint配对不能用GetDC否则重绘区域校验会出问题系统会不停发WM_PAINTCPU 直接飙起来。case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); for (const auto s : g_lines) { MoveToEx(hdc, s.a.x, s.a.y, NULL); LineTo(hdc, s.b.x, s.b.y); } EndPaint(hwnd, ps); return 0; }逻辑说明BeginPaint会自动处理无效区域EndPaint负责校验。线段列表是唯一数据源屏幕只是它的投影这个思路和现代前端「状态驱动视图」是一回事。参数上MoveToEx的第四个参数传NULL表示不关心旧位置画线场景够用。3. 双缓冲与 GDI 对象管理让画面不闪、内存不漏3.1 闪烁的根因与内存 DC 方案上一章用InvalidateRect(FALSE)只是缓解真正要根治闪烁得靠双缓冲。原理是先在内存里建一块和客户区等大的位图所有绘制先画到内存 DC画完一次性BitBlt到屏幕 DC。这样屏幕只被整体刷新一次不会出现「擦背景—画线—再擦」的中间态。内存 DC 和位图要在窗口尺寸变化时重建否则拉伸会糊。// 双缓冲绘制替换原来的 WM_PAINT case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); RECT rc; GetClientRect(hwnd, rc); int w rc.right - rc.left, h rc.bottom - rc.top; HDC memDC CreateCompatibleDC(hdc); // 内存 DC HBITMAP memBmp CreateCompatibleBitmap(hdc, w, h); // 等大位图 HBITMAP oldBmp (HBITMAP)SelectObject(memDC, memBmp); FillRect(memDC, rc, (HBRUSH)GetStockObject(WHITE_BRUSH)); // 白底 for (const auto s : g_lines) { MoveToEx(memDC, s.a.x, s.a.y, NULL); LineTo(memDC, s.b.x, s.b.y); } BitBlt(hdc, 0, 0, w, h, memDC, 0, 0, SRCCOPY); // 一次性贴到屏幕 SelectObject(memDC, oldBmp); // 恢复否则位图无法释放 DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hwnd, ps); return 0; }逻辑说明CreateCompatibleDC建的内存 DC 默认只有 1x1 单色位图必须SelectObject换上等大位图才能画。SelectObject返回旧对象用完必须换回去再DeleteObject这是 GDI 的铁律。参数上BitBlt的SRCCOPY表示直接拷贝够用如果要做半透明叠加才需要AlphaBlend。3.2 GDI 对象泄漏的排查方法GDI 对象泄漏是 Win32 画图最阴的坑。每Create一个 Pen、Brush、Bitmap、DC系统都记账默认进程上限一万个超了Create直接返回 NULL界面就画不出来了。排查靠任务管理器加一列「GDI 对象」画几百笔看这个数是不是只涨不跌。正常应该稳定在一个小范围。血泪经验是凡是Create出来的路径上每条 return 之前都要Delete包括异常分支。// 用 RAII 包一层避免手写 Delete 漏掉 class GdiObj { HGDIOBJ h_; public: explicit GdiObj(HGDIOBJ h) : h_(h) {} ~GdiObj() { if (h_) DeleteObject(h_); } operator HGDIOBJ() const { return h_; } GdiObj(const GdiObj) delete; GdiObj operator(const GdiObj) delete; }; // 用法GdiObj pen(CreatePen(PS_SOLID, 2, RGB(0,0,0)));逻辑说明把DeleteObject放进析构函数函数无论从哪条路径退出都会释放。参数上禁掉拷贝构造和赋值防止两个对象管同一个句柄导致重复删除。这套写法在 C 里很常见Win32 项目里尤其值得因为 GDI 句柄不像内存那样有现成的智能指针。3.3 画笔与颜色的动态切换画图工具总得能换颜色和粗细。做法是维护当前HPEN在WM_LBUTTONDOWN时SelectObject到 DC 上。注意每次换笔要把旧笔换回来否则旧笔一直挂在 DC 上删不掉。颜色用RGB(r,g,b)宏粗细是CreatePen的第二个参数单位是像素。static HPEN g_pen nullptr; static int g_width 2; static COLORREF g_color RGB(0, 0, 0); void ResetPen() { if (g_pen) DeleteObject(g_pen); g_pen CreatePen(PS_SOLID, g_width, g_color); } // 绘制时 HGDIOBJ old SelectObject(memDC, g_pen); // ... 画线 ... SelectObject(memDC, old); // 换回旧笔逻辑说明ResetPen先删旧再建新避免句柄堆积。绘制时SelectObject返回的旧笔必须换回否则g_pen被 DC 占用DeleteObject会失败。参数上PS_SOLID是实线想要虚线用PS_DASH但虚线只在宽度为 1 时有效这是 GDI 的历史限制宽度大于 1 的虚线得自己用LineTo拼。4. 避坑与常见问题那些让画图工具翻车的细节4.1 窗口一缩放内容就错位现象拖动窗口边缘改变大小之前画的线跑到别的位置或者被裁掉。原因线段坐标是客户区坐标窗口变大后客户区原点没变但如果你在WM_SIZE里没做任何处理重绘时用的还是旧坐标视觉上就偏了。解决要么在WM_SIZE里按比例缩放所有线段坐标要么干脆把线段存在一个固定逻辑坐标系里绘制时做映射。简单工具一般选后者逻辑坐标固定 1920x1080绘制时按当前客户区算缩放比。4.2 鼠标拖快了线变成折线现象快速画弧线时轨迹是一段段直线拼的不够顺。原因WM_MOUSEMOVE的采样率有限系统不会为每个像素都发消息。解决在相邻两点之间做插值或者用GetMouseMovePointsEx取历史点。多数简单工具插值就够了按距离等分补几个中间点视觉上就顺了。注意插值别过度否则点太多重绘变慢。4.3 最小化再恢复内容全没了现象窗口最小化再还原画的东西消失。原因最小化时系统可能丢弃窗口内容还原时发WM_PAINT如果你的绘制依赖「当前 DC 上已有的内容」而不是线段列表就会丢。解决坚持「线段列表是唯一数据源」WM_PAINT永远从列表重画。这也是为什么第 2 章就强调用容器存线段而不是直接画在屏幕上。4.4 高 DPI 屏上线条发虚现象在缩放 150% 的屏幕上线看起来模糊、粗细不对。原因程序没声明 DPI 感知系统把窗口整体拉伸像素被插值。解决在清单文件里声明dpiAware或者运行时调SetProcessDpiAwareness。声明后坐标就是物理像素线清晰但要注意客户区尺寸会随 DPI 变化布局别写死。4.5 关闭窗口后进程还在现象点了关闭按钮任务管理器里进程还在。原因WM_DESTROY里没调PostQuitMessage或者消息循环写成了while(1)死循环。解决确认WM_DESTROY里有PostQuitMessage(0)消息循环用GetMessage返回值判断返回 0 就退出。另外如果有工作线程记得在退出前通知线程结束否则主线程退了子线程还挂着。5. 进阶技巧撤销重做与把画布存成图片5.1 用命令栈实现撤销重做画图工具没有撤销用起来就提心吊胆。实现思路是把每次操作包成命令对象压进栈里。画一笔就是一条「添加线段」命令撤销时弹出并反向执行。简单场景不用搞太复杂两个栈undo 栈和 redo 栈新操作压 undo 并清空 redo撤销时从 undo 移到 redo重做反过来。struct Command { enum Type { AddLine } type; Segment seg; }; static std::vectorCommand g_undo, g_redo; void DoAddLine(const Segment s) { g_lines.push_back(s); g_undo.push_back({ Command::AddLine, s }); g_redo.clear(); // 新操作让重做失效 } void Undo() { if (g_undo.empty()) return; Command c g_undo.back(); g_undo.pop_back(); if (c.type Command::AddLine) { g_lines.pop_back(); // 反向操作删掉最后一段 g_redo.push_back(c); } } void Redo() { if (g_redo.empty()) return; Command c g_redo.back(); g_redo.pop_back(); if (c.type Command::AddLine) { g_lines.push_back(c.seg); g_undo.push_back(c); } }逻辑说明命令模式的好处是撤销逻辑和绘制逻辑解耦以后加「填充」「画圆」只要扩Type和对应反向操作。参数上g_redo.clear()是关键很多撤销实现忘了这步导致撤销后再画一笔重做栈里还是旧内容行为就乱了。栈深度建议设个上限比如 200 步防止长时间使用内存涨太多。5.2 把客户区内容导出成 BMP画完想保存最直接的是把客户区抓成位图存文件。用CreateCompatibleDC建内存 DCBitBlt把屏幕内容拷进去然后按 BMP 文件格式写头。BMP 头结构固定BITMAPFILEHEADER加BITMAPINFOHEADER注意行数据要 4 字节对齐不对齐文件打不开。bool SaveToBmp(HWND hwnd, const wchar_t* path) { RECT rc; GetClientRect(hwnd, rc); int w rc.right, h rc.bottom; HDC hdc GetDC(hwnd); HDC memDC CreateCompatibleDC(hdc); HBITMAP bmp CreateCompatibleBitmap(hdc, w, h); HBITMAP old (HBITMAP)SelectObject(memDC, bmp); BitBlt(memDC, 0, 0, w, h, hdc, 0, 0, SRCCOPY); // 准备 BITMAPINFO负高度表示自上而下 BITMAPINFO bi {}; bi.bmiHeader.biSize sizeof(BITMAPINFOHEADER); bi.bmiHeader.biWidth w; bi.bmiHeader.biHeight -h; // 负值行序从上到下 bi.bmiHeader.biPlanes 1; bi.bmiHeader.biBitCount 24; bi.bmiHeader.biCompression BI_RGB; int stride ((w * 3 3) / 4) * 4; // 4 字节对齐 std::vectorBYTE buf(stride * h); GetDIBits(memDC, bmp, 0, h, buf.data(), bi, DIB_RGB_COLORS); // 写文件头 信息头 像素数据 BITMAPFILEHEADER fh {}; fh.bfType 0x4D42; // BM fh.bfOffBits sizeof(fh) sizeof(bi.bmiHeader); fh.bfSize fh.bfOffBits (DWORD)buf.size(); HANDLE f CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); if (f INVALID_HANDLE_VALUE) return false; DWORD written; WriteFile(f, fh, sizeof(fh), written, NULL); WriteFile(f, bi.bmiHeader, sizeof(bi.bmiHeader), written, NULL); WriteFile(f, buf.data(), (DWORD)buf.size(), written, NULL); CloseHandle(f); SelectObject(memDC, old); DeleteObject(bmp); DeleteDC(memDC); ReleaseDC(hwnd, hdc); return true; }逻辑说明biHeight设负值让GetDIBits按自上而下返回省得后面翻转。stride按 4 字节对齐是 BMP 格式硬要求算错文件就损坏。参数上biBitCount用 24 位不带调色板兼容性最好。写文件用CreateFileW支持中文路径WriteFile依次写文件头、信息头、像素数据顺序不能乱。5.3 一个我常犯的错我早期写这类工具总喜欢在WM_MOUSEMOVE里直接GetDC画线图省事。结果窗口一被遮挡内容就没了还以为是系统 bug。后来才明白Win32 的绘制必须围绕WM_PAINT组织任何「直接画在屏幕上」的捷径都会在重绘时露馅。现在我的习惯是只要涉及持久显示一律先改数据再InvalidateRect让WM_PAINT统一负责渲染。这个习惯帮我省了无数排查时间。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →