简介:面向Windows编程初学者与Win32 API入门者的简易画图工具工程,围绕GDI绘图、颜色选择、橡皮擦、图像保存及子窗口消息处理等基础能力展开,适合作为课程实验、自学练手或桌面绘图功能原型的参考案例。压缩包共87个文件,约36.57MB,包含9份cpp与11份h源码及头文件、22个bmp图像素材、5个cur光标与2个ico图标资源,并附带sln、vcxproj工程配置、rc资源脚本、exe可执行文件及pdb调试符号等,便于直接编译运行、断点调试或替换素材二次开发。已有433人学习下载。读者可从中获得一套结构完整的Win32画图程序源码,借由MoveToEx、LineTo、Ellipse、Rectangle等绘图调用理解GDI坐标与刷新机制;还可通过颜色选择对话框、透明画笔擦除、BMP/PNG保存流程和窗口消息循环掌握文件操作与交互处理思路,对后续构建更复杂的Windows桌面应用具有教学与借鉴价值。
1. 从“能画线”到“能交差”:这个 Win32 画图工具到底能画什么
前段时间,某实验室的 A 同学找到我,说他们团队要给一批截图做标注,装个商业绘图软件太重,问我能不能用 Win32 写个画图小工具。我给的是一份纯 SDK 实现:一个 exe,不到两千行 C++,没有任何第三方依赖,双击就能画直线、矩形、椭圆、自由笔迹,能取色、调线宽、撤销重做,最后把整块画布存成 BMP。
这类 win32 画图 工具的价值不在于功能多,而在于它把 GDI 这套东西完整摊开给你看——画笔、画刷、离屏位图、鼠标捕获、消息循环、窗口重绘,全部集中在一个能编译能跑的窗口里,没有框架帮你藏。
它适合两类人:一类是想搞明白 Windows 桌面程序到底怎么跑起来的新手,照着改两天就有手感;另一类是需要一个轻量标注或白板底座的熟手,代码干净、结构分层清楚,往上加箭头、加文字、加马赛克都不费劲。
2. 窗口骨架与消息循环:Win32 画图工具的运行主线
Win32 程序没有“页面”也没有“组件”,它只有一件事:窗口过程不停地收消息、回消息。画图工具的所有行为——按下鼠标开始一笔、松开鼠标结束一笔、窗口被遮挡后重新显示——最终都归结为WndProc里某个case分支的返回值。把这条主线理清楚,后面加什么功能都是往这张 switch 表里插分支。
2.1 从 WinMain 到 CreateWindowEx:一个窗口的诞生
入口函数只干三件事:注册窗口类、创建窗口、跑消息循环。别看它短,几个参数选错了后面全是坑,比如忘了CS_HREDRAW | CS_VREDRAW,窗口拉大时客户区不会自动重绘,画布边上就会留一条白带。
// main.cpp —— 程序入口:注册窗口类、建窗口、跑消息循环 int WINAPI wWinMain(HINSTANCE hInst, HINSTANCE, LPWSTR, int nCmdShow) { WNDCLASSEXW wc = { sizeof(wc) }; wc.style = CS_HREDRAW | CS_VREDRAW; // 宽高变化时强制整窗重绘 wc.lpfnWndProc = WndProc; // 消息回调,全篇的核心 wc.hInstance = hInst; wc.hCursor = LoadCursor(nullptr, IDC_CROSS); // 画布上用十字光标,手感更准 wc.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH); wc.lpszClassName = L"MiniPaintWnd"; if (!RegisterClassExW(&wc)) return 0; HWND hwnd = CreateWindowExW( 0, wc.lpszClassName, L"MiniPaint", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 1024, 720, nullptr, nullptr, hInst, nullptr); ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); // 立刻走一次 WM_PAINT,别等系统施舍 MSG msg; // GetMessageW 返回 0 表示收到 WM_QUIT,返回 -1 才是真出错,两者要分开处理 while (GetMessageW(&msg, nullptr, 0, 0) > 0) { TranslateMessage(&msg); // 把 WM_KEYDOWN 翻译成 WM_CHAR DispatchMessageW(&msg); // 派发回 WndProc } return (int)msg.wParam; }参数里最值得说的是hbrBackground。设成白刷子,系统会在WM_ERASEBKGND阶段帮你刷底色,省掉一次自己的填充;但如果你后面用了双缓冲整块贴图,这次擦除就是纯浪费,还会带来一次可见的闪白。我的做法是把它设成nullptr,在OnPaint里自己刷白,可控性更好。
GetMessageW的返回值判断也常被写错。很多人写成while (GetMessage(...)),看起来没问题,可一旦消息循环出错返回 -1,while(-1)是真值,程序就死循环了。写成> 0才稳妥。
2.2 消息泵与 WndProc:所有交互都从这张 switch 表进来
窗口过程是整个项目的调度中心。画图工具需要关心的消息其实就那么几条:WM_CREATE做初始化,鼠标三个事件驱动一笔的生死,WM_PAINT负责把文档模型刷到屏幕上,WM_SIZE重建离屏位图,WM_DESTROY收尾。
// wndproc.cpp —— 消息分发,只做路由,具体逻辑都甩给 document/render 模块 LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { switch (msg) { case WM_CREATE: CreateToolbar(hwnd); // 顶部工具按钮 DocInit(hwnd); // 初始化文档模型、离屏位图、撤销栈 return 0; case WM_LBUTTONDOWN: DocBeginStroke(hwnd, GET_X_LPARAM(lp), GET_Y_LPARAM(lp)); SetCapture(hwnd); // 关键:把鼠标"锁"在本窗口,拖出边界也收得到消息 return 0; case WM_MOUSEMOVE: if (DocIsDrawing()) DocExtendStroke(hwnd, GET_X_LPARAM(lp), GET_Y_LPARAM(lp)); return 0; case WM_LBUTTONUP: DocEndStroke(hwnd); ReleaseCapture(); // 和 SetCapture 必须成对 return 0; case WM_PAINT: OnPaint(hwnd); return 0; case WM_SIZE: DocResize(hwnd, LOWORD(lp), HIWORD(lp)); return 0; case WM_ERASEBKGND: return 1; // 告诉系统"我擦过了",避免双缓冲之外的多余闪白 case WM_DESTROY: DocDestroy(); PostQuitMessage(0); return 0; } return DefWindowProc(hwnd, msg, wp, lp); // 没处理的一律还给系统 }这段代码里有两个细节值得单独记一下。第一个是GET_X_LPARAM/GET_Y_LPARAM,鼠标坐标是有符号的 16 位值,直接LOWORD(lp)拿到的类型是WORD,鼠标移到客户区左侧或上方(拖拽时很常见)会变成六万多的巨大正数,画出来的线直接飞出屏幕。第二个是SetCapture,不加它的话,用户按下鼠标往窗口外一拖再松手,WM_LBUTTONUP就收不到了,这一笔永远结束不了,状态机卡死在“绘制中”。
提示:
SetCapture之后如果程序在别的路径上崩了或者提前 return,一定记得补ReleaseCapture,否则整个系统的鼠标消息都被你这个窗口吃掉,用户会以为电脑死机了。
2.3 目录结构与编译命令:把七个文件拼成一个 exe
项目不大,但我还是按职责拆了文件。全塞一个main.cpp里也能跑,可一旦要加图层或者换渲染后端,两三千行的单文件会让人想砸键盘。
| 文件 | 职责 | 大致行数 |
|---|---|---|
| main.cpp | 入口、窗口类注册、消息循环 | 80 |
| wndproc.cpp | 消息分发、鼠标事件、WM_PAINT 入口 | 300 |
| document.h / document.cpp | Stroke 模型、撤销栈、重绘调度 | 250 |
| render.cpp | GDI 绘制、双缓冲、橡皮筋预览 | 200 |
| toolbar.cpp | 工具按钮、颜色与线宽控件 | 180 |
| bmpio.cpp | BMP 序列化与读回校验 | 120 |
| resource.rc | 图标、菜单、加速键(Ctrl+Z 等) | 60 |
装好 Visual Studio 的 C++ 桌面开发组件后,开一个“开发人员命令提示”,在源码目录下直接编译:
cl /nologo /EHsc /std:c++17 /DUNICODE /D_UNICODE ^ main.cpp wndproc.cpp document.cpp render.cpp toolbar.cpp bmpio.cpp ^ /link /SUBSYSTEM:WINDOWS ^ user32.lib gdi32.lib comdlg32.lib shell32.lib/SUBSYSTEM:WINDOWS告诉链接器入口是wWinMain而不是控制台main,不加这个会报LNK2019。/DUNICODE /D_UNICODE必须显式给,否则CreateWindowExW那类显式带 W 的调用能过,但TCHAR系列宏会退回 ANSI,中文路径就踩坑了——这个问题我在第 5 章还会再说一次。链接库那四个分别对应窗口与消息(user32)、GDI 绘图(gdi32)、颜色对话框(comdlg32)、拖拽打开文件(shell32)。
想用 CMake 也行,add_executable(MiniPaint WIN32 ...)里的WIN32关键字等价于上面那个/SUBSYSTEM。
3. 绘图内核:文档模型、双缓冲与鼠标状态机
很多人写 Win32 画图,第一版都是“在 WM_MOUSEMOVE 里直接往窗口 DC 上画线”。这版能跑,但撑不过五分钟:窗口一被遮挡再露出来,画的东西全没了。根本原因是你把屏幕当成了存储介质。正确的做法是屏幕只是投影,真正的数据在内存里。
3.1 Stroke 文档模型:先存数据,再谈画
我把每一次落笔抽象成一个Stroke,整张画布就是一个std::vector<Stroke>。这个模型一旦立起来,重绘、撤销、另存为、改变窗口大小,全都变成对同一个数组的操作。
// document.h —— 图元模型,整个工具的数据地基 enum class ShapeType { Line, Rect, Ellipse, Freehand }; struct Stroke { ShapeType type; COLORREF color; // 0x00BBGGRR,注意不是 RGB 的顺序 int width; // 线宽,像素 std::vector<POINT> pts; // 自由笔迹是采样点序列;矩形/椭圆只保留首尾两点 }; struct Document { std::vector<Stroke> strokes; // 已提交的图元 Stroke pending; // 正在拖拽、尚未提交的那一笔 bool drawing = false; COLORREF curColor = RGB(0, 0, 0); int curWidth = 2; ShapeType curTool = ShapeType::Freehand; std::vector<std::vector<Stroke>> undo, redo; // 快照式撤销栈 };pts用一个vector<POINT>承载所有形状,看起来有点浪费——画矩形只需要两个点。但这样换来的是统一的扩展接口:以后要加箭头,就是首尾两点;要加多边形,就是 N 个点;要加贝塞尔,还是这串点。用一个字段通吃,比给每种形状写一套结构体省心得多。
绘制函数就是把这个模型翻译成 GDI 调用:
// render.cpp —— 一个 Stroke 到一次 GDI 绘制的翻译 void DrawStroke(HDC hdc, const Stroke& s, bool preview) { HPEN pen = CreatePen(PS_SOLID, s.width, s.color); HGDIOBJ oldPen = SelectObject(hdc, pen); HGDIOBJ oldBrush = SelectObject(hdc, GetStockObject(NULL_BRUSH)); // 空心,不填充 if (preview) SetROP2(hdc, R2_NOTXORPEN); // 橡皮筋预览:再画一次就擦掉 switch (s.type) { case ShapeType::Freehand: if (s.pts.size() > 1) Polyline(hdc, s.pts.data(), (int)s.pts.size()); break; case ShapeType::Line: if (s.pts.size() >= 2) MoveToEx(hdc, s.pts.front().x, s.pts.front().y, nullptr), LineTo(hdc, s.pts.back().x, s.pts.back().y); break; case ShapeType::Rect: { if (s.pts.size() >= 2) Rectangle(hdc, s.pts.front().x, s.pts.front().y, s.pts.back().x, s.pts.back().y); break; } case ShapeType::Ellipse: { if (s.pts.size() >= 2) Ellipse(hdc, s.pts.front().x, s.pts.front().y, s.pts.back().x, s.pts.back().y); break; } } if (preview) SetROP2(hdc, R2_COPYPEN); // 立刻恢复,别把这个状态泄漏出去 SelectObject(hdc, oldPen); SelectObject(hdc, oldBrush); DeleteObject(pen); // 谁创建谁释放,这条规矩后面会救命 }几个参数说明一下。PS_SOLID是实线样式,想要虚线换成PS_DASH就行,但注意PS_DASH只在width == 1时有效,线宽大于 1 会退化成实线——这是 GDI 的历史遗留,几乎所有新手都会撞一次。NULL_BRUSH让矩形和椭圆保持空心,如果你在这里传了GetStockObject(BLACK_BRUSH),画出来的就是实心块。
R2_NOTXORPEN是橡皮筋预览的核心:同一支笔在同一位置画两次,像素会异或回原样,等于自动擦除。这比“把整块画布重绘一遍再画预览”便宜得多,拖拽时帧率立刻上去了。用它有两个前提:预览必须画在屏幕 DC上而不是离屏位图上,且预览和擦除之间的坐标必须严格一致,所以拖拽过程中每次移动都得先擦旧的后画新的。
3.2 双缓冲:把闪烁按在地上
只解决重绘还不够。自由笔迹的绘制路径如果每次都往屏幕 DC 上逐段画,用户能看到明显的撕裂和闪烁,尤其是笔迹密集的时候。标准解法是离屏位图:所有提交的图元先画进一张内存位图,WM_PAINT时一次BitBlt整块贴过去。
// render.cpp —— 离屏位图的生命周期,跟着客户区大小走 static HBITMAP g_bmp = nullptr; static HDC g_memDC = nullptr; static int g_w = 0, g_h = 0; void DocResize(HWND hwnd, int w, int h) { if (w <= 0 || h <= 0 || (w == g_w && h == g_h)) return; HDC screen = GetDC(hwnd); if (g_memDC) { DeleteDC(g_memDC); g_memDC = nullptr; } if (g_bmp) { DeleteObject(g_bmp); g_bmp = nullptr; } g_memDC = CreateCompatibleDC(screen); g_bmp = CreateCompatibleBitmap(screen, w, h); g_w = w; g_h = h; ReleaseDC(hwnd, screen); SelectObject(g_memDC, g_bmp); // 新位图内容是未定义的,必须自己刷白,否则会看到花屏 RECT rc{ 0, 0, w, h }; FillRect(g_memDC, &rc, (HBRUSH)GetStockObject(WHITE_BRUSH)); // 尺寸变了,把已有图元整体重绘一遍 for (const auto& s : g_doc.strokes) DrawStroke(g_memDC, s, false); InvalidateRect(hwnd, nullptr, FALSE); }WM_PAINT就变得极简:
void OnPaint(HWND hwnd) { PAINTSTRUCT ps; HDC hdc = BeginPaint(hwnd, &ps); // 只贴脏区域,客户区大时能省不少填充率 BitBlt(hdc, ps.rcPaint.left, ps.rcPaint.top, ps.rcPaint.right - ps.rcPaint.left, ps.rcPaint.bottom - ps.rcPaint.top, g_memDC, ps.rcPaint.left, ps.rcPaint.top, SRCCOPY); EndPaint(hwnd, &ps); }BitBlt的后四个参数容易搞混:前四个是目标位置和目标宽高,接着是源 DC,再接着是源坐标,最后是光栅操作码。源坐标这块很多人图省事写0, 0然后把目标坐标也写0, 0,能跑,但脏区域一大就白贴了整张图。跟着ps.rcPaint走是正确写法。
还有一个坑:CreateCompatibleBitmap必须传屏幕 DC,不能传内存 DC。传内存 DC 会得到一张单色位图,画出来全是黑白——这个现象特别迷惑人,因为代码编译运行都没问题,就是颜色不对。
3.3 鼠标三事件与状态机:一笔画到底
鼠标三个事件对应一笔的三个阶段。开始的时候记下起点、往撤销栈压一份快照;移动的时候追加点、触发预览;松手的时候把pending提升为正式图元、清空预览、重绘缓存。
// document.cpp —— 一笔的完整生命周期 void DocBeginStroke(HWND hwnd, int x, int y) { PushUndo(g_doc); // 动手之前先存档,撤销才有得退 g_doc.pending = Stroke{}; g_doc.pending.type = g_doc.curTool; g_doc.pending.color = g_doc.curColor; g_doc.pending.width = g_doc.curWidth; g_doc.pending.pts.push_back(POINT{ x, y }); g_doc.drawing = true; } void DocExtendStroke(HWND hwnd, int x, int y) { POINT last = g_doc.pending.pts.back(); if (g_doc.curTool == ShapeType::Freehand) { // 距上一个点太近就丢弃,避免采样点爆炸把 vector 撑爆 if (abs(x - last.x) + abs(y - last.y) < 2) return; g_doc.pending.pts.push_back(POINT{ x, y }); } else { // 矩形/椭圆/直线只需要首尾两点,后者直接覆盖 if (g_doc.pending.pts.size() >= 2) g_doc.pending.pts.back() = POINT{ x, y }; else g_doc.pending.pts.push_back(POINT{ x, y }); } // 双缓冲 + 橡皮筋:先把缓存整块贴上去,再用 NOTXOR 叠一层预览 HDC hdc = GetDC(hwnd); BitBlt(hdc, 0, 0, g_w, g_h, g_memDC, 0, 0, SRCCOPY); DrawStroke(hdc, g_doc.pending, /*preview=*/true); ReleaseDC(hwnd, hdc); } void DocEndStroke(HWND hwnd) { g_doc.drawing = false; if (g_doc.pending.pts.size() >= 2) { DrawStroke(g_memDC, g_doc.pending, false); // 落到缓存,永久生效 g_doc.strokes.push_back(std::move(g_doc.pending)); } g_doc.pending = Stroke{}; InvalidateRect(hwnd, nullptr, FALSE); }DocExtendStroke里那个“距离小于 2 就丢弃”的判断看着不起眼,实际很关键。快速划动时 Windows 每秒能送来上百个WM_MOUSEMOVE,一个长笔迹攒下几千个点,Polyline画起来还行,但撤销栈每步都存在拷贝整份文档,内存会以肉眼可见的速度往上走。做个最小距离过滤,点数立刻降一个数量级,笔迹看起来还更顺滑。
3.4 撤销栈:整份文档快照的取舍
撤销有两种实现:命令模式(记录操作、反向执行)和快照模式(每次操作前存一份完整副本)。命令模式省内存但写起来啰嗦,每种图元都要实现一对 do/undo;快照模式写起来三行,代价是内存。这个工具图元数量通常在几十到几百之间,一个Stroke平均几十字节,快照模式完全扛得住,所以我选了后者。
// document.cpp —— 快照式撤销,简单粗暴但够用 void PushUndo(const Document& doc) { g_doc.undo.push_back(doc.strokes); // 只存图元数组,不带 UI 状态 if (g_doc.undo.size() > 30) // 只留最近 30 步,防止长时间使用吃光内存 g_doc.undo.erase(g_doc.undo.begin()); g_doc.redo.clear(); // 新操作会截断重做链,这是编辑器通用约定 } void Undo(HWND hwnd) { if (g_doc.undo.empty()) return; g_doc.redo.push_back(g_doc.strokes); g_doc.strokes = std::move(g_doc.undo.back()); g_doc.undo.pop_back(); RebuildCache(hwnd); // 全量重绘离屏位图 }注意PushUndo存的是doc.strokes而不是整个Document,后者里面包含pending、undo、redo自身,拷进去就是无限递归。这个坑我第一次写的时候就踩了,编译能过,运行时栈溢出,排查了半天。
RebuildCache就是清空离屏位图、刷白、把strokes逐条重画。图元多了会有几十毫秒的卡顿,能接受;真嫌慢的话,把快照换成“差量 + 定期全量”的混合策略就行。
4. 交互与落盘:工具栏、取色器和 BMP 保存
画得出来只是第一步,用户还得能选颜色、调线宽、把成果带走。这三件事分别对应 Win32 里的按钮控件、通用对话框和文件写入,每一块都有各自的脾气。
4.1 工具条与线宽:用按钮消息驱动状态机
工具按钮不需要什么复杂框架,一组CreateWindowExW(L"BUTTON", ...)加上BS_GROUPBOX或自绘就够。真正要注意的是按钮的父子关系:按钮的父窗口必须是主窗口,消息才会以WM_COMMAND的形式发到WndProc。
// toolbar.cpp —— 工具按钮就是普通子窗口控件 void CreateToolbar(HWND parent) { struct { const wchar_t* text; int id; } btns[] = { { L"直线", ID_TOOL_LINE }, { L"矩形", ID_TOOL_RECT }, { L"椭圆", ID_TOOL_ELLIPSE }, { L"画笔", ID_TOOL_FREE }, { L"颜色", ID_COLOR_PICK }, { L"保存", ID_FILE_SAVE }, }; int x = 8; for (const auto& b : btns) { CreateWindowExW(0, L"BUTTON", b.text, WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, x, 8, 72, 28, parent, (HMENU)(INT_PTR)b.id, (HINSTANCE)GetWindowLongPtrW(parent, GWLP_HINSTANCE), nullptr); x += 78; } // 线宽下拉框,内容固定,用 CBS_DROPDOWNLIST 防止用户乱输 HWND combo = CreateWindowExW(0, L"COMBOBOX", nullptr, WS_CHILD | WS_VISIBLE | CBS_DROPDOWNLIST | WS_VSCROLL, x, 8, 80, 200, parent, (HMENU)ID_COMBO_WIDTH, (HINSTANCE)GetWindowLongPtrW(parent, GWLP_HINSTANCE), nullptr); for (int w : { 1, 2, 4, 8, 16 }) SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)std::to_wstring(w).c_str()); SendMessageW(combo, CB_SETCURSEL, 1, 0); // 默认 2px }对应的WM_COMMAND分支:
case WM_COMMAND: switch (LOWORD(wp)) { // LOWORD 是控件 ID,HIWORD 是通知码 case ID_TOOL_LINE: g_doc.curTool = ShapeType::Line; return 0; case ID_TOOL_RECT: g_doc.curTool = ShapeType::Rect; return 0; case ID_TOOL_ELLIPSE: g_doc.curTool = ShapeType::Ellipse; return 0; case ID_TOOL_FREE: g_doc.curTool = ShapeType::Freehand; return 0; case ID_COLOR_PICK: g_doc.curColor = PickColor(hwnd, g_doc.curColor); return 0; case ID_COMBO_WIDTH: if (HIWORD(wp) == CBN_SELCHANGE) { wchar_t buf[16]{}; GetDlgItemTextW(hwnd, ID_COMBO_WIDTH, buf, 16); g_doc.curWidth = _wtoi(buf); } return 0; } break;LOWORD(wp)拿控件 ID、HIWORD(wp)拿通知码,这两个顺序写反了是经典翻车点:写反之后switch永远匹配不上,按钮点了没反应,你还以为是消息没收到。调试的时候拿 Spy++ 挂上去看一眼,有没有WM_COMMAND一目了然。
4.2 ChooseColor 取色:自定义色板那个坑
取色直接用系统通用对话框,别自己画调色板。ChooseColorW只要三四行就能弹出标准的颜色选择器,用户熟悉,还自带吸管。
// toolbar.cpp —— 系统取色器,注意色板数组的生命周期 COLORREF PickColor(HWND owner, COLORREF cur) { static COLORREF customs[16] = { 0 }; // 必须是 static 或全局,函数返回后仍要有效 CHOOSECOLORW cc{}; cc.lStructSize = sizeof(cc); cc.hwndOwner = owner; cc.rgbResult = cur; // 进去时是当前色,出来是新色 cc.lpCustColors = customs; cc.Flags = CC_FULLOPEN | CC_RGBINIT; return ChooseColorW(&cc) ? cc.rgbResult : cur; // 用户取消时保持原色 }lpCustColors指向的数组必须是static或者全局的,指向栈上局部变量是未定义行为——对话框还在显示,函数栈帧可能已经被别的东西覆盖了,表现就是自定义色块随机变成黑色。CC_RGBINIT决定对话框打开时是不是预选当前颜色,不加的话每次都是从纯黑开始,用户调完颜色再点一次会很难受。
另外别忘了COLORREF的字节序是0x00BBGGRR,不是你想当然的RRGGBB。想把COLORREF序列化成网页颜色或者写进配置文件,得手动拆字节,直接当整数打印出来颜色一定是反的。
4.3 手写 BMP:24 位、4 字节对齐、自底向上
保存这块我没引任何图像库,手写 BMP 文件头就一百来行。原因很简单:BMP 格式简单到不需要库,而且自己写一遍,GetDIBits的行为、行对齐规则、位图行序这些知识会真正变成你的。
// bmpio.cpp —— 24 位 BMP 序列化,含行对齐处理 bool SaveBmp(const wchar_t* path, HDC srcDC, int w, int h) { const int rowBytes = ((w * 24 + 31) / 32) * 4; // 每行必须补齐到 4 的整数倍 const DWORD imgBytes = (DWORD)rowBytes * h; BITMAPINFOHEADER ih{}; ih.biSize = sizeof(BITMAPINFOHEADER); ih.biWidth = w; ih.biHeight = h; // 正数 = 自底向上存储,与 GetDIBits 输出一致 ih.biPlanes = 1; ih.biBitCount = 24; ih.biCompression = BI_RGB; ih.biSizeImage = imgBytes; BITMAPFILEHEADER fh{}; fh.bfType = 0x4D42; // 'BM' fh.bfOffBits = sizeof(fh) + sizeof(ih); fh.bfSize = fh.bfOffBits + imgBytes; std::vector<BYTE> buf(imgBytes); BITMAPINFO bi{}; bi.bmiHeader = ih; // 从离屏位图里把像素捞出来,DIB_RGB_COLORS 表示不做调色板映射 if (!GetDIBits(srcDC, (HBITMAP)GetCurrentObject(srcDC, OBJ_BITMAP), 0, h, buf.data(), &bi, DIB_RGB_COLORS)) return false; // 用 CreateFileW 而不是 fopen,中文路径才不会挂 HANDLE f = CreateFileW(path, GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (f == INVALID_HANDLE_VALUE) return false; DWORD written = 0; WriteFile(f, &fh, sizeof(fh), &written, nullptr); WriteFile(f, &ih, sizeof(ih), &written, nullptr); WriteFile(f, buf.data(), imgBytes, &written, nullptr); CloseHandle(f); return true; }rowBytes那一行是整个函数的心脏。24 位色每像素 3 字节,一行 100 像素是 300 字节,不是 4 的倍数,必须补到 300→300?不是,300 已经是 4 的倍数;换个宽度,101 像素是 303 字节,得补到 304,末尾塞一个 0 字节。少了这一步,图片宽度对它 4 的倍数时能开,其他宽度一律花屏,而且花得很有规律——每行往右偏一点,像被斜着切了一刀。
biHeight给正数表示图像数据按自底向上的顺序排列,这是 BMP 的老传统。GetDIBits在biHeight为正时输出的正是这个顺序,所以文件头和数据能对上。如果这里手贱写成负数(想让它自上而下),BMP 文件头也得跟着写负数,两边不一致就是上下颠倒。
调用侧就更简单了:
case ID_FILE_SAVE: { wchar_t path[MAX_PATH]{}; OPENFILENAMEW ofn{}; ofn.lStructSize = sizeof(ofn); ofn.hwndOwner = hwnd; ofn.lpstrFilter = L"位图文件 (*.bmp)\0*.bmp\0所有文件\0*.*\0"; ofn.lpstrFile = path; ofn.nMaxFile = MAX_PATH; ofn.lpstrDefExt = L"bmp"; ofn.Flags = OFN_OVERWRITEPROMPT | OFN_PATHMUSTEXIST; if (GetSaveFileNameW(&ofn)) SaveBmp(path, g_memDC, g_w, g_h); return 0; }lpstrFilter里那串\0是双 null 结尾的字符串对,写错一个就成了乱码过滤器,这是OPENFILENAME的老毛病。lpstrDefExt也别省,用户不输扩展名的时候它帮你补上,否则存出来是个没有后缀的文件,双击打不开。
5. 避坑排查:五个最容易翻车的地方
这个工具我从第一版到能交付,踩的坑基本都集中在 GDI 和文件格式上。下面这几条都是“现象很怪、原因很傻、解决很简单”的典型,按我实际撞上的顺序列。
5.1 拖动窗口后画布全白
现象:画了几笔,把窗口拖到屏幕外再拖回来,或者拉大窗口,之前画的东西全没了,只剩一片白。
原因:图元只画在了屏幕 DC 上,没有存进任何数据结构。WM_PAINT里只有BeginPaint和EndPaint,中间什么都没干。系统在窗口被遮挡后并不保留你的像素,它只会通知你“这块脏了,你自己重画”。
解决:建立Stroke数组作为唯一数据源,WM_PAINT里按数组重绘;同时维护离屏位图作为缓存,WM_SIZE时重建并全量重绘。判断标准很简单:把WM_PAINT里的代码全部注释掉,画面应该变成纯白而不是保持原样——如果保持原样,说明你根本没用WM_PAINT在画。
5.2 鼠标画快了变成折线
现象:慢慢画圆是个圆,快速划一圈出来是个多边形,拐角全是直线段。
原因:WM_MOUSEMOVE不是每个物理采样点都会产生一条消息,系统会做合并。加上消息队列积压,实际到达你手上的点数远少于鼠标上报的点数。另外如果没调SetCapture,拖出窗口边界的那一段直接丢失。
解决:一是SetCapture必加;二是用Polyline一次性连起所有点,而不是LineTo逐段画,视觉上会平滑很多;三是接受现实,鼠标画图本来就不追求像素级精确,真需要平滑的话在DocExtendStroke里对折线做一次 Catmull-Rom 插值,把点加密,代价是计算量。我一般只做前两步。
5.3 存出来的 BMP 花屏或者上下颠倒
现象:用系统看图工具打开保存的 BMP,要么整张图斜着错位,要么上下翻转,要么干脆打不开报“文件损坏”。
原因:三个独立问题经常一起出现。第一,每行没做 4 字节对齐,宽度不是 4 的倍数时行错位;第二,biHeight的正负和GetDIBits的输出顺序没对齐,导致翻转;第三,bfSize/bfOffBits算错,文件头自相矛盾。
解决:统一用正数biHeight,让文件头和数据都按自底向上;rowBytes严格用((w * 24 + 31) / 32) * 4;写完文件之后自己再读一遍bfType和bfSize做校验。我现在养成一个习惯:保存完立刻用GetFileSize比对一下预期大小,对不上就说明哪个头部字段写错了,比等用户反馈快得多。
5.4 画十几分钟后程序变卡、颜色变黑白
现象:刚启动很流畅,画了几十笔之后越来越慢,界面上的彩色按钮逐渐变成黑白,最后CreatePen返回NULL,什么都画不出来。
原因:GDI 对象泄漏。CreatePen、CreateFont、CreateCompatibleBitmap这些内核对象的数量是有配额的(默认每个进程一万个),只创建不DeleteObject,配额耗尽后所有创建调用都会失败,系统还会把你的窗口降级成黑白渲染来警告你。
解决:规矩只有一条——谁创建谁释放,SelectObject一定要保存旧句柄并在用完时还原。DocResize里的DeleteDC/DeleteObject就是这个原因。验证方法很直接,任务管理器里加一列“GDI 对象”,画几十笔看这个数字是不是一直涨;或者代码里定时调GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS)打日志,稳定在几十个才算正常。
5.5 中文路径保存无声失败
现象:英文路径保存正常,换成带中文的目录,SaveBmp返回false,但没有任何提示,界面上什么都没发生。
原因:项目字符集是 ANSI,编译器把L"..."之外的窄字符串按本地代码页解释,CreateFileW收到的是被截断或乱码的路径,自然打不开。另一种情况是用了fopen,它在某些运行库配置下对宽字符支持不完整。
解决:工程属性里把字符集设成 Unicode,或者编译时显式加/DUNICODE /D_UNICODE;文件操作一律走CreateFileW/FindFirstFileW这类宽字符版本;OPENFILENAMEW的lpstrFile缓冲区给MAX_PATH起步,必要时上MAX_PATH * 2。我的习惯是编译脚本里把这两个宏写死,不依赖 IDE 设置,换台机器也不会出岔子。
6. 二次开发:把它改成标注器或白板的三条路子
这份代码的结构决定了它的扩展方向是可控的。Document是唯一数据源,DrawStroke是唯一的渲染入口,WndProc是唯一的事件入口——想加东西,往这三个点里插就行,不用大改。
| 想加的能力 | 改动位置 | 工作量 | 注意点 |
|---|---|---|---|
| 箭头、文字标注 | 扩展ShapeType+DrawStroke加分支 | 半天 | 文字要走DrawTextW,字体句柄别每次创建 |
| 输出 PNG | 新增pngio.cpp,用 WIC 或 GDI+ 替换SaveBmp | 半天 | WIC 要CoInitialize,别忘了CoUninitialize |
| 多图层 | Document里把vector<Stroke>换成vector<Layer> | 一天 | 重绘顺序、撤销粒度、图层合成都要重新想 |
| 白板实时同步 | 把DocEndStroke的提交事件序列化后发出去 | 一到两天 | 别传位图,传Stroke结构,带宽差两个数量级 |
加箭头是最容易上手的练手题,骨架大概是:
// render.cpp —— 在 Line 的基础上补一个三角箭头,注意夹角的取法 case ShapeType::Arrow: { POINT a = s.pts.front(), b = s.pts.back(); MoveToEx(hdc, a.x, a.y, nullptr); LineTo(hdc, b.x, b.y); const double ang = atan2((double)(b.y - a.y), (double)(b.x - a.x)); const int len = 12 + s.width * 2; // 箭头大小跟着线宽走 const double spread = 0.45; // 半张角,弧度 POINT head[3] = { b, { b.x - (LONG)(len * cos(ang - spread)), b.y - (LONG)(len * sin(ang - spread)) }, { b.x - (LONG)(len * cos(ang + spread)), b.y - (LONG)(len * sin(ang + spread)) }, }; Polygon(hdc, head, 3); // 复用当前画刷,箭头会跟着填充 break; }atan2的参数顺序是(y, x),写成(x, y)得到的是余角,箭头会朝着垂直方向歪——这个错误我第一次写的时候盯了半小时才反应过来。spread控制箭头的张角,0.45 弧度大概 26 度,看着比较精神;len跟着线宽走是为了粗线配大箭头,不然 16px 的线配 12px 的箭头会像根火柴棍。
改完怎么验证?三个动作。第一,用 Spy++ 挂上去看消息流,确认新加的按钮确实发了WM_COMMAND且 ID 对得上;第二,任务管理器盯 GDI 对象数,连续画五十笔看它是不是稳定;第三,故意把窗口拖到负坐标区域再拖回来,看新图元重绘是否正常。这三步走完,基本不会留下大的回归问题。
我从这个不到两千行的工具里得到的最实在的一条经验是:Win32 那套东西看着原始,可它把所有状态都摆在你面前,没有框架替你兜底,也没有框架替你藏坑。从那以后我每次接这种小型桌面工具,都强制先跑一遍 GDI 对象计数和 Spy++ 消息日志再交付,省下来的返工时间远比这两分钟多。希望帮到你。
本文还有配套的精品资源,点击获取