2026/9/7 1:48:57

纯Win32 GDI实战:从零构建一个极简画图工具

纯Win32 GDI实战:从零构建一个极简画图工具 简介一份基于Win32 API实现的简易画图工具完整工程适合初学Windows编程或想理解GDI图形接口原理的开发者。项目涵盖了窗口消息循环与子窗口管理、颜色选择对话框、通过移动和画线函数绘制线条、利用椭圆和矩形函数绘制基本图形、使用透明画笔实现橡皮擦以及调用文件创建和写入函数保存图像等关键实现是学习Win32常见接口的良好范例。资源以rar压缩包发布共87个文件大小约36.57MB包含C源码cpp/h、Visual Studio工程配置sln/vcxproj、位图/图标/光标等图形资源、编译中间文件及可执行程序同时附带ReadMe文本说明可在Visual Studio环境中打开运行或直接体验exe效果。目前已有430人学习下载对入门阶段的Win32开发者而言这套带完整源码与运行程序的资源具有较好参考价值既能临摹核心代码也能直接运行观察窗口绘制与消息响应过程。 写画图工具这种题目放在十年前几乎是每个Windows C初学者的必修课但现在很多人一听到“Win32 API”就本能地觉得过时、繁琐宁愿去拖个Qt或者Electron。我想说的是如果你只是想做一个单文件、双击就能跑、不依赖任何运行时的像素级画图小工具纯Win32 GDI其实是最舒服的路线。没有庞大的依赖树没有跨平台抽象层的额外开销你能直接触摸到消息循环、设备上下文、位图填充这些Windows桌面程序的原始骨架。这篇内容会从零搭一个“能画线、能选色、能橡皮擦、能保存BMP”的极简画图工具顺带把我实际编码时踩过的坑全部摊开来讲。1. 我为什么要用纯Win32写画图工具而不是Qt或Electron先说个真实场景我在一台配置很一般的旧笔记本上需要快速做一个临时画板用来标注图片里的问题区域。当时机器上没有Visual Studio只有MinGW和Windows SDK里的头文件与库。若用Qt光下载安装Qt Creator和对应编译器就得折腾半天Electron更是离谱为了画个矩形可能要占掉300MB内存。可是Win32 API是系统自带的只要编译器能调user32.dll和gdi32.dll就能在几分钟内编译出一个独立exe。在决定用纯Win32之前我其实也考虑过低层方案比如Direct2D。Direct2D的渲染性能确实好但它是COM接口初始化流程长而且对于“画点画线”这种简单需求是杀鸡用牛刀。GDI虽然基于旧式保留模式但画直线、画矩形、填充颜色、位块传输这些基础操作都是一行函数调用的事完全匹配我们的需求。还有一个容易被忽略的理由可调试性。Win32程序的消息循环是单线程的出现问题可以用OutputDebugString或者直接断点停在某个消息处理分支里不需要像Electron那样在主进程和渲染进程之间来回猜。鼠标事件的坐标直接就在lParam里画画逻辑跟系统交互的耦合度极低出了问题基本一眼就能定位。再说说这个工具应该具备的“简单功能”。我理解中的简单不等于简陋而是核心路径清晰鼠标左键落下开始画移动时连续绘制弹起时结束一笔右侧有几个颜色按钮和粗细选项橡皮就是白颜色加粗笔触清屏就是把画布填充白色最后能把当前画布保存成BMP文件。这些功能恰好都落在GDI的舒适区里不需要任何第三方库。2. 先把窗口和画布这条地基打好画图工具肯定需要一个窗口窗口里要有一块和客户区大小一致的画布。这里最关键的两个决策是画布用什么数据结构存储以及如何避免重绘时画面闪烁。2.1 用HBITMAP内存位图当画布而不是记录一堆线段新手很容易把画图程序写成“在WM_PAINT里重放所有鼠标轨迹”比如维护一个std::vectorLine每次WM_PAINT都从头把所有线用LineTo重画一遍。这在笔画少时没问题但画满几十条线后会明显卡顿而且窗口尺寸改变时坐标还要换算。更糟糕的是如果画笔颜色和橡皮擦混用橡皮擦效果很难通过重放实现——因为橡皮擦的本质是用背景色覆盖而不是删除之前的线。更靠谱的方案是维护一张内存位图HBITMAP所有绘制操作直接在这张位图上进行WM_PAINT时只需要用BitBlt把位图从内存DC复制到窗口DC。这样可以做到每条笔画一画完就立刻“固化”在画布上无论窗口怎么刷新、最小化再恢复画布内容都不会丢。创建位图时要注意尺寸。如果固定用CreateCompatibleBitmap创建但窗口支持改变大小位图尺寸就得跟着变。我为了简单把画布固定为800x600像素窗口客户区也固定为这个大小。这样只需要在初始化时创建一次位图省去后续大量麻烦。// 窗口初始化时创建画布 HDC hdcWindow GetDC(hwnd); hdcMem CreateCompatibleDC(hdcWindow); hBitmap CreateCompatibleBitmap(hdcWindow, CANVAS_WIDTH, CANVAS_HEIGHT); SelectObject(hdcMem, hBitmap); // 清空为白色 HBRUSH whiteBrush CreateSolidBrush(RGB(255, 255, 255)); RECT rc { 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT }; FillRect(hdcMem, rc, whiteBrush); DeleteObject(whiteBrush); ReleaseDC(hwnd, hdcWindow);注意CreateCompatibleDC创建的内存DC默认位图是1x1像素必须SelectObject把我们的位图选进去。在初始化和结束时需要保存原始的HGDIOBJ并在清理时恢复不过我这种一次性程序不强求。2.2 双缓冲别让屏幕在你眼前闪烁如果直接在WM_PAINT里画一堆图形GDI每画一个图形窗口上就会被部分修改显示过程会产生明显的闪烁。解决方法是先在内存DC里绘制好完整的一帧再用BitBlt一次性把整块像素推送到窗口DC。这里我们画布本身就在内存里所以WM_PAINT几乎成了傻瓜操作case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); // 把画布内容一次性拷贝到窗口 BitBlt(hdc, 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT, hdcMem, 0, 0, SRCCOPY); EndPaint(hwnd, ps); } return 0;由于窗口固定为画布大小不需要考虑滚动条和拉伸这段代码足够用了。BitBlt是位块传输速度极快刷一帧800x600也就几微秒根本不会有闪烁感。2.3 注册窗口与消息循环的基本骨架Win32程序的入口都是老几样WinMain里注册窗口类、创建窗口、进入消息循环。窗口过程负责处理消息。这里不需要什么封装直白地写反而更容易理解LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_PAINT: // 上面提到的绘制逻辑 break; case WM_LBUTTONDOWN: // 左键按下开始一笔 break; case WM_MOUSEMOVE: // 鼠标移动持续画线 break; case WM_LBUTTONUP: // 左键弹起结束一笔 break; case WM_DESTROY: PostQuitMessage(0); break; } return DefWindowProc(hwnd, msg, wParam, lParam); }完整的窗口创建代码没什么特殊的注意窗口类名、窗口样式WS_OVERLAPPEDWINDOW以及用AdjustWindowRect来把客户区调成精确的800x600大小。如果忘了计算边框窗口实际客户区会比预期小画布会被裁掉。3. 鼠标移到哪里线就画到哪里事件循环里的小心机画图工具的核心交互在于鼠标轨迹如何变成画笔笔触。这个环节最容易翻车因为涉及到坐标来源、捕获时机和笔锋连续性。3.1 用WindowFromPoint还是直接用lParam函数WindowFromPoint是一个Win32 API可以用来根据屏幕坐标找窗口但我们这里根本用不到——因为鼠标消息里的lParam已经把客户区坐标给出来了。低16位是x高16位是y。直接解析就好int x GET_X_LPARAM(lParam); int y GET_Y_LPARAM(lParam);如果用了GET_X_LPARAM记得包含windowsx.h头文件。很多老代码用LOWORD(lParam)和HIWORD(lParam)但在多显示器缩放环境下可能会有符号扩展的小问题还是GET_X_LPARAM更规范。3.2 鼠标捕获别让画笔在窗口外断掉若用户按住左键快速拖出窗口外默认情况下窗口会失去鼠标消息这会导致笔画中断。解决方法是调用SetCapture(hwnd)把鼠标“捕获”到当前窗口。这样即使鼠标移出客户区窗口依然能收到WM_MOUSEMOVE。在WM_LBUTTONUP时调用ReleaseCapture()释放捕获。case WM_LBUTTONDOWN: { previousX GET_X_LPARAM(lParam); previousY GET_Y_LPARAM(lParam); isDrawing TRUE; SetCapture(hwnd); // 防止画出窗口后断线 break; } case WM_MOUSEMOVE: { if (isDrawing) { int curX GET_X_LPARAM(lParam); int curY GET_Y_LPARAM(lParam); // 在画布上从 previous 到 cur 画线 } break; }注意窗口捕获状态下鼠标移出客户区时坐标可能会超出[0, CANVAS_WIDTH]范围。如果坐标是负数或大于尺寸直接画到内存位图上是安全的——GDI画布外绘制会被裁剪不会崩溃。但更好的做法是判断一下防止接下来保存BMP时拷贝了奇怪内容。3.3 为什么鼠标移动一快画出来的线就断成点Win32的WM_MOUSEMOVE消息并不是“连续性”的而是基于鼠标中断频率与消息队列合并机制的。如果用户移动鼠标速度很快两个消息之间的坐标差可能好几像素。如果只在消息点之间画点会发现快速画时笔迹变成了虚线。正确做法是在每个WM_MOUSEMOVE事件里用MoveToEx移动到上一笔坐标再用LineTo画到当前坐标这样两点之间会自动补上直线线段。上一笔坐标在WM_LBUTTONDOWN时初始化为按下位置之后在每次处理完移动后更新。// 画布DC中设置当前颜色、线宽 SelectObject(hdcMem, hBrush); SelectObject(hdcMem, hPen); MoveToEx(hdcMem, previousX, previousY, NULL); LineTo(hdcMem, curX, curY); previousX curX; previousY curY; InvalidateRect(hwnd, NULL, FALSE);InvalidateRect会让窗口产生重绘消息从而把画布同步显示到屏幕上。传FALSE表示不擦除背景配合双缓冲能进一步减少闪烁。另一种做法是直接调用UpdateWindow强制同步重绘不过连续鼠标签出时会让消息循环变成同步阻塞反而丢消息。用InvalidateRect更合适Windows会把多次无效区域合并成一次消息。3.4 画笔与橡皮的统一模型很多人第一反应是“橡皮擦需要特殊的混合模式”其实橡皮擦就是画笔用白色画线。因为画布背景是白色只要把画笔颜色设为白色线宽调粗一些就变成了橡皮擦。当然如果以后要做透明橡皮擦就需要位图alpha通道和TransparentBlt这套复杂的流程但那是另一个课题了。我在代码里用一个BOOL isEraser标识在鼠标绘制前临时决定画笔颜色和线宽if (isEraser) { currentColor RGB(255, 255, 255); currentWidth eraserWidth; } else { currentColor selectedColor; currentWidth penWidth; }这招不需要任何额外的状态变量和代码路径是简单画图程序里最优雅的实现。4. 给工具加点实用功能换颜色、调粗细、能擦除、还能清屏有了鼠标画线的基础其他功能其实都是锦上添花。但这些“花”恰恰是工具实用性的关键。4.1 用WM_COMMAND和菜单做界面而不是堆按钮在Win32里做UI有两种常见方案按钮控件和菜单。如果我只放一个颜色选择栏可以用一排在WM_CREATE时创建的Button控件但更符合经典画图程序习惯的是菜单栏。菜单的好处是不占画布空间用键盘也能操作。在资源文件里定义菜单或者在注册窗口类后用LoadMenu加载。为了不让示例依赖资源文件我选择在.rc资源里写菜单这样代码更工程化IDR_MENU MENU BEGIN POPUP 颜色 BEGIN MENUITEM 黑色, ID_COLOR_BLACK MENUITEM 红色, ID_COLOR_RED MENUITEM 蓝色, ID_COLOR_BLUE MENUITEM 绿色, ID_COLOR_GREEN END POPUP 工具 BEGIN MENUITEM 画笔, ID_TOOL_PEN MENUITEM 橡皮, ID_TOOL_ERASER END POPUP 线宽 BEGIN MENUITEM 1px, ID_WIDTH_1 MENUITEM 3px, ID_WIDTH_3 MENUITEM 5px, ID_WIDTH_5 END POPUP 文件 BEGIN MENUITEM 保存BMP, ID_FILE_SAVE MENUITEM 清空画布, ID_FILE_CLEAR END END然后在窗口过程里处理WM_COMMAND消息。注意wParam的低16位是菜单ID高16位是通知码。对于菜单来说通知码是0直接检查LOWORD(wParam)即可。4.2 用CreatePen和SelectObject切换线宽GDI中的画笔对象决定线条颜色、宽度和样式。CreatePen可以创建宽度大于1的几何画笔也可以创建PS_SOLID样式。HPEN hPen CreatePen(PS_SOLID, currentWidth, currentColor); HGDIOBJ oldPen SelectObject(hdcMem, hPen); // 画完线后恢复 SelectObject(hdcMem, oldPen); DeleteObject(hPen);每次画线条都创建/删除画笔有点浪费但性能其实还好。我更加倾向于在设置改变时保存当前画笔对象鼠标移动时只用一次SelectObject。这个优化在简单工具里不明显不过代码结构更干净。每次鼠标按下时根据当前设置创建一个新画笔选入画布DC随后在弹起时选回旧画笔再删除新画笔。这样不会有画笔漏删的问题。4.3 清空画布不是简单的FillRect有人会写“清空就是FillRect白色”这没错。但有个细节位图可能需要兼容的设置背景色。FillRect使用画刷填充和当前画笔无关。填充完成后没必要手动调用InvalidateRect吗需要否则界面不会刷新。case ID_FILE_CLEAR: { HBRUSH white CreateSolidBrush(RGB(255, 255, 255)); RECT rc { 0, 0, CANVAS_WIDTH, CANVAS_HEIGHT }; FillRect(hdcMem, rc, white); DeleteObject(white); InvalidateRect(hwnd, NULL, FALSE); break; }这里还有一个容易忽略的坑清空前如果当前正在画一条线isDrawing还处于TRUE状态下次WM_LBUTTONUP会有多余的画线动作。清空画布前应当把isDrawing重置为FALSE否则鼠标一松开之前未完成的线又出现了。5. 保存成BMP从内存位图到硬盘文件画了半天不保存等于白画。保存BMP在Win32里有两种常见路线用GDI的GetDIBits把HBITMAP转换成DIB字节流或者采用OleLoadPicture那套COM组件。这里直接讲最底层的GetDIBits方案因为它不需要任何多余库而且能让你理解BMP文件格式的四个部分BITMAPFILEHEADER、BITMAPINFOHEADER、像素数据可选调色板。5.1 获取位图信息并写入文件保存BMP的核心步骤如下bool SaveBitmapToFile(HBITMAP hBitmap, const wchar_t* filename) { BITMAP bmp; GetObject(hBitmap, sizeof(BITMAP), bmp); BITMAPFILEHEADER bmfh { 0 }; BITMAPINFOHEADER bmih { 0 }; bmih.biSize sizeof(BITMAPINFOHEADER); bmih.biWidth bmp.bmWidth; bmih.biHeight bmp.bmHeight; bmih.biPlanes 1; bmih.biBitCount 24; // 转成24位RGB避免调色板麻烦 bmih.biCompression BI_RGB; bmih.biSizeImage ((bmp.bmWidth * 3 3) ~3) * bmp.bmHeight; bmfh.bfType 0x4D42; // BM bmfh.bfOffBits sizeof(BITMAPFILEHEADER) sizeof(BITMAPINFOHEADER); bmfh.bfSize bmfh.bfOffBits bmih.biSizeImage; HANDLE hFile CreateFileW(filename, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile INVALID_HANDLE_VALUE) return false; DWORD bytesWritten 0; WriteFile(hFile, bmfh, sizeof(bmfh), bytesWritten, NULL); WriteFile(hFile, bmih, sizeof(bmih), bytesWritten, NULL); // 申请像素缓冲区 std::vectorBYTE buffer(bmih.biSizeImage); HDC hdc GetDC(NULL); GetDIBits(hdc, hBitmap, 0, bmp.bmHeight, buffer.data(), (BITMAPINFO*)bmih, DIB_RGB_COLORS); ReleaseDC(NULL, hdc); WriteFile(hFile, buffer.data(), buffer.size(), bytesWritten, NULL); CloseHandle(hFile); return true; }这里有个重点GetDIBits要求BITMAPINFOHEADER的biHeight必须为正数表示自下而上的DIB。而BMP文件标准也采用自下而上的行序所以直接用这个顺序写入即可。如果你希望图像是自上而下就要把biHeight改为负数且GetDIBits对某些平台行为会有差异。我在实践里一般都用正数因为市面上看图工具都能正常显示。另外注意biSizeImage必须是4字节对齐的行字节数乘以高度。24位每像素3字节如果宽度是奇数行字节数不是4的倍数就要用((width * 3 3) ~3)向上对齐。上面代码已经处理了。5.2 弹窗保存文件的坑用GetSaveFileName弹系统保存对话框需要初始化OPENFILENAME结构并且要分配一个wchar_t数组存放路径。很多新手会直接传一个局部WCHAR filePath[MAX_PATH]但如果用户选择了长路径MAX_PATH可能不够导致保存失败。更稳一点的做法是用OPENFILENAMEW的lpstrFile指向一个动态分配的缓冲区比如std::vectorWCHAR。不过在本工具中我直接用固定大小也能应付绝大多数场景。由于对话框会改变当前工作目录保存完BMP之后文件路径是绝对路径影响不大。但程序里如果有相对路径的读取/写入保存操作后工作目录就变了这需要特别注意。我用的是全路径所以没事。6. 我踩过的几个坑以及事后反思写完这个工具只用了一个下午但过程里有一半时间都耗在几个老掉牙问题上。写在这里给后来人省点时间。6.1 画了一条线但屏幕上什么都没有刚开始我在WM_LBUTTONDOWN里设置currentColor在WM_MOUSEMOVE里绘制时发现线条没出现。排查后发现是hdcMem在窗口创建后的某个时刻被重新创建了而工具栏WM_COMMAND里又SelectObject了别的对象导致原来选择的位图被取消hdcMem上变成1x1的默认位图画上去实际只有1像素。解决方法是任何需要切换画笔、画刷、位图的操作都用SelectObject和恢复原对象的方式保证内存DC始终带着我们的画布位图。如果嫌麻烦可以在每个鼠标按下时把画布位图重新Select一次。6.2 GetDIBits保存的BMP上下颠倒第一次保存BMP后用看图软件打开发现图像是上下颠倒的。原因是BMP文件行序是自下而上而GDI内存位图的坐标原点是左上角。虽然这符合标准但如果你直接拿视觉坐标去对应会觉得“反了”。这个其实不是问题反倒是我自己在读取像素时的坐标转换产生了误解。正确做法就是照上面代码保存不需要额外翻转看图软件会自动正确处理。6.3 鼠标移动过快导致轨迹撕裂即使用了LineTo补线还是会在快速移动时出现轨迹不连续。后来发现不是我画线的问题而是消息循环里InvalidateRect之后立刻又收到大量WM_MOUSEMOVE有些WM_PAINT被合并但绘制使用的仍是旧坐标。解决办法是尽量在WM_MOUSEMOVE里只在内存位图上画不依赖窗口DC来逐点显示。由于InvalidateRect后系统在空闲时才会处理WM_PAINT所有绘制逻辑都集中在WM_PAINT里BitBlt过去就不会出现撕裂。6.4 别忽略DPI缩放如果你在125%缩放的屏幕上运行程序GetSystemMetrics(SM_CXSCREEN)之类的值会受到影响窗口可能被拉伸。但客户区坐标lParam仍然以逻辑像素为单位GDI默认不感知DPI位图尺寸也保持800x600所以画面会模糊。更彻底的方案是在程序入口调用SetProcessDPIAware()让GDI按物理像素绘制。我在工具里加了这句画面立刻锐利多了。SetProcessDPIAware();只要记住加上它以后所有坐标都按物理像素算对于固定画布尺寸的程序没什么副作用。6.5 程序退出时别忘了释放对象HBITMAP、HDC、画笔、画刷这些GDI对象不释放进程结束后系统会回收但如果是长驻程序就会发生GDI泄漏。在这个简单工具里按顺序先释放画笔等临时对象再恢复内存DC的原对象最后删除hBitmap和hdcMem。哪怕忘了也不至于蓝屏但好习惯还是要养成。6.6 关于SetNamedSecurityInfo那种报错离画图工具十万八千里搜索资料时有人可能会看到setnamedsecurityinfow failed (win32 5): grantwrite这类报错它多半出现在文件权限管理、服务程序或安装包场景与画图工具无关。如果你在写Win32程序时碰到这类安全描述符相关的错误大概率是权限配置或对象名传错的问题不要和GDI绘图搞混。最后分享一个我用了很久的小技巧画布位图保存为BMP之后如果还想快速输出成PNG或JPG不必引入图像库。你可以调用GDI的Bitmap类封装HBITMAP再用Save方法转码。GDI初始化只需要几行代码但这是另一个话题了。至少在这个纯Win32画图工具里BMP已经足够用——后续不管你是想接OCR、做标注、还是临时涂鸦都能无缝衔接。我个人在实际操作里的体会是越是简单的工具越能逼你把最底层的机制搞明白。写Qt或Electron时你不需要关心设备上下文不需要了解GDI对象生命周期但一旦环境里没有那些框架帮倒忙反而能更清醒地看到“画一条线”背后到底发生了什么。这个800行不到的Win32程序至今还是我在Windows机器上最顺手的一个效率工具。本文还有配套的精品资源点击获取