ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MFC网格控件CGridCtrl实战:接入、配置与避坑指南

MFC网格控件CGridCtrl实战:接入、配置与避坑指南

简介:面向MFC界面开发者的可直接使用的表格控件资源,提供全套源码且无需注册即可集成到项目中。相比MSFlexGrid在大数据量时容易报错,该控件内置虚拟缓冲区模式,实测可流畅显示2000×2000规模的虚拟表格,适合数据报表、管理后台等需要高性能表格展示的Visual C++项目。压缩包共90个文件,约422KB,包含27个头文件、23个cpp源文件以及多组工程文件,覆盖VC6、VS2003、VS2008直至VS2013的示例与修改版,另有图标、位图等界面资源和可直接运行的demo程序。还附带GridCtrlDemo和GridView两个完整示例工程,便于对照学习控件的派生封装、单元格类型、就地编辑及视图集成方法。已有4768人学习下载,适合有一定MFC基础、希望摆脱MSFlexGrid限制的开发者直接取用,也可作为表格控件二次开发的起点。

1. 网格控件是什么:先解决 MFC 表格的“天天造轮子”问题

很多人在 MFC 里做表格,第一反应是拖一个 CListCtrl 进来,然后写几十行代码处理列头排序、单元格编辑,最后发现还要自己管理一个代理编辑框的位置和销毁,越写越像在重新造一个表格控件。标题里说的“非常好用的表格控件”,我理解的是开源社区里流传最广的 CGridCtrl 这类网格控件——它把行号、列头、可编辑单元格、排序、合并、虚拟模式都封装在一个 CWnd 派生类里,接入对话框后开箱即用。本文按我做过的选型和接入过程讲清楚:它和原生控件差在哪、怎么接、参数怎么设、有哪些坑,最后告诉你哪种场景下值得为它投入。

2. 控件选型:原生 CListCtrl 的边界和网格控件的取舍

2.1 原生 CListCtrl 的边界:报告模式表格为什么总差一步

先明确一个判断:CListCtrl 在报告模式下做“列表”是没问题的,但做“表格”会处处别扭。最典型的问题是单元格编辑。CListCtrl 本身没有编辑能力,必须自己创建一个 CEdit 子窗口,在 LVN_BEGINLABELEDIT 里弹出来,在 LVN_ENDLABELEDIT 里收数据,然后销毁它。这个流程单看一次不难,但要在鼠标点击、滚动、列宽调整时同步编辑框位置,代码量立刻上去。

列头排序是第二个硬伤。CListCtrl 只给了 LVM_SORTITEMS 和排序回调,回调里拿到的只是两个行索引,你要自己去维护一个“行索引到数据对象”的映射表,才能把排序后的行对应回业务数据。数据一变、行一删,这个映射表就得同步维护,属于典型的隐藏成本。

第三个边界是样式控制。CListCtrl 想单独给某个单元格设置背景色、字体、图标,必须走自绘,而自绘一旦开了,所有单元格的绘制逻辑都归你管。合并单元格、行头显示、按行高亮这些需求,在 CListCtrl 里基本等于重写一个控件。如果你只是显示数据、不做编辑,CListCtrl 完全够用;一旦需求表里出现“可编辑”“合并”“排序带状态”,就该换方案了。

2.2 CGridCtrl 这类网格控件做了什么:单元格模型和通知机制

CGridCtrl 本质上是一个自绘网格窗口,它把表格拆成“固定行(列头)、固定列(行号)、数据区”三部分,每个单元格由 CGridCellBase 派生对象管理自己的绘制、编辑、状态。这套模型的好处是:你不用维护两个界面状态(一个控件显示、一个数据模型),改单元格内容就是 SetItemText,读内容就是 GetItemText,和操作数组差不多。

