
简介面向VC入门开发者与Windows GDI绘图学习者这份示例代码解决的是如何在窗口或控件上高效绘制规则网格并让每个格子独立填色的常见需求适合课程设计、表格界面、棋盘类小工具开发。资源包共3个文件Graph.h与Graph.cpp构成自定义图形类把网格绘制和填充逻辑封装起来另外附一份说明文本整个压缩包仅18KB代码量少便于快速通读。目前已有170人浏览学习。示例在窗口重绘消息中完成绘图画水平线和垂直线时分别设置起点与终点位置填充格子时指定矩形区域和颜色可灵活调整行数、列数、单元格大小、填充色以及边框样式从而形成棋盘式布局调整网格疏密还能适配不同粒度的界面。说明文件通常只是版权或来源信息不影响代码使用。该示例对初学者尤其友好代码结构清晰只保留核心绘图逻辑有助于降低学习门槛。读者可以从这两个文件中理解图形设备接口绘图的基本流程、控件重绘机制和简单图形类的设计方式并直接复用或扩展成表格编辑器、地图编辑器等界面组件。 我在整理老代码仓库时顺手翻出一个很典型的 VC 绘图小项目——“绘制格子格子可以填充”。这个功能听起来并不起眼在窗口里画出一张网格用户用鼠标点哪个格子哪个格子就被填充颜色再点一下可以取消填充。但真要把这个“简单”功能做得顺手、不闪烁、好扩展里面涉及到的坐标系换算、状态管理、重绘机制、双缓冲防闪烁每一步都有不少值得记录的地方。这篇博客就围绕这个主题从设计思路到核心代码再到排查经验的完整过程分享一下我实际动手做这个功能时的方案和心得。无论你是刚接触 MFC 自绘控件还是想找一个现成的网格填充交互实现这篇文章应该都能帮上忙。1. 项目整体设计先搞清楚“画格子”这件事的本质1.1 需求拆解这个功能到底在做什么“VC 绘制格子格子可以填充”拆开看就三个核心点绘制在窗口客户区中按行列绘制等间距的网格线形成一个 n×m 的格子矩阵。填充用户点击某个格子后该格子内部区域被填充成指定颜色再次点击则取消填充。交互程序需要正确处理鼠标坐标到网格行列索引的换算并且维持每个格子的填充状态。这三个点看似独立实际上牵扯到两个层面一是数据层你需要一套数据结构记录“每个格子当前是否被填充”二是视图层你需要把这份数据正确绘制到屏幕上并在数据变化时触发局部或全量重绘。我在最初梳理需求时吃过一个亏一上来就盯着“怎么画网格线”想结果代码写了一半才发现格子的状态管理才是核心难点画线只是最基础的 GDI 操作。如果先从数据结构入手后面的实现会顺很多。1.2 扩展方向不要只做一个“能点的棋盘”虽然标题很简单但这种网格填充交互放在实际项目里通常是被当成一个基础控件来复用的。比如地图编辑器里的地块标记工具排班系统里的值班表可视化电子表格类应用的自绘单元格背景游戏中的简易棋盘状态编辑工具比如扫雷、五子棋、生命游戏的地图编辑器。做的时候如果不考虑扩展性把行数、列数、格距、填充颜色、网格线颜色全部硬编码在 OnDraw 里那将来改一个参数就可能牵连好几处代码。更推荐的做法是把网格参数集中成成员变量并用一个二维状态数组去驱动绘制逻辑。这样底层数据结构不变上层做什么样的应用都能接得住。2. 技术选型与架构为什么用 MFC数据层和视图层怎么拆2.1 技术选型MFC 依然是 VC 绘图项目里最快上手的方案有人可能会问现在都什么年代了为什么还写 MFC直接上 Qt、Duilib 或者 Web 前端不香吗这里得看场景。如果是新的跨平台项目Qt 的确更合适如果是维护老系统、在既有 VC6 / VS2005 工程里增加一个自绘控件MFC 仍然是最稳的选择。MFC 对 GDI 的封装足够薄你不需要接触太多 Windows 消息机制的底层细节OnDraw、OnLButtonDown、OnMouseMove 这些虚函数直接拿来重写即可非常适合网格绘制这种自绘需求。MFC 的另一个好处是它自带文档/视图架构。对于“格子填充”这种带状态编辑的场景Document 保存格子状态数据View 负责渲染和交互天然契合 MVC 思想。即便项目不大也建议保留这种分层习惯后面一旦要把格子数据保存成文件、支持撤销重做分层的好处就会立刻体现出来。2.2 架构设计数据、渲染、交互三层各司其职我把这个网格功能拆成三个部分数据层CGridDocument 或 CGridData存储行数 rows、列数 cols、格子大小 cellSize以及一个 std::vectorstd::vector 或 std::set 来记录哪些格子被填充。视图渲染层CGridView::OnDraw遍历行和列画背景、画填充矩形、画网格线。交互层CGridView 的鼠标消息响应把客户区坐标换算成行列索引更新状态然后调用 InvalidateRect 触发重绘。这种分层思路最大的价值在于绘制函数只关心“根据状态数组把格子画出来”不需要关心状态从哪里来交互函数只负责“把坐标换为索引并更新状态数组”不去碰 GDI 绘制细节。你想改成拖拽批量填充、支持右键清除、撤销重做都只需要在交互层和数据层分别加逻辑绘制层几乎不用动。2.3 坐标与尺寸动手前先想清楚这四件事在写任何绘制代码之前必须先把坐标体系理清楚否则后期一定会出现“点不对位”的诡异问题。我总结了四个必算的参数网格起始点 (originX, originY)通常是客户区左上角留出边距margin比如 20 像素格子尺寸 (cellWidth, cellHeight)每个格子的宽和高我习惯让宽高相等保持正方形网格行列数 (rows, cols)由业务决定比如 20×20客户区大小 (clientWidth, clientHeight)除非你处理了 WM_SIZE否则不要假设客户区永远够大。有了这四个参数绘制和交互就都能基于同一个换算公式格子的左上角坐标 (originX col * cellWidth, originY row * cellHeight)。鼠标点击坐标换算row (pt.y - originY) / cellHeight; col (pt.x - originX) / cellWidth再判断是否越界。这套公式在 OnInitUpdate 和鼠标消息里保持一致就能避免坐标系错位。3. 核心细节解析从数据结构到重绘策略3.1 数据结构选择布尔矩阵还是集合对于格子填充状态最常见做法是使用二维数组。// GridData.h #pragma once #include vector class CGridData { public: CGridData(int rows, int cols); BOOL GetFillState(int row, int col) const; void SetFillState(int row, int col, BOOL bFilled); void ClearAll(); int GetRows() const { return m_rows; } int GetCols() const { return m_cols; } private: int m_rows; int m_cols; std::vectorstd::vectorBOOL m_cells; };// GridData.cpp #include GridData.h CGridData::CGridData(int rows, int cols) : m_rows(rows), m_cols(cols) { m_cells.resize(m_rows); for (int i 0; i m_rows; i) { m_cells[i].resize(m_cols, FALSE); } } BOOL CGridData::GetFillState(int row, int col) const { if (row 0 || row m_rows || col 0 || col m_cols) return FALSE; return m_cells[row][col]; } void CGridData::SetFillState(int row, int col, BOOL bFilled) { if (row 0 || row m_rows || col 0 || col m_cols) return; m_cells[row][col] bFilled; } void CGridData::ClearAll() { for (int i 0; i m_rows; i) { for (int j 0; j m_cols; j) { m_cells[i][j] FALSE; } } }如果只有少量格子需要填充也可以换成 std::setstd::pairint,int只保存“被填充”的格子遍历起来反而省内存。但我更推荐二维布尔数组因为绘制时要频繁按行列查询数组的随机访问是 O(1)而且思路直观排错容易。格子数量在几百上千量级时内存开销几乎可以忽略。3.2 绘制网格线GDI 性能和视觉细节都要兼顾绘制网格线是重绘部分最频繁的操作。如果你在 OnDraw 里用 MoveTo/LineTo 按像素一个个画线在格子很多时性能会急剧下降。推荐的做法是只画分界线也就是行方向画 (rows 1) 条水平线列方向画 (cols 1) 条垂直线。void CGridView::DrawGrid(CDC* pDC) { CPen penGrid(PS_SOLID, 1, RGB(180, 180, 180)); CPen* pOldPen pDC-SelectObject(penGrid); int originX m_margin; int originY m_margin; int cellW m_cellSize; int cellH m_cellSize; // 水平线 for (int row 0; row m_rows; row) { int y originY row * cellH; pDC-MoveTo(originX, y); pDC-LineTo(originX m_cols * cellW, y); } // 垂直线 for (int col 0; col m_cols; col) { int x originX col * cellW; pDC-MoveTo(x, originY); pDC-LineTo(x, originY m_rows * cellH); } pDC-SelectObject(pOldPen); }视觉上要注意网格线颜色不要用纯黑纯黑会喧宾夺主浅灰 RGB(180,180,180) 比较合适。填充色通常用浅蓝或淡绿色不要用太艳的颜色否则看久了眼睛累。还有一个小细节外边框线可以用更粗的画笔单独画一遍形成“表格外框明显、内部分隔线细淡”的层次感。3.3 填充格子的重绘策略全量重绘还是局部重绘点击一个格子后如果你直接调用 Invalidate()MFC 会重绘整个客户区在格子很多时会产生明显闪烁。更优的做法是只重绘被点击的那个格子区域。void CGridView::OnLButtonDown(UINT nFlags, CPoint point) { int col (point.x - m_margin) / m_cellSize; int row (point.y - m_margin) / m_cellSize; if (row 0 || row m_rows || col 0 || col m_cols) { CView::OnLButtonDown(nFlags, point); return; } BOOL oldState m_gridData.GetFillState(row, col); m_gridData.SetFillState(row, col, !oldState); // 只刷新这个格子的矩形区域 CRect rcCell( m_margin col * m_cellSize, m_margin row * m_cellSize, m_margin (col 1) * m_cellSize, m_margin (row 1) * m_cellSize); InvalidateRect(rcCell, FALSE); CView::OnLButtonDown(nFlags, point); }这里需要注意InvalidateRect 的第二个参数必须传 FALSE告诉系统不要擦除背景。如果传 TRUE系统会把该区域先刷成背景色再触发 WM_PAINT视觉上依然会出现短暂的白闪。配合背景刷子局部刷新的体验会非常顺畅。3.4 双缓冲网格多时的最后一道防线如果只是 20×20 的网格局部刷新就够了但如果你要做到 100×100 甚至更多仍然建议引入双缓冲。双缓冲的核心思路是在内存中创建一张和客户区一样大的位图先把所有内容画到这张位图上再一次 BitBlt 到屏幕。这样屏幕不会出现“绘制一半”的中间状态。void CGridView::OnDraw(CDC* pDC) { // 客户区大小 CRect rcClient; GetClientRect(rcClient); // 内存位图 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 背景色 memDC.FillSolidRect(rcClient, RGB(255, 255, 255)); DrawFilledCells(memDC); DrawGrid(memDC); // 一次性拷贝到屏幕 pDC-BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); bmp.DeleteObject(); memDC.DeleteDC(); }双缓冲的两个常见坑一是位图大小要随 WM_SIZE 变化而更新否则窗口拉大后会出现绘制区域不够的问题二是内存 DC 创建失败时要做好备份检查别直接 SelectObject 空对象否则会崩溃。我一般在 OnSize 里记录客户区尺寸并在 OnDraw 里根据最新尺寸创建位图稳妥很多。4. 实操过程从建立工程到跑通完整交互4.1 建立 MFC 工程与裁剪向导我用 VS2019 建一个 MFC 应用工程应用类型选“单个文档”项目风格选“Visual Studio”复合文档支持选“无”。工程名可以叫 GridFillerDemo。工程建好后我习惯关掉工具栏、状态栏等跟本项目无关的东西一方面界面干净另一方面减少向导生成代码的干扰。然后新建两个类CGridData数据层非 UI 类和 CGridView从 CView 派生负责绘制与交互。还有一个很容易被忽略的步骤在 CGridFillerDemoDoc 里添加 CGridData 成员。我通常是让 Document 拥有数据对象View 通过 GetDocument() 获取数据指针这样天然支持“新建文档重新生成网格”的逻辑。4.2 在 OnDraw 里完整渲染一个格子现在把渲染分三步走先填充被标记的格子再绘制网格线最后绘制外边框。完整 OnDraw 的核心代码如下void CGridView::OnDraw(CDC* pDC) { CGridFillerDemoDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; CGridData data pDoc-GetGridData(); CRect rcClient; GetClientRect(rcClient); // 双缓冲准备 CDC memDC; memDC.CreateCompatibleDC(pDC); CBitmap bmp; bmp.CreateCompatibleBitmap(pDC, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 背景 memDC.FillSolidRect(rcClient, RGB(255, 255, 255)); // 1. 绘制填充格 CBrush fillBrush(RGB(173, 216, 230)); // 浅蓝 CBrush* pOldBrush memDC.SelectObject(fillBrush); CPen fillPen(PS_SOLID, 1, RGB(173, 216, 230)); CPen* pOldPen memDC.SelectObject(fillPen); for (int r 0; r data.GetRows(); r) { for (int c 0; c data.GetCols(); c) { if (!data.GetFillState(r, c)) continue; CRect rcCell( m_margin c * m_cellSize, m_margin r * m_cellSize, m_margin (c 1) * m_cellSize, m_margin (r 1) * m_cellSize); memDC.Rectangle(rcCell); } } memDC.SelectObject(pOldBrush); memDC.SelectObject(pOldPen); fillBrush.DeleteObject(); fillPen.DeleteObject(); // 2. 绘制网格线 DrawGrid(memDC); // 3. 绘制外边框加粗 CPen outerPen(PS_SOLID, 2, RGB(80, 80, 80)); CPen* pOldOuter memDC.SelectObject(outerPen); CRect rcOuter( m_margin, m_margin, m_margin data.GetCols() * m_cellSize, m_margin data.GetRows() * m_cellSize); memDC.MoveTo(rcOuter.left, rcOuter.top); memDC.LineTo(rcOuter.right, rcOuter.top); memDC.LineTo(rcOuter.right, rcOuter.bottom); memDC.LineTo(rcOuter.left, rcOuter.bottom); memDC.LineTo(rcOuter.left, rcOuter.top); memDC.SelectObject(pOldOuter); // 4. 位图一次性上屏 pDC-BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); bmp.DeleteObject(); memDC.DeleteDC(); }这一段代码是“格子填充”的可视化核心。有一个细节填充矩形时我用的是 Rectangle 而不是 FillRect是因为 Rectangle 同时会画边框配合浅蓝背景能让填充格看起来更有立体感。如果你不希望填充格有额外边界换成 FillRect 更好。4.3 鼠标事件点击、批量拖拽填充与右键清除除了单击切换状态之外我还加了一个很实用的功能按住鼠标左键拖动可以批量填充经过的格子。这在地图编辑器场景里几乎是刚需。实现方式是在 OnLButtonDown 里记录“处于拖拽填充状态”在 OnMouseMove 里判断左键是否按下如果是就对当前格子执行填充。同时为了保证拖拽过程中格子状态一致我在 OnMouseMove 里会比较当前格子和上次格子只有变化了才做处理。void CGridView::OnLButtonDown(UINT nFlags, CPoint point) { SetCapture(); int col (point.x - m_margin) / m_cellSize; int row (point.y - m_margin) / m_cellSize; if (row 0 || row m_rows || col 0 || col m_cols) { CView::OnLButtonDown(nFlags, point); return; } // 记录点击时拖拽将要采用的填充状态 m_bDragging TRUE; m_bDragFillState !m_gridData.GetFillState(row, col); m_lastRow row; m_lastCol col; SetCellState(row, col, m_bDragFillState); CView::OnLButtonDown(nFlags, point); } void CGridView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDragging) { int col (point.x - m_margin) / m_cellSize; int row (point.y - m_margin) / m_cellSize; if (row 0 row m_rows col 0 col m_cols) { if (row ! m_lastRow || col ! m_lastCol) { SetCellState(row, col, m_bDragFillState); m_lastRow row; m_lastCol col; } } } CView::OnMouseMove(nFlags, point); } void CGridView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDragging) { m_bDragging FALSE; ReleaseCapture(); } CView::OnLButtonUp(nFlags, point); }右键清除是另一个顺手加上的交互。在 OnRButtonDown 里把当前格子状态设置为 FALSE 并局部刷新即可。如果你还想支持“按住右键拖拽清除”模式和左键拖拽填充完全一致只需再增加一个 m_bRightDragging 标志位。右键菜单也可以在这里扩展比如“清除全部”和“填充全部”。我加了一个简单的 CMenu 右键菜单响应命令时调用 m_gridData.ClearAll() 或遍历全部置 TRUE然后 Invalidate() 全量刷新。这个交互在很大程度上提升了这个小工具的实用性。4.4 初始化与动态更新网格参数从哪里来网格的行列数、格子大小我建议在文档初始化时统一设置。// CGridFillerDemoDoc::OnNewDocument BOOL CGridFillerDemoDoc::OnNewDocument() { if (!CDocument::OnNewDocument()) return FALSE; m_gridData.Init(20, 20, 30); return TRUE; }我在 CGridData 里会把行列数注释为“可以在未来通过对话框修改”。如果你要做参数可调版本最简单的方式是加一个“设置”对话框输入行列数和格子大小然后调用 m_gridData.Init(rows, cols, cellSize)最后 View 里 Invalidate()。注意 View 里之前缓存的 m_rows、m_cols、m_cellSize 等成员变量也要同步更新否则绘制和点击换算会用不同的参数导致点不中格子。我吃过一次亏数据层已经更新成 30×30但视图层 m_rows 还停留在 20结果前 20 行正常后 10 行怎么点都没反应。后来统一改为“每次绘制和交互都从数据层读行数列数”没有再出现过类似问题。5. 常见问题与排查技巧实录5.1 画面闪烁严重怎么定位和解决网格数量一多全量 Invalidate() 会带来严重闪烁。定位方法很简单先在 OnDraw 入口和出口分别用 OutputDebugString 或断点确认是否被高频调用再看是否每次都是整窗重绘。解决方案按优先级从低到高排列局部刷新点击单个格子只 InvalidateRect 这个格子范围双缓冲在内存 DC 中完成所有绘制再 BitBlt 上屏减少 GDI 对象创建画笔、画刷不要在 OnDraw 里反复创建尽量用成员变量或缓存。其中第三点很容易被忽略。GDI 对象创建是有系统资源限制的如果在 OnDraw 里每次循环都 new CPen很快就会达到 GDI 对象上限默认每进程 10000 个导致绘制异常甚至程序崩溃。我习惯把这些对象定义为视图类成员只在构造函数里创建一次析构时清理。5.2 坐标换算误差为什么点击位置总差一两个像素常见原因是没考虑窗口边框和边距。GetClientRect 拿到的是客户区坐标而鼠标消息里的 point 也是客户区坐标两者基准一致所以问题通常出在你自己设置 margin 和 cellSize 时忘了统一。另一个隐蔽的错误是用 int 除法时负坐标会被截断成 0。比如 row (point.y - originY) / cellHeight 中如果 point.y originY结果是负整数但除法的绝对值小于 1int 会截断为 0导致“点击网格上方空白区域却选中第一行格子”。解决方法是先判断 (point.x - originX) 和 (point.y - originY) 是否小于 0越界就直接退出不要参与索引计算。5.3 刷新后填充色残留或丢失出现“填充状态丢失”通常是数据层没有正确更新常见原因是视图里的 m_gridData 与文档里的 m_gridData 不是同一个对象。如果视图里用了拷贝构造或者临时对象数据不会回流到文档。出现“填充色残留”则通常是背景刷子设置问题。MFC 默认会用白色背景擦除整个客户区如果你设置了自定义背景刷子并且 InvalidateRect 的 bErase 参数为 TRUE系统会用背景刷子擦除被刷区域可能把部分填充色“擦掉”也可能因为刷子和重绘内容不一致导致残影。建议统一背景逻辑在 OnEraseBkgnd 里直接返回 TRUE不让系统擦除由 OnDraw 自己 FillSolidRect 背景色这样背景和内容完全可控。5.4 常见问题速查表问题现象常见原因解决方案点击格子无响应坐标换算越界row/col 超出范围后直接 return在换算前判断负值在换算后判断大于行列数的值并 OutputDebugString 打印索引画面闪烁严重全量 Invalidate 无双缓冲改局部刷新 InvalidateRect再叠加双缓冲方案填充格颜色异常画刷生命周期管理错误使用了已删除的 GDI 对象画刷用成员变量或局部对象但保证使用前有效拖拽填充出现“跳跃断点”移动过快OnMouseMove 采样间隔内跨越多个格子在 OnMouseMove 中在上一格到当前格之间做线性插值填充或至少记录每帧渲染覆盖范围窗口拉大后绘制区域只占一部分OnDraw 使用了固定客户区尺寸或未处理 WM_SIZE在 OnSize 中更新客户区尺寸成员变量并在 OnDraw 中读取最新值GDI 对象泄漏每次 OnDraw 都创建画笔画刷且未删除用成员变量缓存 GDI 对象析构函数统一清理5.5 拖拽填充的“插值”优化上面表格里“跳跃断点”问题我值得展开说说。快速拖拽鼠标时Windows 并不会给你每一个像素的 MouseMove 消息如果鼠标移动速度特别快两次消息之间可能跨过好几个格子。如果不做处理填充出来的轨迹会是一串断点非常难看。我的解决思路是在 OnMouseMove 里做 Bresenham 直线插值已知上一格坐标 (r0, c0) 和当前格坐标 (r1, c1)用直线算法把中间经过的格子都填充上。这样即使鼠标瞬间移动也不会漏格子。关键代码如下void CGridView::FillLineCells(int r0, int c0, int r1, int c1, BOOL bState) { int dr abs(r1 - r0); int dc abs(c1 - c0); int sr (r0 r1) ? 1 : -1; int sc (c0 c1) ? 1 : -1; int err dr - dc; while (true) { SetCellState(r0, c0, bState); if (r0 r1 c0 c1) break; int e2 2 * err; if (e2 -dc) { err - dc; r0 sr; } if (e2 dr) { err dr; c0 sc; } } }这段插值逻辑是拖拽填充体验“丝滑”的关键。如果你只是做单击填充可以暂时不实现它但建议把函数位置预留出来后面加功能时直接调用。6. 收尾几个让我省下大量调试时间的小习惯做这种自绘功能我几次踩坑之后总结出几个习惯写在这里供参考。第一所有网格参数原点、格子尺寸、行列数不要在 OnDraw 里直接硬编码而要用成员变量或数据层接口统一提供。这样排查坐标问题时只需要在两个地方确认数值是否一致而不是满文件找魔法数字。第二尽早引入 OutputDebugString 或 TRACE 打印点击坐标和行列索引。很多时候“点不到格子”不是绘制问题而是数据换算问题。打印一行日志就能立刻知道换算结果是否符合预期比打断点效率高很多。第三如果你在 OnDraw 里做了比较复杂的状态分支建议把绘制函数拆成几个独立的小函数比如 DrawFilledCells、DrawGrid、DrawOuterBorder。每个函数只负责一件事维护和排错都会轻松很多。原始需求越简单越容易写出“一次性代码”但这个“越简单越容易烂”的坑我是真的踩过不少次。拿这个“VC 绘制格子格子可以填充”的小项目来讲它的价值不只是会画一个棋盘而是它完整覆盖了自绘控件开发的常规链路数据建模、坐标换算、GDI 绘制、消息交互、局部刷新、双缓冲优化。把这条链路跑通之后你再去做地图编辑器、排班表格、棋盘游戏地图编辑核心思路基本就是同一套。后面想扩展的话可以考虑把格子数据做成可保存的文件格式或者加入撤销重做栈这些都是建立在当前结构之上的良性演进方向。本文还有配套的精品资源点击获取