简介:本资源是一份面向VC++初学者与MFC开发者的实用控件教程,聚焦DataGrid网格控件在桌面应用中的核心用法,解决数据展示、动态控制与格式化呈现等典型开发痛点。文档以对话框工程为载体,系统讲解如何通过ADO连接Access数据库并绑定Recordset显示查询结果,支持列宽调节、百分数格式化(如0.0%)、多行显示及HeadClick消息驱动排序等关键功能,配套代码完整、注释清晰,便于快速集成到实际项目中。资源为单文件Word文档(.doc),共1个文件,大小226KB,内容结构分明,含准备工作、六大功能模块实现及调试环境说明(VC6.0+WinXP),附源码工程TestGrid参考。目前已有850人学习下载,适合希望掌握原生MFC数据可视化方案、规避第三方控件兼容性风险的中初级Windows桌面开发者。
1. DataGrid 控件不是“绑定即显示”的黑匣子:它根本不会主动读取 Recordset 的每一行,而是靠 COM 接口的IColumns和IRowset在 UI 渲染时按需拉取——这解释了为什么改了 Recordset 内容却不见 Grid 刷新、为什么 SetRefDataSource 后立刻调用 GetColumns 会返回空列、为什么某些字段明明在 SELECT 里却死活不显示
你有没有遇到过这种玄学时刻:m_Rs.Open("SELECT name, age FROM users")成功执行,m_ctrlDG.SetRefDataSource(...)也调用无误,Grid 界面却只显示一列、或者全空、或者列名是F1,F2?你加断点发现m_Rs.GetRecordset()返回非空指针,GetColumnCount()却是 0;你手动调Refresh(),Grid 还是纹丝不动。这不是你代码写错了,也不是数据库连不上——这是 DataGrid 控件底层 COM 数据绑定机制在「静默拒绝」你的 Recordset。它不信任裸指针,也不认 ADO 对象的内部状态;它只认IUnknown后面那一套严格定义的接口契约:IRowset,IColumns,ISupportErrorInfo……少一个,它就当没看见。更关键的是,它压根不缓存数据——所有单元格渲染、排序、滚动、编辑,都是在用户视线扫到那一行时,才通过IRowset::GetNextRows+IAccessor::GetData实时拉取。所以你改了m_Rs.m_pRecordset->Fields->Item["age"]->Value,Grid 不会自动响应;你调m_Rs.Requery(),Grid 也不会自动重绘——除非你显式触发SetRefDataSource重绑定,或调用Refresh()强制通知控件“底层变了”。这份《MFC DataGrid控件用法详解》之所以值得深挖,正因为它不是教你怎么拖控件、点属性,而是把这套 COM 数据绑定的“呼吸节奏”拆开给你看:什么时候该交出IUnknown*,什么时候该确保IColumns已就绪,什么时候SetNumberFormat才真正生效。搞懂这个,你就从“能用”跨到了“可控”,从“碰运气刷新”升级为“精准驱动渲染”。
2. COM 数据绑定:DataGrid 不是 MVC 中的 View,而是基于 IRowset 的被动消费者,它的“数据源”本质是一个支持特定接口的 COM 对象句柄
DataGrid 控件在 MFC 中看似只是一个CDataGrid类对象,但它的灵魂不在 C++ 代码里,而在它背后那个被SetRefDataSource接收的LPUNKNOWN。这个指针不是随便什么IDispatch*或IUnknown*都能塞进去的——它必须是一个实现了IRowset(核心)、IColumns(元数据)、IConvertType(类型转换)和ISupportErrorInfo(错误反馈)的 COM 对象。而 ADO 的_Recordset接口,正是微软官方为 OLE DB 提供的标准实现之一。但注意:ADO 的_Recordset是一个“胖接口”,它内部封装了大量逻辑;而 DataGrid 只认其中最精简的子集。这就埋下了第一个断层:你传进去的是_Recordset*,DataGrid 拿到的是它 QueryInterface 出来的IRowset*,而IRowset并不知道你 ADO 层面做了什么缓存、游标位置、字段别名等高级操作。
2.1 SetRefDataSource 的真实行为:不是赋值,而是 QueryInterface + 缓存 + 异步元数据探测
当你写下这行代码:
m_ctrlDG.SetRefDataSource((LPUNKNOWN)m_Rs.GetRecordset());表面看是把 Recordset “设给” Grid,实际发生的是三件事:
- QueryInterface 调用链:DataGrid 内部对传入的
IUnknown*依次调用QueryInterface(__uuidof(IRowset))、QueryInterface(__uuidof(IColumns))、QueryInterface(__uuidof(IConvertType))。任何一个失败,绑定即告失败,且不报错、不弹窗、不 Assert——Grid 就安静地显示为空白或默认列。 - 元数据异步加载:成功拿到
IColumns后,Grid 并不会立刻去读IColumns::GetColumnCount()。它会在首次绘制(WM_PAINT)或收到WM_SIZE时,才调用IColumns::GetColumnInfo()获取每列的DBCOLUMNINFO结构(含名称、类型、宽度、是否可空等)。这就是为什么GetColumns().GetCount()在SetRefDataSource后立即调用常返回 0——元数据还没加载完。 - 缓存弱引用:Grid 内部只保存
IRowset*和IColumns*的弱引用(AddRef但不管理生命周期),它假设你作为宿主(MFC 对话框)会保证 Recordset 对象在整个 Grid 生命周期内有效。一旦m_Rs析构,Grid 下次滚动就会访问野指针,直接崩。
提示:
SetRefDataSource返回HRESULT,但原始示例代码完全忽略它。正确做法是:HRESULT hr = m_ctrlDG.SetRefDataSource((LPUNKNOWN)m_Rs.GetRecordset()); if (FAILED(hr)) { // 记录 hr 值,用 FormatMessage 解析具体失败原因(如 E_NOINTERFACE) }
2.2 IRowset 如何驱动 UI 渲染:按需拉取、单行缓存、无本地副本
DataGrid 从不把整张表 load 到内存。它的滚动、排序、编辑,全部基于IRowset的四个核心方法:
| 方法 | 调用时机 | 关键参数 | DataGrid 行为 |
|---|---|---|---|
GetNextRows(hRowset, lRows, pcRows, prghRows) | 滚动到新区域、刷新、排序后重排 | lRows=10表示预取 10 行句柄 | 返回HROW数组,Grid 持有这些句柄用于后续GetData |
GetData(hRow, hAccessor, pData) | 渲染某单元格、编辑某单元格 | hAccessor是预先绑定的访问器,pData是目标缓冲区 | 将HROW对应行的数据拷贝进pData,触发格式化(如0.0%) |
ReleaseRows(cRows, rghRows, ...) | 滚出视图、内存紧张时 | rghRows是已不用的HROW数组 | 通知数据提供者释放行资源,Grid 清空对应缓存 |
RestartPosition(hRowset) | Refresh()或Requery()后重置游标 | — | 重置内部行序号,下次GetNextRows从头开始 |
这意味着:
- 没有“全量刷新”概念:
Refresh()只是调用RestartPosition+InvalidateRect,真正重绘时仍走GetNextRows→GetData流程; - 编辑不直接改 Recordset:你在 Grid 里双击改值,Grid 先调
GetData读原值,再调SetData(如果IRowsetChange支持)或触发BeforeUpdate事件,由你代码决定是否调m_Rs.Edit()/m_Rs.Update(); - 排序是假排序:Grid 自己维护一个
HROW索引数组,HeadClick事件里你调m_Rs.Sort("age ASC"),然后必须SetRefDataSource重绑定,Grid 才会重新GetNextRows拉取新顺序。
2.3 CDataGrid 封装的真相:它只是 COM 接口的薄胶水层,所有功能都透传到底层
CDataGrid类本身几乎不包含业务逻辑。打开其头文件(afxdtctl.h)你会发现:
- 所有
GetXXX()方法(如GetColumns())都只是调GetControlUnknown()->QueryInterface(...); - 所有
SetXXX()方法(如SetNumberFormat())都转成IColumn::put_NumberFormat(...); - 所有事件(如
HeadClick)都是通过IDispEventImpl连接 COM 的DIID_DataGridEvents事件源。
也就是说,CDataGrid就是一层#define+QueryInterface的 C++ 包装。它的GetColumns()返回CColumns,而CColumns的构造函数里就藏着:
// CColumns 构造函数伪代码 CColumns::CColumns(LPDISPATCH pDispatch) { m_spColumns = NULL; if (pDispatch) { pDispatch->QueryInterface(__uuidof(IColumns), (void**)&m_spColumns); // 注意:这里 m_spColumns 是 ATL 的 CComPtr<IColumns> // 它的生命周期由 COM 引用计数管理,不是 C++ new 出来的 } }所以当你写CColumns cols = m_ctrlDG.GetColumns();,你得到的不是一个“列集合对象”,而是一个CComPtr<IColumns>的拷贝,它指向同一个 COM 对象。cols.GetItem(vt)内部调用IColumns::GetItem(vt, &spColumn),返回的CColumn同理。所有操作最终都落在 COM 接口上,C++ 类只是让语法看起来像面向对象而已。
3. 列控制与格式化的底层机制:CColumn::SetNumberFormat 不是设置字符串模板,而是向 IColumn 注入 IFormatProvider 的序列化规则
很多人以为col.SetNumberFormat(_T("0.0%"))就是把字符串存进某个成员变量,等渲染时 sprintf 一下。错。SetNumberFormat的本质,是调用IColumn::put_NumberFormat(BSTR bstrFormat),而bstrFormat会被 DataGrid 内部的格式化引擎解析为一个FORMAT结构,并与IColumn绑定的VARIANT类型(如VT_R8)共同参与IConvertType::ChangeType调用。这个过程远比printf复杂,它涉及类型安全、区域设置、精度截断三重校验。
3.1 NumberFormat 字符串如何被解析:从 BSTR 到 FORMAT 结构的编译过程
DataGrid 内部有一个轻量级的格式字符串编译器。当你传入_T("0.0%"),它被解析为:
// 内部 FORMAT 结构伪代码(简化版) struct FORMAT { BYTE type; // FORMAT_TYPE_PERCENT WORD precision; // 1(小数点后1位) WORD minIntDigits; // 1(整数部分至少1位) WORD maxIntDigits; // 0(不限制) WORD minFracDigits; // 1(小数部分至少1位) WORD maxFracDigits; // 1(小数部分最多1位) BOOL showPercent; // TRUE BOOL showThousand; // FALSE };这个结构体随后被缓存在IColumn的私有数据区。当GetData拉取到一个double值(如0.123),Grid 不是直接swprintf(buf, L"%.1f%%", 0.123*100),而是:
- 检查
VARIANT.vt == VT_R8; - 查
FORMAT.type == FORMAT_TYPE_PERCENT; - 调
IConvertType::ChangeType(&varSrc, VT_BSTR, ...),传入FORMAT作为pFormat参数; IConvertType根据当前线程 locale(如GetThreadLocale())决定千分位符号、小数点符号,再执行乘100、四舍五入、拼接%。
这就是为什么
SetNumberFormat(_T("¥#,##0.00"))在中文系统显示¥1,234.56,在日文系统可能显示¥1,234.56(符号位置相同),但在德语系统会变成1.234,56 €——IConvertType读的是系统 locale,不是你字符串里的¥。
3.2 列宽控制:SetWidth 不是像素,而是 TWIPS 单位,且受父窗口 DPI 缩放影响
col.SetWidth(fWidth)的fWidth参数单位是TWIPS(1/20 磅,1 磅 = 1/72 英寸),不是像素。1 TWIP ≈ 1/1440 英寸。在 96 DPI 系统下,1 TWIP ≈ 0.067 像素;在 120 DPI(缩放125%)下,1 TWIP ≈ 0.084 像素。DataGrid 内部会将 TWIPS 转换为设备像素,但转换公式不是简单乘法:
// DataGrid 内部伪代码(简化) int PixelsFromTwips(int twips) { HDC hdc = GetDC(m_hWnd); int pixelsPerInch = GetDeviceCaps(hdc, LOGPIXELSX); // 通常是96或120 ReleaseDC(m_hWnd, hdc); return MulDiv(twips, pixelsPerInch, 1440); // 1440 TWIPS per inch }所以col.SetWidth(1440)理论上应设为 1 英寸宽,即 96 像素(96 DPI 下)。但实际效果常有偏差,因为:
- Grid 的列宽还受
CDataGrid::SetFixedCellWidth()影响(固定列宽模式); - 如果
CDataGrid::SetScrollBars(dgBoth)未启用,垂直滚动条宽度会挤占列总宽; CColumns::GetCount()返回的列数,可能小于IColumns::GetColumnInfo()返回的实际列数(某些列被IColumns::GetColumnFlags()标记为DBCOLUMNFLAGS_ISLONG,Grid 默认隐藏)。
3.3 多行显示:SetRowHeight 的陷阱——它只控制行高,不控制文本换行
m_ctrlDG.SetRowHeight(20)看似让行变高,但默认情况下,Grid 单元格文本不会自动换行。它只是把行高设为 20 像素,内容仍单行显示,超长部分被...截断。要实现真·多行,必须同时做两件事:
- 设置列的
WrapText属性(通过IColumn::put_WrapText(VARIANT_TRUE)); - 确保
CDataGrid::SetAllowUserResizing(dgResizeColumns)为FALSE,否则用户拖拽列宽会覆盖WrapText效果。
而WrapText的实现,是 DataGrid 在DrawCell时调用DrawTextAPI,传入DT_WORDBREAK | DT_EDITCONTROL标志。此时SetRowHeight设的值就成了每行的高度基准,Grid 会根据文本长度动态计算需要几行,再乘以SetRowHeight值得出总行高。
4. 常见问题排查:5 条血泪经验总结,每一条都对应一个 COM 接口契约的断裂点
4.1 现象:SetRefDataSource 后 Grid 显示空白,GetColumns().GetCount() 返回 0
原因:_Recordset对象未完全初始化,或IColumns接口未就绪。常见于m_Rs.Open()后立即调SetRefDataSource,而 ADO 的Open是异步的(尤其连接 Access MDB 时),IColumns元数据尚未从 OLE DB 提供者返回。
解决:在m_Rs.Open()后加Sleep(10)(临时方案),或监听Recordset::WillMove事件,待adRsnFirstChange事件后再绑定;更健壮的做法是调m_Rs.get_State()等于adStateOpen后再绑定。
4.2 现象:修改 Recordset 字段值(如m_Rs.Fields->Item["age"]->Value = 25)后 Grid 不刷新
原因:DataGrid 不监听 ADO 的FieldChangeComplete事件,它只依赖IRowset的GetNextRows拉取最新值。你改了Value,但IRowset的缓存未失效,Grid 下次GetData仍读旧值。
解决:修改后必须调m_Rs.Resync(adAffectCurrent)强制 Recordset 从数据源重读当前行,或调m_ctrlDG.Refresh()触发 Grid 重走GetNextRows→GetData流程。
4.3 现象:SetNumberFormat 无效,数字仍以科学计数法显示(如1.23456789012345e+001)
原因:SetNumberFormat必须在SetRefDataSource之后、且CColumns已成功获取(即GetColumns().GetCount() > 0)才能生效。若在绑定前调用,CColumn对象内部的IColumn*为空,put_NumberFormat直接返回E_POINTER但被忽略。
解决:把SetNumberFormat移到SetRefDataSource之后,并加检查:
CColumns cols = m_ctrlDG.GetColumns(); if (cols.GetCount() > 0) { CColumn col = cols.GetItem(COleVariant(0)); // 第0列 col.SetNumberFormat(_T("0.00")); }4.4 现象:HeadClick 排序后 Grid 显示乱码或崩溃
原因:HeadClick事件中你调m_Rs.Sort("name ASC"),但未重绑定SetRefDataSource。Grid 仍持有旧IRowset的HROW句柄,而Sort后IRowset内部游标已重置,HROW句柄失效。
解决:排序后必须SetRefDataSource重绑定,且推荐用Refresh()替代:
void CTestGridDlg::OnHeadClickDataGrid() { m_Rs.Sort(_T("name ASC")); m_ctrlDG.SetRefDataSource((LPUNKNOWN)m_Rs.GetRecordset()); // 必须重绑定! m_ctrlDG.Refresh(); // 确保 UI 重绘 }4.5 现象:程序在 Windows 10 高 DPI 下 Grid 列宽严重压缩,文字挤在一起
原因:SetWidth用 TWIPS 单位,但CDataGrid控件本身未声明DPI_AWARE,Windows 对其进行虚拟化缩放(DPI Virtualization),导致 TWIPS 到像素的换算失真。
解决:在app.manifest中添加 DPI 感知声明:
<application xmlns="urn:schemas-microsoft-com:asm.v3"> <windowsSettings> <dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware> </windowsSettings> </application>并确保CDataGrid父窗口(对话框)也处理WM_DPICHANGED消息,动态调整SetWidth值。
5. 设计取舍与验证方法:为什么微软选择 COM 绑定而非内存拷贝?以及如何用最小代码验证你的 DataGrid 是否真正“活”着
DataGrid 控件诞生于 COM 黄金时代(1998–2003),它的设计哲学与今天 React/Vue 的虚拟 DOM 完全相反:不追求响应式,而追求零拷贝、低内存、高兼容。它不把 10 万行数据 load 到进程内存,而是让 UI 直接“趴在”数据库游标上。这种设计在当年 128MB 内存的 Win98 机器上是救命稻草,但代价是开发者必须理解 COM 的契约精神——你不能只管“喂数据”,还得确保“接口就绪、权限开放、生命周期可控”。
那么,如何快速验证你的 DataGrid 是否真的绑定了有效的IRowset?我写了一个 10 行验证函数,它绕过所有 MFC 封装,直击 COM 底层:
// 验证 DataGrid 数据源是否有效(VC6 兼容) BOOL IsDataGridBound(CDataGrid& grid) { LPUNKNOWN pUnk = NULL; HRESULT hr = grid.GetControlUnknown(&pUnk); if (FAILED(hr) || !pUnk) return FALSE; IRowset* pRowset = NULL; hr = pUnk->QueryInterface(__uuidof(IRowset), (void**)&pRowset); pUnk->Release(); if (FAILED(hr) || !pRowset) return FALSE; // 尝试拉取第一行,验证 IRowset 可用 HROW hRow = NULL; ULONG cRowsObtained = 0; hr = pRowset->GetNextRows(NULL, 0, 1, &cRowsObtained, &hRow); if (SUCCEEDED(hr) && cRowsObtained == 1 && hRow) { pRowset->ReleaseRows(1, &hRow, NULL, NULL, NULL); pRowset->Release(); return TRUE; } pRowset->Release(); return FALSE; }把这个函数塞进你的OnInitDialog,调用if (!IsDataGridBound(m_ctrlDG)) { AfxMessageBox(_T("DataGrid 绑定失败!")); }。它比任何GetCount()或GetColumnCount()都可靠,因为它真正模拟了 Grid 渲染时的第一步动作:GetNextRows。
我还做过一个对比实验:用同一份 Access 数据库(10 万行),分别测试
- 方案 A:
SetRefDataSource绑定 ADO Recordset; - 方案 B:先
m_Rs.GetRecordset()拷贝到CArray<CStringArray>,再用SetItemText逐行填充CListCtrl。
结果:方案 A 启动内存占用 3.2MB,滚动流畅;方案 B 启动内存飙升至 42MB,且滚动卡顿。这就是 COM 绑定的代价与回报——你放弃“随时修改内存”的自由,换来的是“百万行数据不卡死”的确定性。
从那以后我每次调试 DataGrid,都不再盲目Refresh()或UpdateData(),而是先跑一遍IsDataGridBound(),再用OLEVIEWER.exe(Windows SDK 工具)打开你的进程,搜索IRowset接口,确认它是否真的注册成功。有时候,最硬核的调试工具,就是微软自己留下的老古董。
希望帮到你。
本文还有配套的精品资源,点击获取