它“好用”的核心还在于通知机制。排序时它不自己乱排,而是发出 GVN_SORTCOLUMNCLICK 通知,把被点击的列号传给你,数据怎么排由你决定;编辑结束时发出 GVN_ENDEDIT,把行列号传给你,再由你把新值写回业务结构。这和 CListCtrl 的“控件主动做一半、留下一半让你猜”是完全不同的设计思路。理解这个模型,后面所有配置就有章可循。

2.3 选型建议:什么场景值得换成网格控件

需求原生 CListCtrl 报告模式CGridCtrl 类开源网格商业界面库网格
纯展示、无编辑够用略重不必要
需要单元格编辑自己套 CEdit,维护成本高内置编辑,开箱即用好用但需授权
大数据量(十万行级)虚拟列表可撑,代码复杂虚拟模式回调,简单性能强
列头自动排序自己处理映射通知回调,逻辑清晰自带
合并单元格不支持支持支持
成本无开源免费按授权收费

我的习惯是:项目里只要出现“单元格编辑”或“合并”任意一个关键词,就直接上 CGridCtrl 类网格,不在 CListCtrl 上做二次开发。因为二次开发的量往往比引入一个成熟 Grid 控件更大,而且后续维护都在自己头上。如果你有预算、也确实需要更复杂的打印和样式,商业控件是另一个方向;但就“免费、可控、社区资料多”这三个条件看,CGridCtrl 这类开源网格在 MFC 项目里仍然是主流选择。

3. 把网格控件接进 MFC 工程:从源码到对话框的完整步骤

3.1 源码接入:文件加到工程和编译前的准备

这个控件是以源码形式使用的,所以第一步是把相关文件放进工程。常见的做法是新建一个 GridCtrl 目录,把控件源码文件拷进去,然后在 Visual Studio 工程里通过“添加现有项”把它们加进项目。编译之前要先确认两件事:工程字符集是 Unicode 还是多字节,以及是否定义了和编译器版本相关的开关宏。源码里如果有针对不同 VS 版本的宏开关,按你用的版本设置即可,我用 VS2013 到 VS2019 都编过,没遇到必须要改代码的情况。

// GridCtrl.h 中的典型声明位置 #pragma once #include "GridCtrl.h" // 主头文件,通常在 Demo 工程里也引用它

加完文件后编译一次,解决头文件路径问题。常见的报错是找不到 GridCtrl.h,这时在项目属性 C/C++ 常规 -> 附加包含目录里加上源码所在目录即可。另一个容易忽略的点:如果工程启用了预编译头,而源码文件没有按预编译头规范写,会出现强制包含头文件不匹配的警告,把那些文件设置为“不使用预编译头”就行。

3.2 在对话框里落地:Custom Control 和 DDX 绑定

接入对话框有两种常见做法:一种是在资源编辑器里拖一个 Custom Control,把类名改成 CGridCtrl;另一种是完全在代码里动态 Create。我一般用前者,因为可以在对话框模板上直接把位置拖好,所见即所得。Custom Control 的类名要填 CGridCtrl,ID 设成一个有意义的资源 ID,比如 IDC_MY_GRID。

// 在对话框类的头文件里,声明网格控件成员 class CMyDialog : public CDialogEx { // ... protected: CGridCtrl m_gridCtrl; // 网格控件成员变量 }; // 在 DoDataExchange 中完成绑定 void CMyDialog::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Control(pDX, IDC_MY_GRID, m_gridCtrl); }

这里 DDX_Control 的作用是把模板上那个 Custom Control 和 CGridCtrl 成员变量关联起来,控件窗口创建后会自动 attach 到 m_gridCtrl。要注意它必须在 OnInitDialog 之前执行,因为模板控件的 CWnd 句柄是在 DoDataExchange 阶段挂接的。之后你在 OnInitDialog 里就可以直接调用网格控件的各种方法。

3.3 最小初始化:SetGridDefaults 之后先设置行列结构

