简介:一份基于 Win32 API 实现的简易画图工具工程,面向 Windows 编程初学者或需要了解 GDI 绘图与消息机制的开发者。工程演示了颜色选择、图形绘制、橡皮擦、文件保存以及子窗口打开关闭等基本交互,可通过鼠标操作完成简单的图像编辑。压缩包共 87 个文件,约 36.57MB,包含 C++ 源码(h/cpp)、Visual Studio 工程与解决方案(sln/vcxproj)、窗口资源(rc/bmp/cur/ico)以及编译产物(exe/obj/pdb),可直接打开工程对照学习并运行体验。已有 432 人浏览学习。工程源码中 drawfun、drawwnd、toolbox、popfile 等模块划分清晰,drawfun 封装绘图操作,drawwnd 处理窗口消息,popfile 负责图像文件保存,工具栏与主窗口逻辑也单独成模块,便于按功能定位相关代码。初学者可根据这套工程自行修改颜色、画笔样式与图形种类;阅读源码可理解 Win32 消息循环、GDI 绘图接口以及位图文件保存的具体实现,适合作为入门 Windows 图形编程的实操参考。
1. 一个基于 win32 的简单画图工具:从鼠标消息到像素落地的完整闭环
如果你用 MFC 或 Qt 写过画图小程序,再回头看纯 Win32 SDK 手写的画图工具,会有一种“原来系统底层这么直接”的醒悟。这个项目不大,但把窗口注册、消息循环、设备上下文、内存位图、图形对象存储这几块经典内容全串起来了,非常适合想搞懂 Windows 原生绘图机制的从业者。它解决的不是“画得好看”,而是“怎么让每一次鼠标拖拽都稳定落到画布上,窗口遮挡后重绘也不丢东西”。适合正在学 Win32 编程、准备给某些跨平台系统做原生绘图模块 Demo、或者单纯想摆脱框架黑匣子的开发者。下面我从搭建骨架开始,把每一步代码和容易卡住的点拆开讲。
2. 先搭骨架:窗口类注册、消息循环与设备上下文选型
2.1 为什么用 Win32 SDK 手写而不是 MFC/Qt
网上很多画图 Demo 用 MFC 的 CView 或者 Qt 的 QPainter,封装得太干净,反而看不清“鼠标消息如何转换成屏幕像素”。Win32 SDK 里你能直接接触到 WM_LBUTTONDOWN、WM_MOUSEMOVE、WM_PAINT 这些原始消息,能控制每一条 GDI 绘图指令。这个项目默认选纯 C 风格实现,一是方便移植到其他 Windows 原生项目,二是目标足够小——不需要文档/视图框架,不需要消息映射宏。MFC 里你要带着 CWinApp、CDocument 一大串东西,对于“画几条线”来说太笨重。而我实际拆分这类资源时更看重“单文件能编译、依赖只有 kernel32 user32 gdi32”,这就是 Win32 最实在的优势。
2.2 窗口注册与主循环:代码拆解与参数习惯
主窗口创建看起来简单,但有三个参数直接影响后续绘图行为。先看注册窗口类的代码:
#include <windows.h> #include <windowsx.h> #define MAX_SHAPES 1024 // 绘图模式和工具定义 enum DrawMode { MODE_PENCIL = 0, MODE_LINE, MODE_RECT, MODE_ELLIPSE, MODE_ERASER }; HINSTANCE g_hInst; HWND g_hWnd; LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM); int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { WNDCLASSEX wc = {0}; wc.cbSize = sizeof(WNDCLASSEX); wc.style = CS_HREDRAW | CS_VREDRAW; // 客户区尺寸变化时触发重绘 wc.lpfnWndProc = WndProc; wc.hInstance = hInstance; wc.hCursor = LoadCursor(NULL, IDC_CROSS); wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); wc.lpszClassName = TEXT("SimplePaintClass"); RegisterClassEx(&wc); HWND hWnd = CreateWindowEx(0, TEXT("SimplePaintClass"), TEXT("Simple Paint"), WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 960, 640, NULL, NULL, hInstance, NULL); if (!hWnd) return -1; g_hWnd = hWnd; ShowWindow(hWnd, nCmdShow); UpdateWindow(hWnd); MSG msg; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } return (int)msg.wParam; }逻辑说明:窗口类的style字段设置了CS_HREDRAW | CS_VREDRAW,意味着窗口水平或垂直大小改变时,系统会强制重绘整个客户区。对画图工具来说,这是句后悔药——如果你不设它,拉伸窗口后新暴露的区域可能留着背景色残影。光标用十字光标IDC_CROSS,这是画图工具顺手的选择。主循环是标准写法,GetMessage拿到WM_QUIT时返回 0 退出。
参数说明:CreateWindowEx里我用了CW_USEDEFAULT作为初始坐标,省得算屏幕位置;960x640 是画布起步尺寸,实际绘图区域是客户区,比这个更小。hbrBackground用了系统窗口色加 1,实际绘制时我们会用自己的白色画布覆盖它,这里只是让未绘制区域不突兀。
2.3 设备上下文:GetDC 与 BeginPaint 的区别决定稳定性能
很多人一上来就在鼠标事件里用GetDC(hWnd),在WM_PAINT里也用BeginPaint,结果遇到奇怪的重绘残影。这里的规矩是:鼠标事件里用GetDC,因为你想在消息响应期间立刻画;WM_PAINT里必须用BeginPaint,因为 BeginPaint 会正确验证无效区域,返回的 HDC 自带裁剪区域,只画需要重画的部分。GetDC不裁剪,如果窗口有部分被遮挡,你依然能画,但内容可能画到屏幕外,造成内存绘图和实际显示不一致。
case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); // 此处执行双缓冲绘制,见第 4 章 EndPaint(hWnd, &ps); return 0; }逻辑说明:BeginPaint会把ps.fErase置成是否擦除背景,如果背景画笔非空,系统先填充背景。为了不闪,通常我们让它擦除,但后面用内存位图整体覆盖回去。EndPaint会释放 HDC、解除独占,并且让无效区域变为有效。如果你用GetDC在这里画,窗口一直处于无效状态,消息队列会持续发WM_PAINT,CPU 立刻空转。
这一章核心结论:用GetDC处理鼠标实时反馈,用BeginPaint处理系统触发重绘,两者对应的画布逻辑只有在双缓冲下才能真正融合。
3. 让鼠标画起来:绘制状态机、橡皮筋预览与图元坐标规范化
3.1 鼠标消息触发链路与状态切换
画图工具本质是状态机:空闲、按下、拖动、抬起。我们需要在WndProc里维护几个静态变量记录起点、当前点、上一个预览点、是否正在拖拽。看下面的消息分支模板:
static POINT ptStart, ptPrev, ptEnd; static BOOL bDragging = FALSE; static int currentMode = MODE_LINE; static int currentThickness = 2; static COLORREF currentColor = RGB(0, 0, 255); case WM_LBUTTONDOWN: { ptStart.x = GET_X_LPARAM(lParam); ptStart.y = GET_Y_LPARAM(lParam); ptEnd = ptPrev = ptStart; bDragging = TRUE; SetCapture(hWnd); return 0; } case WM_MOUSEMOVE: { if (!bDragging) break; ptEnd.x = GET_X_LPARAM(lParam); ptEnd.y = GET_Y_LPARAM(lParam); if (currentMode == MODE_PENCIL || currentMode == MODE_ERASER) { HDC hdc = GetDC(hWnd); HPEN pen = CreatePen(PS_SOLID, currentThickness, (currentMode == MODE_ERASER) ? RGB(255,255,255) : currentColor); HPEN oldPen = (HPEN)SelectObject(hdc, pen); MoveToEx(hdc, ptPrev.x, ptPrev.y, NULL); LineTo(hdc, ptEnd.x, ptEnd.y); SelectObject(hdc, oldPen); DeleteObject(pen); ReleaseDC(hWnd, hdc); } else { // 橡皮筋预览:使用异或模式,见 3.2 ErasePreview(hWnd, ptStart, ptPrev); DrawPreview(hWnd, ptStart, ptEnd); } ptPrev = ptEnd; return 0; } case WM_LBUTTONUP: { if (!bDragging) break; bDragging = FALSE; ReleaseCapture(); // 结束图元:存储或直接提交到内存位图 CommitShape(hWnd, ptStart, ptEnd); InvalidateRect(hWnd, NULL, FALSE); return 0; }逻辑说明:WM_LBUTTONDOWN里我把三个点都初始化成按下位置,并用SetCapture捕获鼠标,这样即使用户拖拽到窗口外部,窗口依然能收到WM_MOUSEMOVE,不会中途丢失。WM_MOUSEMOVE里分两类:铅笔和橡皮擦走实时画线,画完一条线就把ptPrev更新到当前点,这样连续轨迹是一段段短直线拼接的。橡皮擦其实是用背景色画线,偷懒做法,但在这个资源里勉强够用。
参数说明:铅笔线创建了PS_SOLID笔,宽度是currentThickness,颜色是currentColor;橡皮擦颜色写死为RGB(255,255,255),如果你的画布背景改成染黄或网格,这个橡皮擦会露馅,更好的做法是取背景色,我们后面避坑章会提。
3.2 橡皮筋预览:用 SetROP2 异或消除残影
画直线、矩形、椭圆时,用户期望拖动时看到“橡皮筋”效果,也就是虚影跟着鼠标移动。最原始做法是每次移动都把上一个预览图元重画成背景色,再画新的。但直接重画背景色会破坏已经画好的其他图形。正确的常用做法是设置绘图模式为R2_XORPEN:用前景色异或屏幕像素,第一次画出来,在原位置再画一次就恢复原样。
void ErasePreview(HWND hWnd, POINT ptStart, POINT ptPrev) { HDC hdc = GetDC(hWnd); SetROP2(hdc, R2_XORPEN); HPEN pen = CreatePen(PS_SOLID, currentThickness, currentColor); HPEN oldPen = (HPEN)SelectObject(hdc, pen); // 背景透明画刷,避免填充区域盖住已绘内容 HBRUSH oldBrush = (HBRUSH)SelectObject(hdc, GetStockObject(NULL_BRUSH)); if (currentMode == MODE_RECT) { Rectangle(hdc, ptStart.x, ptStart.y, ptPrev.x, ptPrev.y); } else if (currentMode == MODE_ELLIPSE) { Ellipse(hdc, ptStart.x, ptStart.y, ptPrev.x, ptPrev.y); } else { // LINE MoveToEx(hdc, ptStart.x, ptStart.y, NULL); LineTo(hdc, ptPrev.x, ptPrev.y); } SelectObject(hdc, oldBrush); SelectObject(hdc, oldPen); DeleteObject(pen); ReleaseDC(hWnd, hdc); }逻辑说明:ErasePreview和DrawPreview其实是同一个画法,区别在于传入的终点坐标不同。相同颜色、相同模式下,在原位置再调用一次Rectangle,像素会被异或回原样。这样做的好处是不用保存“被覆盖的背景”快照,内存占用为零。
参数说明:R2_XORPEN下,颜色函数是前景色异或背景色的叠加,所以如果背景上有其他图形,第二次调用依然能精确还原。如果你发现残影,多半是第一次和第二次调用之间窗内容被WM_PAINT重绘过,异或上下文变了,所以强制把预览和确认逻辑都放在鼠标消息里,不触发InvalidateRect,这一点很重要。
3.3 图元数据的存储:结构体与坐标规范化
每次WM_LBUTTONUP都要把完成后的图形加入数组,这样窗口重绘时可以统一画一遍。但用户拖拽可能从右下到左上,直接用起点和终点画矩形会得到负宽高,所以必须规范化为左上角和右下角。
#define MAX_SHAPES 1024 typedef struct tagSHAPE { int type; // 0 铅笔轨迹, 1 直线, 2 矩形, 3 椭圆, 4 橡皮擦轨迹 RECT rc; // 规范化后的边界 COLORREF color; int thickness; } SHAPE; SHAPE g_shapes[MAX_SHAPES]; int g_shapeCount = 0; void CommitShape(int type, POINT ptStart, POINT ptEnd, COLORREF color, int thickness) { if (g_shapeCount >= MAX_SHAPES) return; // 简单保护,不处理扩容 SHAPE *s = &g_shapes[g_shapeCount]; s->type = type; s->rc.left = min(ptStart.x, ptEnd.x); s->rc.top = min(ptStart.y, ptEnd.y); s->rc.right = max(ptStart.x, ptEnd.x); s->rc.bottom = max(ptStart.y, ptEnd.y); s->color = color; s->thickness = thickness; g_shapeCount++; }逻辑说明:铅笔和橡皮擦实际上是一个长轨迹,用一个外接矩形把这个轨迹包起来,重绘时只需要重新画这一段区间内的全部曲线点。如果你把每个鼠标移动当成一个独立图元,数组会爆掉,所以我把同一段WM_LBUTTONDOWN到WM_LBUTTONUP之间的连线轨迹记录成MODE_PENCIL,但坐标只存起点终点,中间路径不存。这会导致重绘后铅笔线变成直线段,所以更完整的做法是单独存折线点集合。
这个资源为了简单,直接用了上述结构体,但它的代价是:铅笔工具重绘后不再平滑。如果想兼顾平滑,需要把POINT数组作为动态数组存在SHAPE里,但 MAX_SHAPES 语义就变了。我一般会在资源里附两个版本,一个简版如上,一个完整版用POINT* points保存折线,这样重绘轨迹和鼠标拖拽一致。
4. 画布不丢内容:内存位图双缓冲与 WM_PAINT 全量重绘
4.1 重绘机制的三个触发时机:无效区域、拉伸、遮挡恢复
有了图元数组,窗口重绘的分工就清晰了。WM_PAINT只做一件事:把数组里的图形依次画到目标画布。这里目标画布不是直接屏幕,而是一块内存位图。重绘时机有三个:用户拖拽结束后的InvalidateRect、窗口拉伸导致的客户区尺寸变化、被其他窗口遮挡后恢复。若不做双缓冲,这三种情况都会闪烁或出现黑色空隙。
case WM_SIZE: { // 释放旧缓冲,重新分配 RecreateBackBuffer(); return 0; } case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); DrawBackBufferToScreen(hdc); EndPaint(hWnd, &ps); return 0; }逻辑说明:WM_SIZE消息在窗口大小变化后发送,此时客户区有可能变大或缩小。如果缓冲位图还是老尺寸,新的空白区域没有像素内容,重绘会露出底色。所以这里强制重建整个后台画布。
参数说明:InvalidateRect(hWnd, NULL, FALSE)中第三个参数为 FALSE,意思是不要擦除背景。因为擦除背景等于先用背景色涂一遍,而我们的双缓冲会立刻覆盖,纯粹浪费、还会闪。如果你用 TRUE,窗口会先刷白再重绘,视觉上就是白底闪一下。
4.2 内存位图:创建兼容 DC 与 BitBlt 一次拷贝
HDC g_memDC; HBITMAP g_memBmp, g_oldBmp; int g_bmpWidth, g_bmpHeight; void CreateBackBuffer(HWND hWnd, int width, int height) { HDC hdc = GetDC(hWnd); if (g_memDC) { SelectObject(g_memDC, g_oldBmp); DeleteObject(g_memBmp); DeleteDC(g_memDC); } g_memDC = CreateCompatibleDC(hdc); g_memBmp = CreateCompatibleBitmap(hdc, width, height); g_oldBmp = (HBITMAP)SelectObject(g_memDC, g_memBmp); g_bmpWidth = width; g_bmpHeight = height; // 清底 RECT rc = {0, 0, width, height}; HBRUSH bg = CreateSolidBrush(RGB(255, 255, 255)); FillRect(g_memDC, &rc, bg); DeleteObject(bg); // 重放所有图元 for (int i = 0; i < g_shapeCount; i++) { DrawShapeToDC(g_memDC, &g_shapes[i]); } ReleaseDC(hWnd, hdc); } void DrawBackBufferToScreen(HDC hdc) { BitBlt(hdc, 0, 0, g_bmpWidth, g_bmpHeight, g_memDC, 0, 0, SRCCOPY); }逻辑说明:CreateCompatibleDC创建的 DC 默认只有 1x1 单色位图,所以必须马上选入一个兼容位图。兼容位图使用与屏幕 DC 相同的位置格式,BitBlt 时效率最高。创建后先用白色FillRect填充,再重新画数组里的全部图元。这意味着双缓冲位图永远保存当前画布的完整内容。
参数说明:width和height我建议取当前客户区的完整大小,可以用GetClientRect获得。注意CreateCompatibleBitmap第二个参数如果为 0,会创建失败,所以WM_SIZE里收到SIZE_RESTORED时要判断height != 0。
4.3 动态绘制同步:鼠标事件操作内存 DC,再局部刷新
鼠标事件里实时画线不能只画屏幕,否则下一次WM_PAINT会把这条新线从后台缓冲里清掉。正确做法是:在鼠标事件中同时操作实际屏幕 DC 和内存 DC,然后在WM_MOUSEMOVE结束时用InvalidateRect触发局部重绘,或者直接 BitBlt 到屏幕。
void UpdateBackBufferWithLine(POINT ptA, POINT ptB, COLORREF color, int thickness) { HPEN pen = CreatePen(PS_SOLID, thickness, color); HPEN oldPen = (HPEN)SelectObject(g_memDC, pen); MoveToEx(g_memDC, ptA.x, ptA.y, NULL); LineTo(g_memDC, ptB.x, ptB.y); SelectObject(g_memDC, oldPen); DeleteObject(pen); // 同步到屏幕 HDC hdc = GetDC(g_hWnd); BitBlt(hdc, 0, 0, g_bmpWidth, g_bmpHeight, g_memDC, 0, 0, SRCCOPY); ReleaseDC(g_hWnd, hdc); }逻辑说明:这段函数把线段同时落到后台缓冲和屏幕。第一次画图形时它在内存 DC 里,下一次WM_PAINT时还会被重复画一次,但因为已经是正确内容,所以不会出现变化。更重要的是,如果窗口被遮挡后又恢复,WM_PAINT会从数组和内存 DC 中恢复画面,而内存 DC 和数组内容是一致的,双保险。
参数说明:这里的g_bmpWidth和g_bmpHeight必须与实际客户区匹配,否则 BitBlt 目标尺寸超出屏幕右边界时,超出部分不会显示。我习惯在WM_SIZE里记录新尺寸并固定使用,而不是每次调用GetClientRect,因为鼠标消息和WM_PAINT之间可能存在短暂不一致。
5. 避坑指南:从卡顿到丢像素的五个典型问题
5.1 画图闪烁得像霓虹灯
现象:每次拖拽图形时,窗口区域频繁闪白,尤其拉伸窗口时更明显。
原因:窗口类里没处理WM_ERASEBKGND,系统默认擦除背景时,会先用hbrBackground填充,这个过程相当于把画布涂白一次,紧接着WM_PAINT再把图形画上去。两次显示之间产生了白帧。
解决:在WndProc中拦截WM_ERASEBKGND并直接返回TRUE,告诉系统“背景我已经处理了,不用你擦”。但注意,这必须配合双缓冲,否则不擦背景会导致旧画面残留。代码很简单:
case WM_ERASEBKGND: return 1;5.2 橡皮擦擦出灰底或白块
现象:用橡皮擦在彩色图形上拖动,擦过的地方变成纯白方块,但画布背景是米黄色或纹理。
原因:橡皮擦实现时写死了RGB(255,255,255),没有去取背景色;更隐蔽的是,用背景色画线只能“画上”颜色,无法真正擦除像素——如果背景是渐变或已填充图片,就露馅了。
解决:先把背景色统一成纯色,在资源里只支持白底画布;或者用SaveDC/RestoreDC配合BitBlt从后台缓冲保存的“空底”擦除。更成熟的思路是:不在画图时实时擦除,而是把橡皮擦操作记录成一条白色折线,重绘时保持前后一致。如果是修复已有像素,常见做法是在内存 DC 上保存一个干净的背景位图,擦除时从背景位图拷贝区域覆盖。
5.3 GDI 对象泄漏,画了一会儿画面变糊
现象:程序运行约五分钟后,画笔变得特别粗、颜色不对,或者某些矩形画不出来,任务管理器里 GDI 对象数一路涨。
原因:代码里每次CreatePen后没有DeleteObject,每次GetDC后没有ReleaseDC,导致 GDI 句柄耗尽。
解决:养成“成对”的习惯。CreatePen对应DeleteObject,GetDC对应ReleaseDC。在分支里用局部变量接收句柄,画完立刻删除。可以把所有创建画笔的地方统一封装成一个函数,函数内用__try/finally确保释放,但 Win32 下的 C 一般不这么写,直接手动管理反而更清楚。
5.4 鼠标坐标偏移,点击线头对不上鼠标
现象:在非客户区边缘点击时,画的图形起点明显偏下或偏右,尤其是带标题栏的窗口。
原因:直接用了GetCursorPos得到的是屏幕坐标,而绘图要的是客户区坐标。WM_LBUTTONDOWN的lParam是客户区坐标,但在某些封装里容易拿混。
解决:在WM_MOUSEMOVE里统一用GET_X_LPARAM/GET_Y_LPARAM获取客户区坐标;如果确实需要从屏幕坐标转换,调用ScreenToClient(hWnd, &pt)。我自己的检查习惯是:按下鼠标后在窗口边缘移动,观察十字光标和图形起点的偏移量是否恒定为 0。
5.5 橡皮筋预览残影叠成一团黑线
现象:画矩形时,拖动速度快,画面上残留多条深色边框,不是干净的随动效果。
原因:R2_XORPEN异或模式要求第二次擦除时必须使用完全相同的坐标、相同的画笔颜色和笔宽。如果中间有别的图元被画进来,异或恢复不了;另一个原因是预览时使用了NULL_BRUSH,但系统在某些 GDI 版本下对空刷的矩形填充区域有隐含操作,导致边框颜色被混合。
解决:强制在WM_MOUSEMOVE里先擦除上一次预览、再画新预览,且保证两次调用之间不执行任何其他绘图。如果仍残留,改用SaveDC保存绘图环境,再RestoreDC恢复,或者用双层位图:在内存位图上做预览,最后再拷贝到屏幕。
6. 收尾进阶:让工具更像样——颜色选择器、线宽控件与撤销栈
基础绘图闭环已经完成,再加三个小功能,工具就能从“能画”变成“好用”。先说颜色选择,Windows 自带ChooseColor公共对话框,初始化CHOOSECOLOR结构时给lpCustColors传递一个有 16 个COLORREF的数组,然后调用即可。关键点是自定义颜色数组必须使用静态或全局存储,否则对话框第二次打开会报非法指针。线宽可以用一个简单的Trackbar或Spinner,但对纯 SDK 来说,最省事的是在菜单里放“宽度 1 / 2 / 5 / 10”选项,直接改currentThickness。这让用户能立刻看到笔刷粗细变化,不用引入新控件。
撤销是进阶功能里比较讨喜的。最简单的做法是维护一个“图元栈”:每次WM_LBUTTONUP完成后,把g_shapes[g_shapeCount-1]拷贝到一个undo_stack数组;按Ctrl+Z时,把g_shapeCount--,然后触发Sp_InvalidateBackground——即清空内存 DC、重放所有剩余图元、再 BitBlt。注意这里g_shapeCount--并没有真正删除数组数据,只是让它不再被重绘,从而实现逻辑撤销。如果还想要重做,再压入redo_stack。以内存在线的简单工具来说,撤销栈最多保存 64 条就够了,防止无限增长。
SHAPE g_undoStack[64]; int g_undoTop = 0; void PushUndo(SHAPE* shape) { if (g_undoTop < 64) { g_undoStack[g_undoTop++] = *shape; } } void UndoLastShape(void) { if (g_shapeCount > 0) { g_shapeCount--; RecreateBackBufferFromShapes(); // 清空并重画所有图元 InvalidateRect(g_hWnd, NULL, FALSE); } }这段代码的背后逻辑是:撤销不是真的“抹掉”像素,而是让图元数组少一个元素,然后重画整个画布。所以只要图元结构体里的坐标、颜色、线宽完整,它就能精确恢复。如果你把铅笔误存成直线图元,撤销恢复后那一段就会变成直线,这又是一个容易踩的细节——我在资源里把铅笔轨迹独立成MODE_PENCIL,并在重绘函数里用Polyline画点集合,才算彻底解决。
从那以后,我每次改完代码都会强制走一遍“最小化窗口 → 拉伸窗口 → 遮挡后恢复”三连操作,检查画布是否丢内容、橡皮筋是否留残影。等你亲手把位图双缓冲跑通,再回头去看这些代码,每一步都是值得的。希望这份拆解对你手头的 win32 项目有帮助,哪怕只是让你少加一次DeleteObject。
本文还有配套的精品资源,点击获取