简介:这份文档面向使用 MFC/VC 开发桌面数据库程序的初中级开发者,聚焦 DataGrid 控件从数据呈现到外观控制的常见用法,帮助解决查询结果展示、列宽调整与数据格式设置等问题。压缩包共 1 个 doc 文件,约 226KB,以图文教程和 C++ 代码片段组织,便于对照现有工程查阅。当前已有 851 人学习,说明其在 MFC 控件入门与查阅场景中具有一定参考价值。内容围绕 Recordset 绑定数据源、CColumns/CColumn 获取列对象、SetWidth 设置列宽、SetNumberFormat 格式化百分数、SetRowHeight 实现多行显示,以及利用 HeadClick 消息处理排序等展开,并交代 VC6.0、Windows XP 下的调试环境,适合需要快速理解 DataGrid 接口和常见操作思路的读者按需检索。
1. 拖上去就能刷数据的控件,恰恰是最难排查的那一类
说起 MFC DataGrid 用法,多数人的第一印象是它简单得不像话:拖控件、绑 Recordset、设列宽,三行代码对话框里就长出一张表。但它反而是最容易让人卡住的那类控件,因为按 F12 跳不进任何实现,CDataGrid 里只有一堆 InvokeHelper,真正的绘制、滚动、格式化全在进程内的另一个 COM 服务器里跑。你手里只有一根叫 IDispatch 的电话线,对面是个黑匣子。搞清这根线两端各自负责什么,比背十个属性名有用得多:列宽改不动,是参数没发出去还是控件拒收;HeadClick 不响应,是连接点没建起来还是参数类型对不上——答案都不在你的代码里。
2. 控件是怎么变成 C++ 类的:注册表、类型库与分派壳
2.1 插入组件那一刻,IDE 其实只做了两件事
在对话框编辑器里右键「插入 ActiveX 控件」,弹出的列表不是 MFC 自己维护的,它是枚举注册表里标记为控件类别的所有 CLSID,逐个读出注册信息后拼出来的。所以一个控件哪怕文件就在硬盘上,只要没执行过注册,这个列表里就不会出现它——这也是后面「开发机好好的、客户机一片空白」的根源。
选中确定之后,IDE 做的第二件事是加载这个控件的类型库(TypeLib)。类型库是一份二进制接口说明书,里面写着这个控件有哪些属性、哪些方法、每个参数是什么类型、事件的参数是什么。MFC 的添加类向导读这份说明书,机械地生成一对 .h/.cpp,也就是你看到的 CDataGrid、CColumns、CColumn 三个类。类名、函数名全部来自类型库里登记的名字,所以你在 VC 里看到的方法名和 VB 帮助文档里的名字能对上,因为它们是同一份元数据的两种读法。
理解这一步很关键:这三个类不是控件厂商写的 SDK,是工具按说明书现场翻译出来的。换成另一台机器、换一个版本的控件,生成出来的类可能就多两个方法或者少一个。升级控件后你的工程几乎不用改,靠的正是「按说明书重新翻译」这件事,而不是什么兼容性魔法。
2.2 COleDispatchDriver:把 IDispatch::Invoke 翻译成成员函数
生成的每个类都直接或间接继承自 COleDispatchDriver,它内部只持有一个成员 m_lpDispatch,类型是 LPDISPATCH。所有成员函数的函数体都是同一个模板:组装参数数组、调用 InvokeHelper。没有逻辑,只有转发。
// 由添加类向导从类型库生成,函数体基本是机械翻译的结果 // 0x0a 这类数字是类型库里登记的分派 ID(DISPID),编译期就写死了 void CDataGrid::SetRefDataSource(LPUNKNOWN lpDataSource) { // VTS_UNKNOWN 是 MFC 的类型串,告诉 InvokeHelper 这个参数是 IUnknown* static BYTE parms[] = VTS_UNKNOWN; // DISPATCH_PROPERTYPUT 表示这是一次「属性赋值」,不是方法调用 InvokeHelper(0x0a, DISPATCH_PROPERTYPUT, VT_EMPTY, NULL, parms, lpDataSource); } CColumns CDataGrid::GetColumns() { CColumns result; // 返回值类型声明成 VT_DISPATCH:控件会还回来一个 IDispatch* // InvokeHelper 把它 Attach 到 result 的 m_lpDispatch 上 InvokeHelper(0x0b, DISPATCH_PROPERTYGET, VT_DISPATCH, (void*)&result, NULL); return result; }InvokeHelper 内部做的事可以拆成四步:把可变参数按 parms 里声明的类型串逐个塞进 DISPPARAMS 数组;用写死的 DISPID 而不是名字去调 IDispatch::Invoke,省掉 GetIDsOfNames 的开销;根据 wFlags 决定是方法调用、属性读还是属性写;把返回的 VARIANT 按 vtRet 拆出来,遇到 VT_DISPATCH 就交给 COleDispatchDriver 托管。整条链路上没有任何类型检查是你的编译器做的,全部推迟到运行时。
这就是为什么参数写错不会编译报错,只会以一个 HRESULT 失败或者一个 COleException 冒出来;也是为什么断点只能在 InvokeHelper 这一层停下——再往里就是 COM 的跨套间调用,源码不在这份工程里。我一般把这一层叫「壳」,壳只负责把 C++ 调用翻译成 COM 调用,不含任何业务判断。你调 SetWidth 传了个负数,壳照发不误,判断在控件里。
2.3 三个类、两种对象:CColumns 到底是不是「值」
CDataGrid、CColumns、CColumn 看着像三层对象,实际是两层:CDataGrid 是一个常驻的控制对象(对应你对话框上那个真实存在的控件实例),CColumns 和 CColumn 是每次调用现场生成的一次性句柄,它们背后对应的是控件内部的集合对象和列对象,但 C++ 这一侧拿到的只是它们各自的 IDispatch 指针。
这带来一个容易忽略的语义问题:CColumns 是按值返回的。COleDispatchDriver 的拷贝构造在常见实现里会替你把引用计数加一,所以临时对象析构不会把连接拆掉,你可以放心写CColumns cols = m_ctrlDG.GetColumns();。但绝不能把这个对象存成窗口类的成员变量长期持有——一旦控件因为父窗口重建、属性页切换之类的原因重新创建了,旧的 IDispatch 指针指向的就是已经失效的桩,再调它的任何方法都是未定义行为。
更稳的写法是每次都从 m_ctrlDG 重新取一遍集合,拿到的永远是当前有效的那个。多一次 Invoke 的开销,换掉一整类偶发崩溃,账是划算的。这种「句柄不缓存」的习惯,后面讲列控制和事件时还会反复用到。
3. SetRefDataSource 背后:一个 LPUNKNOWN 是怎么变成网格里的行的
3.1 参数为什么是 LPUNKNOWN,而不是 Recordset 指针
看生成的原型会发现参数类型是 LPUNKNOWN,而你在 C++ 里实际传进去的是 ADO 的 Recordset 接口指针。这不是设计者偷懒用 void* 糊弄,而是 COM 里非常典型的一种做法:延迟接口协商。
控件在设计时并不知道你会用哪种数据源。它可能被要求接收 ADO 的 Recordset,也可能接收别的数据源对象,甚至以后出现新的数据访问技术。如果属性类型写成某个具体接口,那么控件每支持一种新数据源就要改一次类型库、升一次版本,所有引用它的工程都得重新生成包装类。把它定成 IUnknown,等于对外说「你随便给我一个 COM 对象,我自己在运行时问它会不会说我要的那门语言」。
控件内部拿到这个 IUnknown 之后,会做一次 QueryInterface,问它是否支持自己认识的那个数据源接口(对 ADO 场景而言就是 Recordset 的那套接口)。问成功就 AddRef 保存下来,后续所有取数据的动作都通过这个接口走;问失败就拒绝绑定,通常表现是控件空白、不报错,或者抛一个「数据源无效」的异常。所以你在调试时如果只看到一片灰白,第一步不是怀疑 SQL,而是确认传进去的到底是不是一个活着的 Recordset 指针——传个野指针或者已关闭的对象,QueryInterface 一样会走到失败分支。
原文代码里那句(LPUNKNOWN)m_Rs.GetRecordset()里的强制转换,本质上是放弃编译期类型信息、只保留「这是一个 COM 对象」这一个事实。转换本身不做任何检查,出问题一定是在运行时。
3.2 数据没有拷进控件:控件反过来向数据源要行
这是整篇里最值得记住的一条机制:绑定时控件不会把结果集复制一份到自己的内存里。它只是拿到了数据源的接口,然后在你需要显示的时候,反过来调用数据源去取行。
顺序是这样的:绑定时控件读一遍列元数据(列数、列名、字段类型),用来决定表头文字和默认列宽;绘制第一屏时,控件从当前行开始向后取若干行填进可视区域;你拖动滚动条时,控件再向数据源要新的行。也就是说,数据源在控件的整个生命周期里都必须保持打开、保持可随机访问,控件随时可能回头找它。
这条机制直接决定了两件事。第一,CursorLocation 和 CursorType 不能随便设。默认的服务端只进游标(forward-only)只允许你一格一格往后走,控件往上滚或者跳到末页时无路可退,表现出来就是只显示前几十行、越往后越空,或者干脆抛一个「行集不支持该操作」。常见的做法是客户端游标配静态快照,游标位置 adUseClient、类型 adOpenStatic,把结果集整份拉到本地,控件想怎么翻就怎么翻。
第二,赋值给 Recordset 的那个 VARIANT 和连接对象,所有权要理清楚。(IDispatch*)m_cn, true里的那个 true 表示「这个连接引用由 Recordset 自己接管」,写错成 false 而你的连接对象又先析构了,Recordset 就成了挂在半空的连接上,一取数据就炸。
// 绑定数据源的关键几步,顺序不能乱 _variant_t vSql(_T("select * from t_order order by id")); // 1) 先定游标位置:客户端游标,控件才能任意前后翻 m_rs->CursorLocation = adUseClient; // 2) 再定游标类型:静态快照,翻页时不重新查库 m_rs->CursorType = adOpenStatic; // 3) 打开时把游标参数再写一遍,两者不一致时以 Open 的参数为准 m_rs->Open(vSql, _variant_t((IDispatch*)m_cn, true), adOpenStatic, adUseClient, adCmdText); if (m_rs->State == adStateOpen) { // 4) 传过去的是 IUnknown,控件内部自己 QueryInterface m_ctrlDG.SetRefDataSource((LPUNKNOWN)m_rs.GetInterfacePtr()); }参数说明:adUseClient 让游标在本地维护,代价是结果集大时内存涨;adOpenStatic 生成静态快照,适合这种「展示型」网格,不适合需要看到别人实时改动的场景,那种要换 adOpenKeyset 并接受额外开销。adCmdText 告诉提供者第一参数是 SQL 文本而非表名或存储过程,写错了通常表现为参数个数或类型不匹配的错误。
3.3 引用计数与销毁顺序:谁先死,谁后死
绑定之后,这个 Recordset 至少有两个持有者:你工程里那个封装对象,和控件内部 AddRef 之后保存的那份。这本来是 COM 的安全网——你把自己的那份 Release 掉,控件那份还在,对象不会立刻消失。
但安全网只保证对象不消失,不保证对象还能用。如果你把连接关了、游标关了,对象虽然活着,状态已经是关闭态。控件下一次绘制时向它要行,拿到的是一个「对象关闭时不允许操作」的失败。表现在界面上就是窗口关闭时闪一下、或者弹一个谁也没法复现的异常框。
正确的销毁顺序是:先让控件松手,再关游标,最后放连接。让控件松手的方式就是把数据源属性重新设成空。这个顺序我在 OnDestroy 里固定成三步,缺一步都可能在某台机器上偶发崩溃,而这类崩溃的堆栈永远停在 COM 那一层,没有任何可读信息。
void CGridDemoDlg::OnDestroy() { // 1) 先解绑:控件会 Release 它内部保存的那份数据源引用 // 这一步之后控件不再向数据源要行,绘制也不会再回调过去 m_ctrlDG.SetRefDataSource(NULL); // 2) 再关游标:此时已经没有人会在后台读它了 if (m_rs != NULL && m_rs->State == adStateOpen) m_rs->Close(); // 3) 最后关连接 if (m_cn != NULL && m_cn->State == adStateOpen) m_cn->Close(); CDialog::OnDestroy(); }这里的每一步都不是仪式感。反过来的顺序(先 Close 再解绑)在实际项目里非常常见,因为写代码时脑子里想的是「关闭资源」,而控件这层隐式持有者根本不在视野里——这就是典型的黑匣子效应。
4. 列宽与格式:属性到底落在控件上还是数据源上
4.1 GetColumns / GetItem:集合对象与 VARIANT 索引
列控制的入口是取集合,再取集合里的某一项,两次 Invoke 拿到两个 IDispatch。第二次调用用的索引是一个 VARIANT,而不是 int,因为同一个集合既支持按序号取,也支持按列标题取——这正是类型库里把它声明成 VARIANT 的原因。
序号从 0 开始,和 ADO 的 Fields 一致。用 VB 那套从 1 开始的思维来取列,结果是整整错开一列:你想设第一列的宽度,实际动了第二列,最后一列永远够不着,而且不报任何错。这个坑非常隐蔽,因为界面上看起来「差不多对」。
写这类代码时还有一条硬规矩:VARIANT 用前必须初始化,用完必须清理。它里面可能携带 BSTR 或接口指针,直接赋 vt 和值而不 VariantInit,轻则泄漏,重则在某些调试堆上触发断言。
// 遍历所有列,统一设列宽和表头文字 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 基,和 ADO Fields 一致 CColumn col = cols.GetItem(vt); // 取到列句柄 VariantClear(&vt); // 用完立刻清,避免携带资源 col.SetWidth(120.0f); // 列宽 col.SetCaption(CString(_T("列")) + (char)('A' + i)); // 表头文字 }SetWidth 传的是 float,单位跟随控件所在容器的映射模式,不同容器下同一数值的视觉宽度可能不一样。稳妥的调法是先给一个基准值跑一遍,看渲染结果再按比例微调,别指望有一个跨环境通用的魔法数字。列数用 GetCount 读属性拿,比你自己数 Recordset 的 Fields 更可靠——控件可能有隐藏列或者你自己手工加过的列。
4.2 SetNumberFormat:格式串只活在绘制层
把小于 1 的小数显示成百分数,一行 SetNumberFormat 就够,格式串语法和 VB 的 Format 函数是同一套:「0.0%」表示乘一百、保留一位小数、带百分号;「#,##0.00」表示千分位加两位小数。
真正需要理解的是它作用在哪一层。这个属性写在列对象上,控件在绘制单元格时按它把原始值渲染成文本;数据源里的值从头到尾都是那个 double,没有被改动过。所以你从 Recordset 里读回来是 0.1234,从网格上眼睛看到的是 12.3%,两者都对。
这个分层会带来两个实际后果。第一,如果用户双击单元格进入编辑状态,控件通常显示的是原始值而不是格式化后的字符串,不一致感由此而来,想要一致就得自己接管编辑行为。第二,任何绕过控件绘制的路径——导出到剪贴板、自己画到 PictureBox、拼进报表——都拿不到那个百分号,必须自己再格式化一遍。把格式串当成「显示模板」而不是「数据转换」,就不会在这上面踩坑。
格式串本身写错不会报错,只会显示成原样或者一串问号。百分号位置、小数点位数这些细节,建议先在几个典型值上试一遍:0.5、0.999、1、1.005,边界值最能暴露取整规则。
4.3 行高、折行与重绘时机
SetRowHeight 撑高的是行,不是文本。单元格里的文字要不要在变高之后折行,取决于另一个开关 WrapText。只设行高不设折行,结果是行变高了、文字还是被右边那列截断,视觉上像是什么都没发生——这是我在别人代码里见过最多的一处误解。
三个属性配合起来才是完整的「多行显示」:行高给足空间,折行开关打开,再按需要放开 AllowRowSizing 让用户自己拖。三个都设完,还要考虑重绘时机,成批修改时逐列设置会触发多次重绘,肉眼可见的闪烁多半来自这里。常见的做法是改之前先把控件隐藏或禁止刷新,全部设完再一次性显示,代价是代码多两行。
5. 事件、排序与排查:HeadClick 从哪来,五个翻车现场
5.1 事件是怎么从控件内部走回你的对话框的
控件内部发生了「用户点了表头」,它不能直接调用你的成员函数,因为它是二进制组件,编译时根本不知道你的类存在。COM 给的标准答案是连接点:控件实现 IConnectionPointContainer,对外提供若干条「出向接口」,每条接口上可以挂任意多个接收器。
MFC 这一侧在建控件的时候,拿到容器的指针,按事件接口的 IID 找到对应的连接点,然后把自己造的一个 sink 对象 Advise 上去。此后控件一触发事件,就遍历这条连接点上挂着的所有 sink,逐个调 Invoke,把你的成员函数喊起来。ClassWizard 生成的事件映射宏做的正是这件事:把某个 DISPID 映射到某个成员函数,并用一串类型串说明参数怎么拆。
所以「事件收不到」的原因基本可以归到两类:要么连接点根本没建立,要么建立了但参数类型对不上被静默丢弃。前者常见于动态创建控件的写法,你只 Create 了控件却没走资源里的 DDX 绑定,MFC 那条 Advise 的路径压根没跑;后者常见于把参数写成 long 而类型串声明的是 short,MFC 拆参数时发现不匹配就直接跳过,连断言都不给。
5.2 用 HeadClick 做排序:控件不排序,排序是你重查
DataGrid 本身没有任何排序能力。它做的只有一件事:用户点了第 N 列表头,它把 N 通过 HeadClick 告诉你。
真正的排序动作落在数据源上。客户端游标的 Recordset 支持 Sort 属性,直接赋值字符串就能重排,然后在控件上刷新一下;更通用、也更符合数据库习惯的做法是记住当前列和升降序,重新拼一条带 ORDER BY 的 SQL、重开记录集、重新绑定。前者省一次往返但依赖游标类型,后者慢一点但行为可预期,数据量大时还能配合分页。
// 事件处理:参数类型必须和类型库里声明的完全一致(short) void CGridDemoDlg::OnHeadClickDataGrid(short nCol) { // 1) 同一列再点一次就反向,换列则重置为升序 if (nCol == m_nSortCol) m_bAsc = !m_bAsc; else { m_nSortCol = nCol; m_bAsc = TRUE; } // 2) 列号到字段名的映射要自己维护,控件不会给你字段名 CString strField = m_arrFieldName[nCol]; // 3) 重开记录集:控件不排序,能排序的只有数据库 CString strSql; strSql.Format(_T("select * from t_order order by %s %s"), (LPCTSTR)strField, m_bAsc ? _T("asc") : _T("desc")); if (m_rs->State == adStateOpen) m_rs->Close(); m_rs->Open(_variant_t(strSql), _variant_t((IDispatch*)m_cn, true), adOpenStatic, adUseClient, adCmdText); // 4) 重开之后必须重新绑定,否则控件还挂在已关闭的旧游标上 m_ctrlDG.SetRefDataSource(NULL); m_ctrlDG.SetRefDataSource((LPUNKNOWN)m_rs.GetInterfacePtr()); }参数说明:nCol 是 0 基列号,直接拿去索引字段名数组即可。第 4 步那个「先解绑再绑定」容易被省掉,省掉之后的表现是网格停留在旧数据上或者直接空白——控件认为数据源没变,不会重新去读。字段名拼进 SQL 前一定要来自白名单而不是用户输入,否则就是个注入口子。
5.3 五个翻车现场:现象、原因、解决
现象一:开发机上一切正常,拷到客户机上对话框里只剩一个空白矩形,Create 返回 FALSE。原因:ActiveX 控件必须在目标机的注册表里有 CLSID 与模块路径登记,光把可执行文件拷过去不算数;还有一种情况是位数不匹配,32 位进程加载不了 64 位控件。解决:安装包里带上控件模块并注册,同时在 InitInstance 里检查创建结果并给出明确提示,别让用户对着空白框猜。
现象二:只显示前几十行,往下拖是空白,翻到末页报「行集不支持该操作」。原因:游标用了服务端只进游标,控件回头要行时无路可退。解决:游标位置改客户端、类型改静态快照,注意 Open 的参数要覆盖掉 CursorType 之前设的值,两处不一致时以 Open 为准。
现象三:关闭窗口时偶发崩溃,堆栈停在 COM 内部,没有任何可读帧。原因:销毁顺序反了,先关了游标再解绑,控件在最后一次绘制里向已关闭的对象要行。解决:OnDestroy 里严格按「解绑 → 关游标 → 关连接」三步走。
现象四:点表头毫无反应,函数里打断点一次都不进。原因:动态创建控件时没走 DDX 绑定,连接点没建;或者函数签名写成了 long,和类型库声明的 short 对不上被静默丢弃。解决:优先在对话框资源里创建,走 DDX_Control;参数类型一个字节都不要改。
现象五:列宽设到了别的列上,最后一列永远调不动。原因:VARIANT 索引用了 1 基,或者 VARIANT 没初始化就赋 vt,控件读到的序号是垃圾值。解决:索引从 0 开始,每次用前 VariantInit、用完 VariantClear。
6. 设计取舍:薄封装换来的是什么,代价又是什么
这套「按类型库生成包装类」的机制,本质是用运行时换取编译期的解耦,代价和收益都很具体。
收益是升级路径短。控件的属性变多了,你在工程里重新生成一次包装类就能用;控件内部实现换了,只要接口没变,你那几百行调用代码一行不动。原文作者反复强调的「用成熟的东西、把精力留给业务功能」,落到机制上就是这个意思——你不是在用某个具体的 DLL,你是在用一份接口契约,具体实现随时可以替换。
代价是调试边界被截断。所有错误都退化成 HRESULT 和异常,堆栈在 InvokeHelper 那一层断掉,剩下的部分你看不见。加上控件会隐式持有你的数据源引用,生命周期不再是你一个人说了算,很多诡异的崩溃都是从这个「隐式持有」里长出来的。
| 维度 | ActiveX 网格控件 | 自绘列表控件 |
|---|---|---|
| 数据绑定 | 交给控件,一行搞定 | 自己遍历记录集填充 |
| 部署成本 | 需注册控件与依赖 | 无外部依赖 |
| 格式化能力 | 属性配置,格式串驱动 | 全部手写绘制 |
| 大数据量 | 结果集全量拉到本地 | 可做虚拟列表按需取 |
| 调试可见性 | 断在分派层,黑匣子 | 全在自己代码里 |
我的判断标准很朴素:界面是「展示型」、列结构相对固定、部署环境可控,就用控件,省下来的时间足够你写三遍填充逻辑;界面要支持几十万行滚动、需要复杂单元格自绘、或者部署环境完全不可控,就老老实实自己画。这两条路的成本曲线是交叉的,选错方向的话,前期省下的时间后期都要连本带利还回去。
// 防御性写法:把绑定与解绑包成一对,保证顺序不靠人记 class CGridBinder { public: CGridBinder(CDataGrid& grid, LPUNKNOWN pSource) : m_grid(grid) { m_grid.SetRefDataSource(pSource); // 构造即绑定 } ~CGridBinder() { m_grid.SetRefDataSource(NULL); // 析构必解绑,异常路径也覆盖到 } private: CDataGrid& m_grid; };这个包装类的价值不在代码量,在于它把「解绑」从一件需要人记住的事,变成了一件对象销毁时必然发生的事。异常、提前 return、消息框里点取消,都不会漏掉。
从那以后我每次接这类控件,都强制走一遍同样的流程:先在目标机上确认控件注册、再把游标类型写死在初始化里、最后把解绑放进析构而不是 OnDestroy。这三件事花不了十分钟,但能挡掉后面八成「偶发、无法复现、只在客户那儿出现」的工单。希望帮到你。
本文还有配套的精品资源,点击获取