简介:《MFC DataGrid控件用法详解》面向使用VC/MFC开发桌面数据库程序的开发者,聚焦DataGrid网格控件的数据展示与交互控制,帮助解决查询结果绑定、列宽调整、内容格式化及事件响应等问题。资源包共1个doc文件,约226KB,以单一文档集中整理关键代码片段与实现说明,便于在VC6.0等环境中对照查阅;已有851人学习或浏览。文档以ADO记录集绑定DataGrid为主线,说明如何显示查询结果,并介绍通过列对象设置列宽、将小数格式化为百分数、实现多行显示,以及利用列标题点击消息处理排序等交互思路。内容还涉及在对话框工程中插入DataGrid控件并选择封装类的准备过程,偏重控件接口调用与示例代码理解,适合具备一定MFC基础、希望在项目中快速上手DataGrid的开发者参考。
1. 结论先给:DataGrid 是个“空壳子”,它显示的数据根本不归它管
很多人第一次用 MFC 里的 DataGrid,是把它当成“表格控件”来学的:设列宽、设格式、点表头排序。可真上手就会发现一个反直觉的事实:这个控件内存里压根没有一行数据。它能滚动几万行、翻页也不卡,是因为它手里攥着数据源那一侧的一个 COM 接口指针,每回重绘,就顺着游标去问底层要当前要画的那几格。理解了“数据在别人家、控件只是门面”,后面那些看着玄学的行为——数据源一析构就崩、SetNumberFormat导出时还是原值、换数据库必须重新绑定——全都变成了顺理成章的推论。这套“数据与显示分离”的边界,是搞懂整个 DataGrid 用法体系的关键。
2. 数据上屏的那一瞬间:SetRefDataSource到底交接了什么
2.1 传进去的是个IUnknown,不是一份数据
看这行代码:
m_ctrlDG.SetRefDataSource((LPUNKNOWN)m_Rs.GetRecordset());签名里收的是LPUNKNOWN,这个类型信息量很大。它意味着控件并不认识“记录集”这个概念,它只接受一个未知接口指针,然后自己内部QueryInterface出去,问这个对象到底支持哪些接口。如果对方恰好支持它需要的那一套行集、游标接口,绑定就成立;不支持,绑上去也是一张空表格,而且不报错。
所以这行代码的语义不是“把数据拷进控件”,而是“把这个对象的地址告诉控件,以后你自己去要”。这是全篇第一个必须刻进脑子里的结论:绑定的本质是接口交接,不是数据复制。
由此推出一条硬约束,也是后面“排查”一节里最常见的事故来源:
提示:
Recordset对象的生命周期必须严格长于控件的使用期。绑在栈上、绑完就析构的写法,等价于把一个已释放的地址交给控件,之后任何一次重绘都可能直接崩掉。
2.2 那些CDataGrid、CColumns、CColumn包装类,只是 IDispatch 的门面
插入控件时,向导会让你勾选要包装的类。它生成的其实是几个从CWnd派生的 MFC 类,每个类内部持有一个LPDISPATCH,每个成员函数体里最终都是一次IDispatch::Invoke,参数被打包进DISPPARAMS,返回值再从VARIANT里解出来。
// 生成类内部大致是这个形状(示意,非控件源码) class CColumn : public COleDispatchDriver { public: void SetWidth(float fWidth); // 内部 Invoke(DISPID, ...) void SetNumberFormat(LPCTSTR fmt); // 同上 // ... 每个方法对应一个 DISPID };这解释了原文里那句“你不可能直接看到控件源码”——ActiveX 基于 COM,你拿到的永远是接口,不是实现。但它同时也给了一个非常实用的调试技巧:方法名、属性名和事件名本身就是接口契约。SetWidth、SetNumberFormat、GetColumns、HeadClick,这些名字直接把控件的功能清单暴露给你了,哪怕手头只有给另一种语言写的帮助文档,对照着猜也能猜个八九不离十,因为底下调的是同一套 DISPID。
2.3 绑定的时序:先开游标,再挂数据源
顺序错了,表格就是空的。一个能稳定工作的绑定流程大致是这样:
// 成员变量,别放局部 CADODatabase m_DBCn; CADORecordset m_Rs; CDataGrid m_ctrlDG; BOOL CXXXDlg::InitGrid() { CString strConnection; // 提供程序名按你机器上实际安装的 mdb 驱动填写 strConnection = _T("Provider=<mdb 提供程序>;Data Source=note.mdb"); if (!m_DBCn.Open((LPCTSTR)strConnection)) // 1. 连接先打开 return FALSE; m_Rs.SetDatabase(&m_DBCn); // 2. 客户端游标:让行集驻留在本地,控件才能连续滚动和定位 m_Rs.GetRecordset()->CursorLocation = adUseClient; m_Rs.Open(_T("select * from test;")); // 3. 查询必须先返回结果集 // 4. 最后一步才是挂指针 m_ctrlDG.SetRefDataSource((LPUNKNOWN)m_Rs.GetRecordset()); return TRUE; }四个步骤里,第 2 步最容易被忽略。默认的服务端游标在某些场景下只支持单向遍历,控件为了画出滚动条,得知道总行数、得能来回定位,一旦游标不满足,表现往往是“表格能显示前几行、往下拖就空了”,或者干脆一片空白。把它切成客户端游标,行集就落在本地内存里,控件怎么问都答得上来。代价是数据量大的时候内存吃得比较多——这是一个典型的用空间换交互流畅度的取舍。
3. 列对象:为什么调个列宽还要先GetItem走一遍容器
3.1GetColumns拿到的不是一个副本
CColumns cols = m_ctrlDG.GetColumns(); // 拿到列集合的接口指针 CColumn col = cols.GetItem(vt); // 再按索引取出某一列 col.SetWidth(120.0f);这三行的语义层次很值得拆。GetColumns()返回的CColumns是一个“值”类型的对象,但它内部裹着的仍然是一个LPDISPATCH,指向控件内部那个列集合。你可以把它理解为一张写着“去某某地址找列集合”的便条——复制便条不等于复制了集合,所以CColumns按值传来传去也没关系,大家看的还是同一份东西。
GetItem(vt)的参数是VARIANT,这是 COM 世界里用来表达“这个参数可能是数字、可能是字符串、可能是对象”的通用容器。列索引是个整数,于是要构造一个VT_I4的VARIANT塞进去。这一步的翻车率极高,原因见 5.2 节。
取到CColumn之后,才轮到真正的操作:SetWidth设宽度,SetNumberFormat设显示格式,还有SetCaption改表头文字、SetVisible控制列显隐。它们改的都是“这一列怎么画”,而不是“这一格是什么”。
3.2SetNumberFormat改的是显示层,不是数据层
// 把 0.1234 这种小于 1 的小数,显示成 12.3% col.SetNumberFormat(_T("0.0%"));这个格式串走的是一套通用的数字格式化约定,0表示这一位必须出现、%表示乘一百并加百分号。看起来很美好,但它有个必须提前知道的后果:格式化只发生在渲染阶段。控件内部拿到的仍然是原始的浮点值,等你把表格内容导出去、或者拿它参与计算,看到的还是0.1234。
注意:如果你的业务里“显示成百分数”和“存成百分数”是两种不同含义,千万别指望
SetNumberFormat帮你完成数值转换,它只负责画得好看。
3.3 遍历所有列的安全写法
要批量设置列宽、列标题,就得遍历。这里有个容易忽略的边界:列数从哪来。
void CXXXDlg::AdjustAllColumns() { CColumns cols = m_ctrlDG.GetColumns(); long nCount = cols.GetCount(); // 列数以控件当前绑定的行为准 for (long i = 0; i < nCount; ++i) { VARIANT vt; VariantInit(&vt); // 必须初始化,否则是随机值 vt.vt = VT_I4; vt.lVal = i; // 列索引从 0 开始 CColumn col = cols.GetItem(vt); col.SetWidth(100.0f); VariantClear(&vt); // 用完清理,避免累积泄漏 } }三个细节:VariantInit不能省,VariantClear也不能省,索引是 0 基。GetCount()依赖当前绑定,所以这段代码必须在SetRefDataSource成功之后再调用,放在对话框的OnInitDialog里更稳妥的位置,是绑定语句之后。
4. 事件回流:HeadClick为什么接不到,排序为什么得自己动手
4.1 COM 事件是怎么变成 MFC 里的成员函数的
控件被点击表头时,它并不知道你的对话框类存在。它能做的只有一件事:遍历自己身上的连接点,把事件“广播”给所有向它注册过的接收者。MFC 在中间做了两件事——用IConnectionPoint向控件Advise一个自己的IDispatch实现,再在收到回调时按事件映射表把 DISPID 翻译成对应的处理函数。
于是整条链路是:
用户点击表头 → 控件触发 HeadClick 事件 → 控件遍历连接点,回调已注册的 IDispatch::Invoke → MFC 的接收器按事件映射表匹配到 OnHeadClick → 调用你在对话框里写的处理函数链路上任何一环没接上,表现都是“点了没反应,也不报错”。最常见的原因是:插入控件时生成包装类,但事件接收那部分没生成完整,或者生成在了另一个类里,对话框根本没参与Advise。这种情况没有编译错误、没有运行时异常,只有一片沉默,属于典型的黑匣子故障。
4.2 排序该放在哪一层:这是本节最需要想清楚的设计问题
HeadClick只告诉你“用户点了第几列”,它不负责排序。排序这件事必须你来决定放在哪一层,常见两种思路,取舍差别很大:
| 方案 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 重查数据库 | 拼ORDER BY重新执行查询、重新绑定 | 结果集天然有序,大数据量下内存稳 | 每次点击一次往返,有延迟 |
| 客户端就地排序 | 取回全部行、在内存里按列重排、重建绑定 | 点击即时响应 | 数据量大时内存翻倍,且要处理类型比较 |
绝大多数桌面小工具场景,客户端排序更合适——总共就几千行,延迟带来的体验损失远大于内存开销。但前提是游标已经是客户端游标(见 2.3 节),否则随便怎么排都容易出问题。
4.3 一个能跑通的排序骨架
// 事件处理函数:colIndex 由控件在事件里带过来 void CXXXDlg::OnHeadClickDataGrid(long colIndex) { static long s_lastCol = -1; static BOOL s_asc = TRUE; // 点击同一列则反转方向,换列则重置为升序 if (colIndex == s_lastCol) s_asc = !s_asc; else { s_lastCol = colIndex; s_asc = TRUE; } CString strField = GetFieldNameByIndex(colIndex); // 列号映射到字段名 CString strSQL; strSQL.Format(_T("select * from test order by [%s] %s"), (LPCTSTR)strField, s_asc ? _T("ASC") : _T("DESC")); // 关键:重查之前先解绑,避免控件还握着即将失效的行集 m_ctrlDG.SetRefDataSource(NULL); m_Rs.Close(); m_Rs.Open((LPCTSTR)strSQL); m_ctrlDG.SetRefDataSource((LPUNKNOWN)m_Rs.GetRecordset()); }GetFieldNameByIndex得自己维护一张“列序号 ↔ 字段名”的映射表,因为HeadClick给的只是一个下标。这张表通常在绑定成功之后按列顺序一次性建好,别每次点击都去猜。
代码里那句SetRefDataSource(NULL)是血泪经验。重新打开记录集时,旧行集对象会失效,如果控件还拿着旧的接口指针,重绘时就会访问到已经释放的内存。先解绑再重查再重绑,多写两行,能省掉一次莫名其妙的崩溃。
5. 排查:从绑定到排序最容易翻车的五处
5.1 绑定与生命周期上的三次翻车
现象一:工程编译时报“未定义类型 CColumns”,或者变量声明处一片红。原因:插入控件时只生成了CDataGrid,没有把CColumns、CColumn一起勾上,包装类根本没生成。 解决:重新走一遍“从 ActiveX 控件添加类”的流程,把要用到的类全勾上;临时应急的话,也可以直接用COleDispatchDriver手写包装,属性名对照帮助文档里的名称即可。
现象二:绑定之后表格是空的,但数据库确实有数据。原因:Recordset没打开就绑了,或者绑定后才执行Open。控件只拿到一个尚未产生行集的接口,自然问不出东西。 解决:严格保证“连接打开 → 记录集打开 → 再绑定”的顺序,并且绑定完成后立即检查游标类型是否满足控件要求。
现象三:程序运行一段时间后随机崩溃,崩溃点常在控件重绘相关的地方。原因:Recordset是对话框的局部变量,函数返回即析构;或者对话框关闭时先释放了数据源,控件还在做最后一次重绘。 解决:数据源对象一律作为类成员,并且析构顺序上让控件先失效——对话框OnDestroy里先SetRefDataSource(NULL),再关连接。
5.2 显示与格式上的两次翻车
现象四:GetItem(vt)调用后行为诡异,时而抛异常、时而取到错误的列。原因:VARIANT没有VariantInit,vt字段是栈上的随机值,所以这次调用可能被当成字符串索引、下次被当成对象引用。 解决:VariantInit之后再填vt和值,用完VariantClear。这条规则适用于所有往 COM 接口里塞VARIANT的地方,不止列索引。
现象五:格式串写对了,界面上也是百分数,但导出到文件就变回小数。原因:SetNumberFormat只作用于渲染,不改数据。导出走的是底层行集,拿到的当然是原始值。 解决:把“显示格式”和“数据转换”当成两件事。导出路径上自己做一次数值换算,不要在控件属性上找答案。
6. 设计取舍:这套“控件 + 数据源”的架构,边界到底在哪
把这一路的机制串起来看,DataGrid 的设计其实非常清晰:它把“数据从哪来”完全外包给下层,自己只负责“怎么画”,中间靠 COM 接口这根细线连起来。好处是解耦得干净——换一个数据源实现,控件一行不改;坏处是所有生命周期责任都压在你身上,指针什么时候失效、谁先析构,得靠你自己想清楚。
这套方案适合什么场景,也不难判断。几百到几万行、单机或局域网、以浏览和简单编辑为主、追求“少写代码快速出一版”的内部工具,它依然是最省事的选择:绑定一行、设格式一行、排序一个事件处理函数,半天就能把一张可用的表格搭出来。反过来,如果数据量上到百万级、需要频繁的批量刷新和并发写,或者界面要求高度定制(自定义单元格、内嵌控件、虚拟滚动),这套“全量行集 + 属性遍历”的方式就会开始吃力,内存和重绘成本会变成主要瓶颈。这时候该转向虚拟列表、分页查询这类思路,而不是继续在列对象上做文章。
还有一个容易被忽略的取舍:AgentX 这套接口天生不透明。你看不到实现,所以调试只能靠“猜接口契约 + 观察现象”,出问题时的信息量比调自己写的代码少很多。这要求你在设计阶段就把边界画死——数据源谁持有、什么时候释放、重新绑定的入口统一在哪——而不是等崩了再回头找。
从那以后我每次接这类控件,都强制先在纸上画一遍对象生命周期:谁先创建、谁后销毁、重绑时机在哪,画完再动手写代码。那几个图后来救过我好几次。希望帮到你。
本文还有配套的精品资源,点击获取