简介:面向使用VC/MFC开发桌面数据库应用的初中级程序员,这份文档以DataGrid ActiveX控件为主线,聚焦对话框中数据网格的显示、外观控制与交互处理,帮助读者把查询结果稳定呈现并做基础格式化。压缩包内仅含1个doc文件,大小约226KB,篇幅集中、按主题分段,适合边看边对照代码实践。内容结合CADODatabase、CADORecordset与CDataGrid演示数据源绑定,说明通过CColumns、CColumn获取列并设置列宽,用SetNumberFormat把小于1的小数显示为百分数,以SetRowHeight实现多行显示,还介绍利用HeadClick消息处理列标题点击以完成排序,并注明示例在VC6.0英文版、Windows XP下调试通过。已有852人学习,可作为MFC项目中DataGrid控件入门与查漏补缺的参考材料。
1. 它其实不是表格控件:一个 OCX 控件凭什么能直接吃下 Recordset
很多人第一次把 DataGrid 拖进对话框,会以为自己在用一个"表格控件":给个数据源、设个列宽、设个格式,三行代码就出结果,于是把 MFC DataGrid 用法记成几条属性调用就收工。真正反直觉的是:这控件自己一格数据都不存。它是一段编译好的 OCX,内部装着两套互不相干的古老机制:一套是 OLE 复合文档的就地激活与属性包,一套是 OLE DB 的数据绑定接口。你SetRefDataSource里传进去的那个LPUNKNOWN,才是数据的真正主人。想通这一点,"为什么拿了 Columns 还要 GetItem""为什么重查一次数据列宽全丢""为什么程序退出时偶尔崩"这些问题就不必死记,能从机制上推出来。
2. SetRefDataSource 的真相:绑定发生在 COM 边界,而不是 C++ 边界上
2.1 参数类型本身就暴露了实现:为什么传的是 LPUNKNOWN
先把最容易看漏的一行挑出来。原文里的调用是m_ctrlDG.SetRefDataSource((LPUNKNOWN)m_Rs.GetRecordset());。很多人写这段时只会注意强制转换"能编译过",但参数类型是LPUNKNOWN而不是某个CADORecordset*,这一点信息量极大:它说明这一行跨越的不是 C++ 的函数调用边界,而是 COM 的接口边界。控件拿到这个IUnknown*之后,会自己QueryInterface去找它认识的那几个接口(行集、数据源、游标相关的一组),找到就建立绑定,找不到就静默失败——界面上什么都不显示,也不报错。
这解释了一个常见困惑:为什么有时候SetRefDataSource调了、编译没错、运行时也没弹框,网格就是空的。因为它压根不是"传对象"的语义,是"传接口指针、由控件自行探测能力"的语义,探测失败是沉默的。
绑定之后,数据流的方向是这样的:控件持有一个接口指针,绘制某个单元格时,它通过行集接口按行列坐标去要值;用户改了单元格,控件再通过同一套接口回写。整个过程数据只有一份,就在 Recordset 里。我在理解这块时习惯画一条时间线:打开连接 → 打开记录集 → 把接口交给控件 → 控件 QI 探测 → 建立绑定 → 每次重绘向行集取数。任何一步断开,表现都是"空白",而不是异常,这是这套设计最坑人的地方。
把这套模型和自绘表格对照一下就清楚了。用CListCtrl或者自己OnPaint画格子的做法,是复制模型:你从记录集里把值读出来,塞进控件的数据结构,此后两边互不相干。DataGrid 是视图模型:控件只是记录集的一个观察者兼编辑器。复制模型下你改控件不影响数据库,视图模型下你改控件可能直接改到数据库——AllowUpdate、AllowAddNew这些属性之所以存在,就是因为回写路径是通的,必须给你一个开关。
2.2 包装类从哪来:向导生成的只是 IDispatch::Invoke 的语法糖
插完控件,向导会让你勾选要从控件类型库生成哪几个包装类。原文只勾了CDataGrid、CColumns、CColumn三个,工程里就多出三个CWnd派生的类。这里有个细节值得说透:这三个类的成员函数不是谁写出来的算法,而是向导读类型库(typelib)自动生成的转发函数,每个函数体里干的事高度雷同——把参数打成VARIANT数组,连同分派 ID 丢给IDispatch::Invoke。
// 向导生成的包装函数,形状基本都是这样(示意写法) void CDataGrid::SetRefDataSource(LPUNKNOWN Param1) { static BYTE parms[] = VTS_UNKNOWN; // 参数类型表,供 InvokeHelper 打包 InvokeHelper(0x1, DISPATCH_PROPERTYPUT, VT_EMPTY, NULL, parms, Param1); }这段没有算法,只有协议:第一个参数是属性的分派 ID,第二个说明这是"写属性",后面是返回值类型、参数类型表和实际参数。所以"看不到控件源码"是必然的——真正的实现在二进制 OCX 里,工程里的包装类只是一层薄壳。
想验证这层壳到底有多薄,可以绕过包装类直接调一次:
// 不用包装类,等价地设置一次数据源;需要 atlbase.h / atlcom.h CComPtr<IDispatch> spDisp; m_ctrlDG.GetControlUnknown()->QueryInterface(IID_IDispatch, (void**)&spDisp); DISPID dispid = 0; OLECHAR* szName = L"RefDataSource"; // 属性的类型库名字 spDisp->GetIDsOfNames(IID_NULL, &szName, 1, LOCALE_USER_DEFAULT, &dispid); CComVariant v((IUnknown*)m_Rs.GetRecordset()); // 值本身就是一个接口指针 DISPPARAMS dp = { &v, NULL, 1, 0 }; // rgvarg 是逆序压栈,单参数无所谓 spDisp->Invoke(dispid, IID_NULL, LOCALE_USER_DEFAULT, DISPATCH_PROPERTYPUT, &dp, NULL, NULL, NULL);GetIDsOfNames把属性名翻译成分派 ID,Invoke带着DISPATCH_PROPERTYPUT表示"写属性",CComVariant里包的是一个IUnknown*。跑通这段,你对后面所有SetXxx的心理模型就会变:它们不是函数调用,是往一个远程对象上发属性包。也正因为如此,参数类型错、名字拼错、接口没探测到,反馈都是运行时的静默失败,而不是编译错误。这个认知差,是后面排错时最省时间的一件工具。
3. CColumns 与 CColumn:为什么改一列之前必须先绕一次集合对象
3.1 GetItem 里的 VARIANT:索引和列名是两条路
原文那句CColumns cols = m_ctrlDG.GetColumns(); CColumn col = cols.GetItem(vt);看着啰嗦,其实是 COM 集合模型的标准形态:Columns 是集合对象,Column 是元素对象,两者各自有独立的分派 ID,必须先拿到集合,再向集合要元素。不能一步到位,因为它们本来就是两个不同的 COM 对象。
GetItem的参数类型是VARIANT,这也不是随便选的。集合元素的定位支持两种键:数字索引和字符串列名。传short走索引,传BSTR走名字匹配,控件内部按vt字段分派。用列名定位在工程上更稳——一旦 SQL 里的字段顺序变了,按索引写的代码会静默地改错列,按名字写的最多找不到、抛个错出来,问题暴露得早。
CColumns cols = m_ctrlDG.GetColumns(); long nCount = cols.GetCount(); // 列数,要在绑定成功之后再取 for (long i = 0; i < nCount; ++i) { COleVariant vtIndex((short)i); // 索引用 short 传,DataGrid 的集合是 0 基 CColumn col = cols.GetItem(vtIndex); col.SetWidth(1400); // 宽度单位常见的实现是缇(twip),不是像素 }两个参数细节要记住:索引基数是 0,写 1 起会漏掉第一列、最后一列越界;宽度单位不是像素,按像素给的数值会小得几乎看不见,常规做法是按像素值乘 15 换算成缇。这两处一旦写错,表现都是"代码没报错但界面不对",属于典型的血泪经验。
还有一层生命周期的问题。GetColumns返回的CColumns和GetItem返回的CColumn都是包装对象,内部只握着一个接口指针,随时可能失效。所以"改属性"这件事必须在拿到对象的当下做完,把CColumn存成成员变量、等到用户点按钮再去设格式,是翻车的常见姿势——中间只要重新绑定过一次数据源,集合就被重建了,你手里那个是上一轮的残留。
3.2 格式化只改显示层:NumberFormat 为什么不动底层值
col.SetNumberFormat(_T("0.0%"));这一行是原文里最有代表性的一处。它的语义是:把一个格式串交给控件,控件在绘制该列单元格时按当前区域设置去解析并渲染。关键认知在于——它只影响显示,不改存储。你从这个单元格读回来的还是那个原始小数,数据库里也还是那个原始小数。
这个分层很有价值。如果自己做这件事,就得在OnDraw或DrawItem里逐格判断类型、取小数、乘一百、拼百分号,还要处理空值和不合法输入。交给控件做,等于把"值 → 文本"的转换集中在渲染层,业务代码只管值。同理,对齐、加粗、单元格内换行这些外观属性,也都是渲染层的东西,跟数据正交。
// 把某一列配置成"右对齐 + 百分数 + 单元格内换行 + 表头文案" CColumn col = m_ctrlDG.GetColumns().GetItem(COleVariant(_T("rate"))); col.SetCaption(_T("率")); col.SetAlignment(1); // 常见的取值:0 左、1 右、2 居中 col.SetNumberFormat(_T("0.0%")); // 只改显示,读回来仍是 double col.SetWrapText(TRUE); // 内容超宽时在单元格内折行 m_ctrlDG.SetRowHeight(400); // 行高,同样是缇;折行必须配合足够的行高SetWrapText和行高是一对,只开折行不给高度,第二行会被裁掉,看起来像"多行显示没生效"。原文提到多行显示就是这个组合。这类"属性之间互相依赖"的地方,靠查属性列表是查不出来的,只能靠对渲染流程的理解:控件先按行高决定可绘制区域,再按折行标志决定文本排版,两个条件缺一不可。
还有一点必须提前说清:所有外观属性的设置,都要放在数据源绑定成功之后。原因是绑定过程会重建列集合、按字段类型重置默认格式,你在绑定前设的东西会被覆盖掉。这就是第 4 章要讲的"重查一次数据,列宽全丢"的根源,也是把它抽成单独函数的理由。
4. HeadClick 只报告不干活:控件把排序这件事甩给了谁
4.1 事件分发链:从连接点到你那三行处理函数
表头点击对应的事件叫HeadClick,参数是一个short列索引。控件触发它的时候,走的是 COM 的事件机制:控件侧提供连接点容器,MFC 的包装类在创建时通过AfxConnectionAdvise建立连接,事件发生时控件回调你注册的事件接收器,接收器再按事件映射表找到你写的OnHeadClick。所以你在对话框类里看到的那三行,本质是一个穿过 COM 边界的回调终点,不是普通的成员函数调用。
理解这条链的现实意义在于:它决定了"事件里能做什么"和"不能做什么"。事件回调发生在控件的消息处理过程中,你在里面重新绑定数据源、销毁记录集,等于在别人遍历自己内部状态时把它的数据抽走——多数时候能跑,偶尔崩,而且崩的位置离现场很远,属于典型的黑匣子式故障。稳妥做法是把重活往后挪一帧:事件里只记录"用户点了哪一列、要升还是降",用PostMessage投递一个自定义消息,在消息处理里再做重查和重新绑定。
// 事件处理里只记录意图,不直接动数据源 void CTestGridDlg::OnHeadClickDataGrid(short ColIndex) { m_nSortCol = ColIndex; // 记下点了哪一列 m_bSortAsc = !m_bSortAsc; // 同一列再点一次就反向 PostMessage(WM_APP + 1); // 真正的排序挪到消息循环里做 }这段代码的价值不在语法,而在时序:把"事件回调"和"数据重绑"拆到两个消息里,控件内部状态不会在中途被打断。我第一次遇到退出崩溃时查了很久,最后发现根子就在这——排序重绑和控件重绘撞在了一起。
4.2 谁来排序:三条路和各自的代价
HeadClick名字里有 "Click",但它不排序,只是通知你"表头被点了"。排序要自己接。下面是三条常见路子,代价差别很大:
| 做法 | 实现成本 | 适用数据量 | 主要代价 |
|---|---|---|---|
| 改 SQL 重新查询 | 最低 | 任意 | 一次往返,列外观全部重置 |
| 用记录集自带的 Sort 属性 | 低 | 中小(需客户端游标) | 服务端游标下调 Sort 会直接失败 |
| 自己排内存数组再回填 | 高 | 小 | 破坏了"视图模型",数据出现两份 |
第二条路的坑特别隐蔽:Sort属性要求游标在客户端,服务端游标上设置它会报错,而这个错误在很多封装里被吞掉,表现就是"点了表头没反应"。原文所依据的那套封装里,连接串用的是本机文件型数据库引擎,游标位置默认在服务端,直接设Sort大概率不生效,得先切到客户端游标再开记录集。
第一条路最省心,也是我在这种场景下首选的做法:
void CTestGridDlg::OnSortRequest() { CString strField, strSQL; strField = m_strSortField; // 列索引到字段名的映射 strSQL.Format(_T("select * from test order by [%s] %s"), (LPCTSTR)strField, m_bSortAsc ? _T("asc") : _T("desc")); m_ctrlDG.SetRefDataSource(NULL); // 先解绑,别让控件握着旧游标 m_Rs.Close(); m_Rs.Open(strSQL); // 重开记录集 m_ctrlDG.SetRefDataSource((LPUNKNOWN)m_Rs.GetRecordset()); ApplyColumnFormat(&m_ctrlDG); // 关键:外观必须重新套一遍 }三处细节。第一,重开前先SetRefDataSource(NULL),主动断开绑定,避免控件还引用着即将关闭的游标。第二,order by后面的字段名用方括号包起来,字段名里带空格或保留字时才不会语法错。第三,也是最重要的一条——重开记录集会重建列集合,之前设的列宽、标题、格式、对齐全部回到默认。所以列配置必须抽成一个可重复调用的函数,绑定成功后立刻调一次。这就是"后悔药":与其在出事之后去找哪一行丢了,不如一开始就让外观配置可重放。
第三条路自己排数组,最大的代价不是代码量,是它把视图模型退化成复制模型:数据有了两份,用户的编辑改到哪一份、什么时候同步回去,全变成你的事。除非数据量小到几百行、且只读,否则不建议。
5. 排查:DataGrid 跑不起来时,八成踩的是这几颗雷
5.1 环境和注册:控件都找不到,谈什么用法
现象一:插控件时列表里翻不到 DataGrid 这一项。原因通常是 OCX 根本没在本机注册,或者位数不匹配——开发环境是 32 位的,注册表里查的也是 32 位那一份,用 64 位的注册工具去注册等于白干。解决是找到那套控件对应的 OCX 文件,用 32 位版本注册工具显式注册一次,然后重启开发环境让控件列表重新枚举。注意这条路子容易让人以为是装漏了组件,反复重装工具,其实只是注册表里少一项。
现象二:本机编译运行正常,换台机器启动就报"未找到控件"或对话框建不出来。原因是部署包里只拷了可执行文件,控件没跟着走,或者跟走了但没注册。这类控件在界面创建阶段就要实例化,缺了就整个对话框资源加载失败,表现往往是一句很含糊的"创建对话框失败"。解决办法是在安装包里带上 OCX 并注册,注册完再启动程序。这一条几乎每个用老控件的人都栽过,属于踩坑率最高的一颗雷。
现象三:某天忽然打不开工程,或者资源编辑器里那个对话框一打开就崩。原因是资源文件里存的是一个控件引用(类 ID 加上一堆属性),开发环境打开对话框时会去实例化这个控件来渲染预览,控件缺失或不兼容时预览阶段就出问题。解决是先确认控件可用,再打开资源;实在不行直接看资源脚本里对应的那行定义,确认引用的标识没错。
5.2 数据绑定和销毁顺序:不报错的那一类最难查
现象四:记录了数据源,网格却一片空白,连表头都没有。三个原因按概率排:一是记录集压根没打开或者打开失败;二是游标类型是只进的,行集不支持往回浏览,控件探测不到可用的行集接口就静默放弃;三是字段全部被设为不可见。排查顺序建议反过来走——先确认列集合的列数是不是大于零(通过GetCount()打印出来),大于零说明绑定成功、是可见性问题;等于零说明绑定压根没成,去查记录集状态和游标位置。这个"先看列数"的习惯能省掉一半时间。
现象五:程序退出时崩,或者调试版里断言在某个销毁函数上。原因是销毁顺序反了:记录集对象先析构,把接口指针指向的底层对象释放了,控件此时还握着那个指针,下一次访问就是野指针。解决办法是在对话框的销毁处理里,先主动把数据源解绑,再让记录集正常关闭,保证控件比数据先死。
void CTestGridDlg::OnDestroy() { m_ctrlDG.SetRefDataSource(NULL); // 先解绑:控件主动放掉行集引用 if (m_Rs.IsOpen()) m_Rs.Close(); // 再关记录集 CDialog::OnDestroy(); }顺序是这段代码的全部意义:解绑在前,关闭在后。反过来写,多数时候也能跑,但那"多数时候"就是问题——它取决于控件有没有在你关闭记录集的瞬间正好在绘图。
现象六:格式设了没反应,或者列宽刚设完又变回去。原因基本只有一个:设置动作发生在数据源绑定之前,被绑定过程整体重置了。另一个可能是列索引越界,GetItem找不到列,返回的对象无效,后续所有Set都打在了空气上,还不报错。解决是把所有外观配置集中到一个函数里,只在绑定成功后调用,并且每次从GetCount()拿到的真实列数做循环边界。
这六条有个共同点:除了第一条,其余全都不抛异常。这套机制的设计哲学是"能力探测失败就降级",代价是错误被压到表现层。所以查问题的姿势得改——不是等报错,而是主动打印中间状态:列数、记录集是否打开、游标位置、列索引范围。养成这个习惯之后,这类问题的定位时间能从半天压到几分钟。
6. 取舍:什么时候该用它,什么时候该自己画
最后落到一个实际的选择问题上。这类老控件的价值不在"功能够多",而在"边界够清楚"。它把 COM 在界面层的五件事——类型库生成包装、IDispatch属性包、连接点事件、属性持久化、数据绑定——压缩进一个可以直接拖到对话框上的对象里。想搞明白GetIDsOfNames和Invoke到底怎么协作、事件回调为什么必须跨接口边界、绑定为什么只能探测不能断言,它是一个样本量极小的实验场:一个控件、三个包装类、四五条属性,就能把整条链路走通一遍。
反过来说,它的代价也很明确。部署依赖外部组件注册,一旦环境不干净就是"打不开对话框"这种级别的故障;外观自由度受控件自身属性集限制,想做单元格内嵌进度条、分组行、冻结列这类效果,就得绕很远;数据量上去之后,绑定式控件的重绘和滚动表现会明显吃紧。这三点决定了它的合理位置:内部工具、数据量可控、外观要求不高的管理界面,用它三四行代码就出结果,比自绘省下的是整个渲染层的代码。
我在几个维度上做过对比,结论大致是这样:
| 维度 | DataGrid 绑定式 | 自绘列表/表格 | 商业网格组件 |
|---|---|---|---|
| 首次可用成本 | 极低,绑定即显示 | 高,绘制和编辑全自己写 | 中,接入加配置 |
| 外观自由度 | 受属性集限制 | 完全自由 | 高,但受组件模型约束 |
| 部署依赖 | 需注册外部控件 | 无 | 需注册或静态链接 |
| 数据回写 | 内建,靠绑定接口 | 全部自理 | 通常内建 |
至于验证方法,我推荐一个很土但很有效的动作:把SetRefDataSource那行先用包装类跑通,再换成第 2 章那段裸IDispatch::Invoke跑一遍,两次表现完全一致,你就彻底确认了"包装类只是语法糖"。此后遇到任何"某个属性设了没生效",你的第一反应会是去查分派 ID 和参数打包,而不是怀疑控件有 bug——这个思维转向,比记住十个属性名有用得多。
有一回我在一个内部工具里图省事,把列宽和格式设置写在了绑定之前,本地跑得好好的,交给别人一用全是默认样式。那天之后我每次写这类界面,都强制走一遍"绑定成功 → 调一次统一的列配置函数 → 打印列数和列名做断言"的固定流程,不管多小的窗口。希望这些弯路你不用重走一遍,直接翻到资源里的源码,对着三段核心调用把机制捋顺就行。
本文还有配套的精品资源,点击获取