ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

VC++ GDI绘制可点击填充网格:坐标换算与双缓冲实战

VC++ GDI绘制可点击填充网格:坐标换算与双缓冲实战 简介VC环境下基于Windows GDI实现网格绘制与格子填充的示例工程适合刚接触图形界面编程、希望掌握自定义绘制的开发者示例采用自定义Graph类将绘制逻辑封装为独立模块可应用于表格、棋盘、数据可视化、游戏地图等需要网格底座的场景。压缩包共3个文件包含两个核心C源码文件和一个文本说明文件整体仅18KB代码精简便于按需阅读和二次修改。核心类覆盖了OnPaint消息处理、MoveTo/LineTo画线以及FillRect填充格子等关键步骤通过调整行列数、单元格大小即可改变网格密度也能为每个格子独立设置填充色、边框样式和画笔颜色满足不同界面风格需求。当前已有170人学习下载适合作为入门GDI绘图和自定义控件渲染的练习案例阅读源码可以体会设备上下文DC的获取、画刷创建与释放等细节后续还能扩展鼠标点击选格、拖拽调整尺寸等交互功能。整体项目边界清晰适合通过单步调试观察每条网格线的生成过程也方便将自定义绘制类迁移到MFC对话框或自定义控件中复用便于后续增加颜色主题、动态行列调整等扩展能力。 做棋类小游戏、扫雷或者简单的像素画编辑器时几乎都会遇到一个绕不开的需求在窗口里“绘制格子”然后让用户点击某个格子实现“格子填充”颜色。我第一次认真做这个功能是给一个小型课设写简易扫雷界面用的就是Visual C 6.0加GDI。到现在各种界面方案层出不穷但VC这套老组合在Windows小工具、调试面板、工控上位机软件里依然很常见。这篇文章不绕弯子直接讲清楚怎么在VC里绘制一个n行m列的规则网格鼠标点上去格子就填上颜色。核心的数学模型、消息处理、双缓冲重绘以及我实际调试时踩过的坑都会完整记录下来。想快速搭一个网格交互原型或者想搞懂Windows图形编程底层逻辑的人都可以参考。1. 从需求到模型这个功能到底在做什么1.1 哪些场景在等这个“网格填充”组合我随手列几个最常见的应用场景你看看是不是刚好踩中棋盘类游戏五子棋、围棋、跳棋棋盘本质上就是一张网格落子就是在某个格子里画一个圆或者放一个图标扫雷类游戏格子数量固定每个格子要有“空白、数字、旗帜、地雷”等不同视觉状态点击切换状态像素画编辑器一张画布分成几十乘几十的格子每个格子对应一个像素点击填充颜色这是最直接的“格子填充”场景表格数据可视化模拟Excel单元格的着色把某个数据异常的格子标红用来做简单的数据标注硬件调试面板比如乐器音序器、灯光控制台用格子代表拍子和通道点击格子改变声光状态。看下来会发现这些都是同一个底层能力一张网格每个格子有独立状态用户能通过鼠标改变格子状态。把这三个问题解决了上面所有需求都会变得很简单。1.2 为什么选用VC GDI这套方案我经常被问现在都什么年代了为什么不用Qt、C#、Electron问得对但得分场景。如果只是做一个内部调试工具或者目标机器是老旧的Windows系统VC GDI的优势非常明显零依赖编译出来的exe很小拷到陌生机器上一跑就通原理透明GDI的绘图模型是“选择对象、画、再选回旧对象”这套思路是整个Windows图形编程的地基学会了以后看任何GDI/GDI代码都不发怵性能可控网格填充这种操作GDI用Rectangle绘制矩形完全够用几千个格子也毫无压力调试直接消息循环和WM_PAINT机制是Windows的老祖宗逻辑遇到问题用断点和简单输出就能定位不像一些框架套了好几层出事之后根本不知道背后发生了什么。当然如果你的项目需要跨平台、需要复杂控件那确实该选Qt。但就“绘制格子 格子填充”这个小需求而言VC GDI是又直接又扎实的选择。1.3 功能拆解一个“可填充网格”需要哪些模块把需求拆开其实只有三块。第一块是网格的几何模型多少行、多少列、每个格子占多少像素整个网格在窗口里居于什么位置。第二块是格子的状态管理用一个数据结构记住每个格子当前是什么状态是未填充还是填充成了某种颜色。第三块是鼠标交互与重绘点击时通过坐标反算出是哪个格子修改它的状态然后触发窗口重绘。绘制本身不是难点难的是状态管理和坐标换算。这三块搞定了整个功能就成立。下面我会一个一个展开。2. 核心设计思路坐标换算与状态管理是灵魂2.1 网格的数学模型从行列索引到像素坐标假设要画rows行、cols列的网格每个格子宽度是cellW像素、高度是cellH像素。为了让网格不贴着窗口边缘我通常在左上角留一个margin外边距比如20像素。那么第i行、第j列的格子它的左上角坐标是左侧x j * cellW margin顶部y i * cellH margin右侧x (j 1) * cellW margin底部y (i 1) * cellH margin这个映射是“行列索引到像素坐标”的正向转换每次重绘时都会用到。反过来当鼠标点击坐标是(point.x, point.y)时要算出它落在哪个格子里相对坐标relX point.x - marginrelY point.y - margin列号col relX / cellW行号row relY / cellHC里整数除法会自动向下取整所以这个计算非常直接。但要注意两个边界一是如果point.x小于marginrelX就是负数col会是负数必须判断行列是否越界二是如果点击坐标落在网格右侧或下方的margin区域计算出的row或col会大于等于最大行列值也要过滤。2.2 状态管理数据要比绘制“高级”一点很多人第一次做这个功能时会直接在WM_PAINT里判断“哪个格子被点击过”但这样做的后果是窗口一被遮挡再恢复所有状态全丢因为WM_PAINT只会重绘它不记忆点击历史。正确的做法是点击事件只负责改数据绘制代码只负责读数据。我习惯用一个二维数组保存状态。如果只需要记录“是否填充”用int就够了0表示未填充1表示已填充。如果需要多种颜色可以存颜色索引避免直接用COLORREF导致数据太散// 头文件里 int m_rows; // 行数 int m_cols; // 列数 int m_cellW; // 格子宽度 int m_cellH; // 格子高度 int m_margin; // 外边距 std::vectorstd::vectorint m_grid; // 每个格子的状态0未填充1填充 COLORREF m_fillColor; // 当前填充颜色这里我把“每个格子填充成什么颜色”和“当前用哪个颜色去填充”分离了。m_grid[i][j]等于0表示未填充等于1表示使用m_fillColor填充。以后要支持多色只需把m_grid[i][j]改成颜色索引绘制时查色表逻辑不用大改。初始化时调用一个函数void InitGrid(int rows, int cols) { m_rows rows; m_cols cols; m_cellW 40; m_cellH 40; m_margin 20; m_fillColor RGB(255, 200, 0); m_grid.clear(); m_grid.resize(m_rows); for (int i 0; i m_rows; i) { m_grid[i].resize(m_cols, 0); } }2.3 为什么要用“数据驱动绘制”而不是“直接画上去”这么说吧绘制永远是一个“表现层”动作。Windows的消息机制决定了WM_PAINT可能在任意时刻被触发窗口拖动、遮挡恢复、调整大小只要窗口内容需要刷新系统都会发WM_PAINT。如果你在点击的时候直接把格子画在屏幕上画面一刷新就没了如果点击时先更新数据绘制时根据数据重画整幅画面那么无论窗口怎么折腾画面都是稳定可复现的。这就是“数据驱动绘制”的核心思想。开发GUI程序尤其是底层的Windows窗口程序越早建立这个认知后面写出来的代码越不容易出幺蛾子。2.4 命中检测的细节填充和清除的交互实际使用中用户不只是想“填充”还想“点错了改回来”。所以我的OnLButtonDown里做的是状态翻转原来未填充就填成当前颜色原来已填充就清零。为了让这个逻辑更直观可以再加一个右键点击专门清除格子。翻转的方式会让操作变得非常顺手这也是很多画板类工具的习惯。3. 动手实现从工程创建到鼠标点击填充3.1 创建工程MFC对话框程序我用MFC对话框来做演示因为它不需要手动维护窗口类注册和消息循环省去一堆样板代码更适合聚焦在绘图逻辑上。在Visual Studio里新建“MFC应用程序”应用程序类型选“基于对话框”语言选中文其余默认即可。VC6的步骤也差不多只是对话框资源编辑器叫法略有差别。进入对话框编辑器后把默认的“确定”“取消”按钮调整位置或者先放着不管运行的时候我们主要看客户区里的绘制效果。记得在对话框的OnInitDialog里调用InitGrid初始化参数。如果你想调整窗口的初始大小可以在对话框资源的属性里把“宽度”“高度”改大一些否则客户区太小网格会显示不全。3.2 消息映射怎么加OnPaint、OnLButtonDown在MFC中消息映射是连接“系统事件”和“处理函数”的桥梁。要在对话框类里响应绘制和鼠标点击需要在BEGIN_MESSAGE_MAP中添加ON_WM_PAINT和ON_WM_LBUTTONDOWN如果要处理右键清除还要加ON_WM_RBUTTONDOWNBEGIN_MESSAGE_MAP(CGridDlg, CDialogEx) ON_WM_PAINT() ON_WM_LBUTTONDOWN() ON_WM_RBUTTONDOWN() END_MESSAGE_MAP()OnPaint不是系统自动生成的需要手动添加声明和实现。如果你在类向导里看不到直接在头文件加上afx_msg void OnPaint();然后再实现即可。3.3 绘制网格三步画出格子绘制部分我分成两个函数DrawGrid负责画格子的背景和边框FillCell是对单个格子的绘制后者在鼠标点击后做局部刷新时会被复用。核心思路是用CPen画边框线用CBrush填充格子背景然后调用Rectangle函数这个函数会自动用当前画笔画边框、用当前画刷填充内部。void CGridDlg::DrawGrid(CDC* pDC) { CPen pen(PS_SOLID, 1, RGB(80, 80, 80)); CPen* pOldPen pDC-SelectObject(pen); for (int i 0; i m_rows; i) { for (int j 0; j m_cols; j) { CRect rect(j * m_cellW m_margin, i * m_cellH m_margin, (j 1) * m_cellW m_margin, (i 1) * m_cellH m_margin); CBrush brush(m_grid[i][j] 0 ? RGB(255, 255, 255) : m_fillColor); CBrush* pOldBrush pDC-SelectObject(brush); pDC-Rectangle(rect); pDC-SelectObject(pOldBrush); brush.DeleteObject(); } } pDC-SelectObject(pOldPen); pen.DeleteObject(); }这里有个性能小优化不要在一个循环里反复创建和删除画笔画笔在循环外创建一次就好。画刷因为颜色随格子状态变化只能在循环内创建但是用完记得选回旧画刷并DeleteObject否则资源会泄漏。提示创建了CPen、CBrush、CBitmap这类GDI对象后一定要用SelectObject把旧对象选回来再DeleteObject释放。GDI对象泄漏是这种小程序最容易犯、又最难被发现的错误程序跑一晚上系统绘图句柄会被耗尽窗口开始画不出来。3.4 鼠标坐标反算行列点击填充的核心代码有了正反两个坐标映射点击处理就很好写了。OnLButtonDown的第一个参数是按键状态第二个参数point就是鼠标在客户区里的坐标直接拿来用void CGridDlg::OnLButtonDown(UINT nFlags, CPoint point) { int relX point.x - m_margin; int relY point.y - m_margin; int col relX / m_cellW; int row relY / m_cellH; if (row 0 row m_rows col 0 col m_cols) { m_grid[row][col] m_grid[row][col] 0 ? 1 : 0; CRect rect(col * m_cellW m_margin, row * m_cellH m_margin, (col 1) * m_cellW m_margin, (row 1) * m_cellH m_margin); InvalidateRect(rect, FALSE); } CDialogEx::OnLButtonDown(nFlags, point); }InvalidateRect的第一个参数是待重绘的矩形区域第二个参数FALSE表示不擦除背景。这里只刷新被点击的格子比整窗Invalidate性能高而且视觉上不会出现整窗闪白。其实这是个很隐藏的细节整窗重绘时即使加了双缓冲重绘区域过大也会让画面有不平滑感局部刷新对交互体验的提升非常明显。右键清除格子的代码和上面几乎一样只是在翻转那里改成强制m_grid[row][col] 0。3.5 双缓冲让绘制告别闪烁如果直接把DrawGrid放在OnPaint里运行起来你就会看见窗口每次刷新都是一片惨白地闪。原因很简单WM_PAINT的第一步通常是清空背景系统会把客户区填成背景色然后再调用你的绘制代码这中间有一段时间窗口是空白的。白屏频率一高就成了闪烁。解决办法是双缓冲先把要画的内容全部画到一张“内存画布”上最后一次性拷贝到屏幕上。用户永远看不到中间被清空的过程。实现如下void CGridDlg::OnPaint() { CPaintDC dc(this); // 设备上下文用于实际绘制 CRect clientRect; GetClientRect(clientRect); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap memBitmap; memBitmap.CreateCompatibleBitmap(dc, clientRect.Width(), clientRect.Height()); CBitmap* pOldBitmap memDC.SelectObject(memBitmap); memDC.FillSolidRect(clientRect, RGB(240, 240, 240)); DrawGrid(memDC); dc.BitBlt(0, 0, clientRect.Width(), clientRect.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBitmap); memBitmap.DeleteObject(); memDC.DeleteDC(); }双缓冲的精髓在于先把背景色和所有内容画进内存再用一个BitBlt瞬间搬到屏幕。这个思路不止适用于网格绘制任何GDI绘图都可以套用。3.6 让网格居中显示如果窗口大小固定直接把margin写死成20是没问题的。但如果窗口可以缩放我习惯在OnSize里计算新margin把网格整体居中void CGridDlg::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); int gridW m_cols * m_cellW; int gridH m_rows * m_cellH; m_margin (cx - gridW) / 2; if (m_margin 10) m_margin 10; }这样当用户拉大窗口时网格会保持居中观感好很多。注意OnSize在窗口创建初期就会触发此时m_cols等成员可能还没初始化调用前要先判断m_cols 0。4. 实战踩坑记录与问题排查4.1 点击位置总是“偏”一个格子这个坑我踩过。现象是点格子A结果填充的是它右边或下边的格子。排查后发现问题出在“坐标基准”上没有对齐。代码里OnLButtonDown拿到的point是客户区坐标客户区坐标的原点在左上角而窗口的客户区并不等于对话框模板坐标。尤其当对话框上还有其他控件时鼠标的客户区坐标和你在资源编辑器里看到的对话框坐标不是一回事。解决办法就是始终统一使用客户区坐标。对话框客户区的(0,0)就是窗口可绘制区域的左上角绘制时我们设定的margin也是相对客户区的。只要绘制和命中检测都采用同一个基准就不会出现偏差。如果程序里有ScrollView或者CSplitterWnd坐标还会被滚动偏移影响那时候就得用ScreenToClient/ClientToScreen组合以及GetScrollPos来校正。4.2 窗口被遮挡后格子状态全丢这个现象发生在我“直接画上去”的早期版本里。点击时调用了dc.Rectangle画完当时有效但只要把窗口拉到屏幕外面再拉回来就会变成一片空白。根因我在前面强调过了WM_PAINT会重新绘制整个客户区点击时直接画的内容根本不会被记忆。解决方法是把状态存进m_grid数组OnPaint完全从m_grid读取状态来绘制。状态在内存里只要进程不退出窗口重绘多少次都能恢复。如果还想把状态存得更久可以考虑写入文件或数据库那就是另外的功能了。4.3 双缓冲在某些环境下仍然闪烁理论上双缓冲能解决闪烁但我在同样写法下遇到过“仍然闪”的情况。最后发现是对话框的WM_ERASEBKGND没有被屏蔽。Windows默认在WM_PAINT之前会发送WM_ERASEBKGND把窗口背景擦成白色。如果你不处理这个消息即使OnPaint里用了双缓冲背景擦除的一瞬间还是会闪。解决办法是在消息映射里加ON_WM_ERASEBKGND然后让OnEraseBkgnd直接返回TRUEBOOL CGridDlg::OnEraseBkgnd(CDC* pDC) { return TRUE; // 告诉系统背景已经擦除避免二次擦除 }注意如果窗口仍然闪烁多半是WM_ERASEBKGND没处理。默认的擦背景动作会让你整个双缓冲白做。这个处理配合双缓冲才是完整的抗闪烁方案。4.4 常见问题速查表现象可能原因解决办法网格画出来了但点击没反应消息映射没加ON_WM_LBUTTONDOWN在BEGIN_MESSAGE_MAP里补上消息映射点击填充的格子位置错乱鼠标坐标与绘制坐标基准不一致统一使用客户区坐标检查滚动偏移窗口恢复时格子全空状态没存到数据结构里点击时更新m_grid绘制时从m_grid读取窗口重绘时白屏闪烁没有双缓冲或没屏蔽WM_ERASEBKGND使用内存DC画完再BitBltOnEraseBkgnd返回TRUE程序运行久了绘图卡顿GDI对象泄漏检查CPen/CBrush/CBitmap是否DeleteObject点击网格边缘的格子填充失败margin计算或越界判断不严谨用relX/relY重新计算行列并判断row/col范围这张表基本覆盖了这类小程序常见的坑。遇到问题别慌先看是绘制层出问题还是数据层出问题定位会快很多。5. 从“能填充”到“更好用”的扩展方向5.1 支持多颜色填充只支持一种颜色做像素画是远远不够的。扩展思路很简单在对话框上加几个颜色按钮或者引入一个CColorDialog颜色选择器点击按钮后修改m_fillColor。m_grid数组里存颜色索引绘制时根据索引查一张颜色表std::vectorCOLORREF colorTable; colorTable.push_back(RGB(255, 255, 255)); // 0未填充用白色 colorTable.push_back(RGB(255, 200, 0)); // 1橙色 colorTable.push_back(RGB(100, 200, 255)); // 2淡蓝 colorTable.push_back(RGB(255, 100, 100)); // 3淡红点击时m_grid[row][col]在0、1、2、3之间轮转即可。这样格子的状态不仅限于“填/不填”而是变成了“颜色编号”绘制端只需要查表代码几乎不用大本文还有配套的精品资源点击获取
返回列表