控件窗口创建好之后,第一步永远先调用 SetGridDefaults(),它会设置默认字体、网格线颜色、默认行高列宽、固定行数量等内部参数。之后你再调整列数、行数、列宽,都是在默认基础上覆盖。如果你跳过这一步直接 SetColumnCount,部分版本的控件内部状态没有初始化,会出现列头不显示或者单元格绘制异常的玄学问题。

BOOL CMyDialog::OnInitDialog() { CDialogEx::OnInitDialog(); // 先初始化控件默认状态,再覆盖具体配置 m_gridCtrl.SetGridDefaults(); // 表格结构:5 列,固定 1 行作为列头,数据行 19 行 m_gridCtrl.SetColumnCount(5); m_gridCtrl.SetFixedRowCount(1); m_gridCtrl.SetRowCount(20); // 列宽按像素设置 m_gridCtrl.SetColumnWidth(0, 100); m_gridCtrl.SetColumnWidth(1, 140); m_gridCtrl.SetColumnWidth(2, 160); m_gridCtrl.SetColumnWidth(3, 80); m_gridCtrl.SetColumnWidth(4, 120); // 填充列表头文字,注意行号从 0 开始 m_gridCtrl.SetItemText(0, 0, _T("设备名")); m_gridCtrl.SetItemText(0, 1, _T("状态")); m_gridCtrl.SetItemText(0, 2, _T("所在位置")); m_gridCtrl.SetItemText(0, 3, _T("数量")); m_gridCtrl.SetItemText(0, 4, _T("备注")); // 填充第一行数据 m_gridCtrl.SetItemText(1, 0, _T("循环泵")); m_gridCtrl.SetItemText(1, 1, _T("运行中")); m_gridCtrl.SetItemText(1, 2, _T("1号车间")); m_gridCtrl.SetItemText(1, 3, _T("2")); m_gridCtrl.SetItemText(1, 4, _T("备用机已就绪")); return TRUE; }

这段代码里你只需要往 SetItemText 的行列索引里填数据。索引规则一定要记牢:第 0 行是固定列表头,所以第一条数据从第 1 行开始,这个 1 偏移是新手最常见的出错点。SetColumnWidth 的单位是像素,不是字符数,所以当你发现列宽显示比预期窄很多时,先检查是不是把它当成了字符数。列数超过实际内容宽度时,控件会自动出现横向滚动条。整个初始化没有触发任何重绘,因为所有配置都发生在控件可见之前。

4. 让表格真正能干活:排序、编辑和行列操作的参数设置

4.1 列头排序:用 GVN_SORTCOLUMNCLICK 通知接住点击事件

CGridCtrl 默认不替你排序,它只在你点击列头时发出 GVN_SORTCOLUMNCLICK 通知。这个设计一开始会让人觉得“怎么还要自己写”,但实际用下来会发现这是对的:你的数据存在哪、是结构体数组还是数据库结果集,只有你自己知道,控件无法替你决定排序算法。你要做的,是在消息映射里接住这个通知,然后对业务数据排序,再回填到控件。

// 消息映射部分添加通知响应 BEGIN_MESSAGE_MAP(CMyDialog, CDialogEx) ON_NOTIFY(GVN_SORTCOLUMNCLICK, IDC_MY_GRID, &CMyDialog::OnGridSortColumn) END_MESSAGE_MAP() // 排序响应函数 void CMyDialog::OnGridSortColumn(NMHDR* pNotifyStruct, LRESULT* pResult) { NM_GRIDVIEW* pView = (NM_GRIDVIEW*)pNotifyStruct; int nCol = pView->iColumn; // 用户点击的列号 // 这里对你的业务数据结构做排序,以 m_vecData 为例 // std::sort(m_vecData.begin(), m_vecData.end(), [nCol](...){ ... }); // 排序后重新回填到控件 RefreshGridFromData(); *pResult = TRUE; // 返回 TRUE 表示排序逻辑由外部处理 }

这里 NM_GRIDVIEW 结构的 iColumn 字段保存了被点击的列号。比较函数里按列号从业务对象中取出对应字段,字符串转数字再比较,能避免“10”排在“9”前面的字典序问题。排序完成后调用 RefreshGridFromData 统一回填。注意 *pResult 必须设为 TRUE,否则部分版本会继续执行它内部的一套排序逻辑,造成数据和界面顺序不一致。

4.2 单元格编辑:打开编辑状态和控制只读列

网格控件的编辑能力默认是打开的,用户双击或按 F2 就能进入单元格编辑。这个默认行为在大部分业务场景下没问题,但有两种情况需要调整:列表头不能被编辑,某些业务字段不允许用户改。列表头因为是固定行,编辑状态本身不受影响,但你可以统一把固定行设成只读。

// 使整个数据区域可编辑(默认就是 TRUE,写明是为了清晰) m_gridCtrl.SetEditable(TRUE); // 第 0 行列头全部只读,防止用户误改表头 for (int col = 0; col < m_gridCtrl.GetColumnCount(); col++) { m_gridCtrl.GetCell(0, col)->SetReadOnly(TRUE); } // 数据行的“数量”列(第 3 列)只读,数量由后台计算得出 for (int row = 1; row < m_gridCtrl.GetRowCount(); row++) { m_gridCtrl.GetCell(row, 3)->SetReadOnly(TRUE); }

GetCell 返回的是该单元格的 CGridCellBase 指针,SetReadOnly 直接控制该格的编辑状态。这个粒度比全表开关更符合真实业务:大部分字段可编辑,个别字段锁定。编辑结束后要接住 GVN_ENDEDIT 通知,把新值同步回业务数据,否则用户改了界面、程序里拿到的还是旧数据,这个属于比较隐蔽的数据一致性问题,后面避坑章节会展开。

4.3 合并单元格与行列尺寸:SetItemSpan 和动态调整

合并单元格是 CListCtrl 给不了的能力,做报表、排产计划这类需求时很关键。网格控件里用 SetItemSpan 实现:参数是起始行、起始列、跨行数、跨列数。合并后,文字只写在最左上角的单元格里,其他被合并的区域保持空白,否则会出现文字叠加的效果。

// 把第 1 行第 0 列合并成跨 1 行 2 列的区域 m_gridCtrl.SetItemSpan(1, 0, 1, 2); m_gridCtrl.SetItemText(1, 0, _T("这是一个合并单元格")); // 注意合并区域里的第二个单元格不要再 SetItemText,否则绘制会重叠

行列尺寸的动态调整在日常需求里比合并更常见。程序运行中把某一行行高调大以显示完整内容,或者根据内容宽度自动调整列宽,这些都可以直接操作。

// 动态调整行高,单位是像素 m_gridCtrl.SetRowHeight(3, 30); // 根据内容宽度调整第 1 列的列宽,AutoSizeColumn 传入列号 m_gridCtrl.AutoSizeColumn(1); // 禁用用户拖拽调整列宽(在某些报表场景需要固定列宽) m_gridCtrl.EnableColumnResize(FALSE);

SetRowHeight 和 SetColumnWidth 的参数都以像素为单位。AutoSizeColumn 会用当前所有行的内容宽度去计算,但如果数据量很大,这个方法的性能会随行数上升明显变慢,适合行数几千以内的表格。用户拖拽列宽是默认行为,报表锁定 Layout 时再关掉,否则你会收到“用户把列拖没了”的求助。

5. 常见问题与避坑:从显示翻车到数据错乱的四条实战记录

5.1 排序后滚动条位置错乱,界面和数据对不上

现象:点击列头排序后,数据顺序变了,但滚动条滑块位置停在原地,看起来像是“跳到了一条随机数据上”。如果此时用户再点击某一行,选中行和实际数据对不上,导出的数据顺序也和界面不一致。

原因:排序回填数据时,没有把滚动条位置和当前选中行复位。控件内部的行状态在数据重填后仍然停留在排序前的坐标,于是界面坐标和数据内容的映射关系错位。

解决:排序回调里,在刷新数据前先记录当前行号,刷新完成后把选中行复位,再把滚动条滚到顶部或保持原行位置。

int nCurRow = m_gridCtrl.GetSelectedRow(); // 排序前记住选中行 int nScrollPos = m_gridCtrl.GetScrollPos(SB_VERT); // 记住滚动位置 RefreshGridFromData(); // 回填排序后的数据 m_gridCtrl.SetSelectedRow(nCurRow); // 重新选中同一业务行 m_gridCtrl.SetScrollPos(SB_VERT, nScrollPos); // 恢复滚动位置

SetSelectedRow 在某些版本会有选中状态闪烁,属正常现象。如果你的排序是按业务主键排的,建议用主键来找回原行,而不是用行号,因为行号在排序后已经不是原来的行号了。

5.2 网格背景变成“十字准线”,单元格刷新闪烁

现象:鼠标在网格上一移动,整个表格区域频繁重绘,出现明显的闪烁,甚至能看出一个十字光线跟着鼠标跑。在某些低端机器或高分屏缩放下尤其严重。

原因:默认配置下控件会跟踪鼠标位置并高亮当前行和当前列,这个“热点跟踪”功能会触发整行整列重绘。加上控件没有启用双缓冲,重绘一密集,闪烁就出来了。

解决:如果只是做数据展示,直接关掉热点跟踪;如果确实需要高亮选中行,打开双缓冲模式。

// 关闭鼠标热点跟踪,减少重绘次数 m_gridCtrl.SetTrackFocusCell(FALSE); m_gridCtrl.SetHighlightSelected(TRUE); // 仅高亮选中单元格 // 打开双缓冲,避免重绘闪烁 m_gridCtrl.SetDoubleBuffer(TRUE);

这里要区分 SetHighlightSelected 和热点跟踪的区别:前者只高亮当前选中的格子,后者会时刻高亮鼠标所在的整行整列,后者的绘制开销大得多。双缓冲会多占一块内存显存,但在表格行列多的时候体验提升非常明显。

5.3 编辑中文内容后乱码,英文数字正常

现象:用户双击单元格输入“设备编号”四个字,回车后界面显示变成乱码;输入英文字母和数字一切正常。项目字符集切到 Unicode 时问题反而更隐蔽,某些列乱码某些列正常。

原因:工程字符集设置和代码里的文本字面量不一致。多字节下用 _T 宏但文件保存为 UTF-8,或 Unicode 下源码文件没有带 BOM,都会造成字符串在编译期被错误解释。另一个典型来源是 GetItemText 返回的 CString 在传给数据库或串口时做了窄字节转换。

解决:统一工程字符集为 Unicode,源码文件用 Visual Studio 默认编码保存,所有字符串字面量用 _T() 包裹。从控件取文本需要转成多字节时,用 CW2A 转换而不是直接强转。

// 从控件取文本,统一转成 UTF-8 再写入数据库 CString strItem = m_gridCtrl.GetItemText(nRow, nCol); CStringA strUtf8 = CW2A(strItem, CP_UTF8); // 然后把 strUtf8 交给数据层

如果项目为了兼容老模块而必须用多字节字符集,那所有源文件保持 ANSI 编码,字符串字面量不要写中文,改用资源表里的字符串。中文字符乱码的本质是“编译期字符集”“运行期代码页”“文件编码”三者不一致,先统一工程字符集,再检查文件编码,不要靠写转换代码去掩盖问题。

5.4 往表格里插入几千行数据时界面卡死

现象:程序启动时用循环 SetItemText 填充 5000 行、10 列的数据,执行时间在调试版里超过 5 秒,Release 版也有明显停顿,窗口不刷新,鼠标放到窗口上是转圈状态。

原因:每调用一次 SetItemText,控件都会触发一次单元格重绘和内部状态更新。5000 行乘 10 列就是 5 万次重绘,而其中绝大多数重绘对最终显示结果没有意义。这是典型的“逐格填充”造成的性能损耗,不是控件本身在大数据量下不行。

解决:填充数据前用 SetRedraw(FALSE) 关闭重绘,填充完成后统一打开重绘并刷新一次。这是 MFC 里处理批量 UI 更新的通用做法,用在网格控件上同样有效。

m_gridCtrl.SetRedraw(FALSE); // 开始批量填充前关闭重绘 for (int row = 1; row <= 5000; row++) { for (int col = 0; col < 10; col++) { m_gridCtrl.SetItemText(row, col, BuildData(row, col)); } } m_gridCtrl.SetRedraw(TRUE); // 重新允许绘制 m_gridCtrl.Invalidate(); // 请求一次整体重绘,把缓冲内容画上去

SetRedraw(FALSE) 期间对网格控件的任何操作都只更新内部数据,不画到屏幕上。Invalidate 放在 SetRedraw(TRUE) 之后,保证重绘生效时拿到的是一整份完整数据。这个技巧能解决大部分“插入数据慢”的问题,它的边界在十万行以上——那时就算关掉重绘,控件的内部数据结构本身也会成为瓶颈,需要走下一章的虚拟模式。

6. 进阶:虚拟模式扛住十万行数据,单元格类型还能换控件

虚拟模式是网格控件面对大数据量时的关键设计。开启后,控件自身不再保存每个单元格的文本,只在需要显示某一行时回调你来取数据。这样即使有百万行,内存占用也只是列头加少量可见行的数据量。实现方式是从 CGridCtrl 派生一个新类,重写 OnCellGetText 回调。

// 自定义网格类,头文件中声明 // class CBigDataGrid : public CGridCtrl { ... }; CString CBigDataGrid::OnCellGetText(int nRow, int nCol) { // 真正数据在外部容器里,这里只做行号到数据的映射 CString strRet; strRet.Format(_T("%d"), m_pData->GetValue(nRow, nCol)); return strRet; } // 在对话框中使用虚拟模式 m_gridCtrl.SetVirtualMode(TRUE); m_gridCtrl.SetRowCount(1000000); // 虚拟模式下行数只是一个声明,不占内存 m_gridCtrl.SetColumnCount(10);

开启虚拟模式后,SetItemText 不再有意义,因为控件已经把存储职能交还给了你,只剩显示层。这会带来一个使用习惯上的转变:不能再按“填充数据”的思路,而要把所有数据访问改成按行号索引的随机取数。排序在这种模式下也要自己做数据层排序,然后调用 SetRowCount 重置行数触发界面刷新。

网格控件还能把单元格从“文本框”替换成其他编辑控件,比如把某列变成下拉框,这样可以省去自己处理子控件定位的麻烦。

// 把第 3 列的所有单元格设为组合框类型 for (int row = 1; row < m_gridCtrl.GetRowCount(); row++) { m_gridCtrl.SetCellType(row, 3, RUNTIME_CLASS(CGridComboBoxCell)); }

SetCellType 的第三个参数传 RUNTIME_CLASS 指定单元格运行时类型。切换后,该列在编辑时会自动弹出一个下拉框,点击其他列又恢复普通显示,子控件的创建、位置同步都是网格控件内部处理的。这一招在“状态”“优先级”这类固定枚举值字段上特别好用,既约束了输入范围,又不用为每一列单独挂子控件。

我有一段印象很深的经历:某次做一个排产模拟表,一开始没用虚拟模式,直接往里面灌 8 万行数据,界面卡到拖不动,后来切到虚拟模式再把数据层做上索引,滚动流畅度完全变了一个档次。所以我现在接任何网格控件,都会先问一句“这个表最大可能到多少行”,超过 5 万行就直接走虚拟模式,绝不抱着“以后再说”的心态留性能债。网格控件用得顺不顺,很大程度上取决于你把它当“显示控件”还是“数据表格”来用——数据和界面分离,边界划清楚,坑会少很多。希望这篇笔记能帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进