纯Win32 GDI实战:从零构建一个极简画图工具
2026/9/7 1:48:55 网站建设 项目流程

简介:一份基于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.dllgdi32.dll,就能在几分钟内编译出一个独立exe。

在决定用纯Win32之前,我其实也考虑过低层方案,比如Direct2D。Direct2D的渲染性能确实好,但它是COM接口,初始化流程长,而且对于“画点画线”这种简单需求是杀鸡用牛刀。GDI虽然基于旧式保留模式,但画直线、画矩形、填充颜色、位块传输这些基础操作都是一行函数调用的事,完全匹配我们的需求。

还有一个容易被忽略的理由:可调试性。Win32程序的消息循环是单线程的,出现问题可以用OutputDebugString或者直接断点停在某个消息处理分支里,不需要像Electron那样在主进程和渲染进程之间来回猜。鼠标事件的坐标直接就在lParam里,画画逻辑跟系统交互的耦合度极低,出了问题基本一眼就能定位。

再说说这个工具应该具备的“简单功能”。我理解中的简单不等于简陋,而是核心路径清晰:鼠标左键落下开始画,移动时连续绘制,弹起时结束一笔;右侧有几个颜色按钮和粗细选项;橡皮就是白颜色加粗笔触;清屏就是把画布填充白色;最后能把当前画布保存成BMP文件。这些功能恰好都落在GDI的舒适区里,不需要任何第三方库。

2. 先把窗口和画布这条地基打好

画图工具肯定需要一个窗口,窗口里要有一块和客户区大小一致的画布。这里最关键的两个决策是:画布用什么数据结构存储,以及如何避免重绘时画面闪烁。

2.1 用HBITMAP内存位图当画布,而不是记录一堆线段

新手很容易把画图程序写成“在WM_PAINT里重放所有鼠标轨迹”,比如维护一个std::vector<Line>,每次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::vector<BYTE> 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要求BITMAPINFOHEADERbiHeight必须为正数,表示自下而上的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可能不够,导致保存失败。更稳一点的做法是用OPENFILENAMEWlpstrFile指向一个动态分配的缓冲区,比如std::vector<WCHAR>。不过在本工具中,我直接用固定大小也能应付绝大多数场景。

由于对话框会改变当前工作目录,保存完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的原对象,最后删除hBitmaphdcMem。哪怕忘了也不至于蓝屏,但好习惯还是要养成。

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机器上最顺手的一个效率工具。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询