简介CGridCtrl_demo演示程序是一份面向Visual C开发者的完整示例重点展示MFC表格控件CGridCtrl与CMyODBC数据库访问类的结合用法。开发者在MFC应用中往往需要以网格形式展示、编辑数据库记录这份代码将两者封装并串起从连接数据源、执行SQL查询、填充单元格到用户交互更新回数据库的典型流程。资源共36个文件压缩包仅102KB以头文件.h和源文件.cpp为主配合对话框资源、项目工作区文件.dsp/.dsw及图标位图资源适合在VC6.0或更高版本中直接打开编译学习。已有274人学习代码结构清晰注释直接可直接复用网格控件封装类与ODBC操作模块也可用作开发数据管理界面的起点。对于想深入了解MFC控件扩展、ODBC数据库封装以及表格交互设计的开发者这是一份实用的参考范例。除核心网格实现外还包含单元格选区、行内编辑、拖放目标等辅助类可以观察这些功能如何与数据库更新逻辑协同工作。1. CGridCtrl demo 不是给你一个表格是给你一套可编辑网格的骨架CGridCtrl demo 最常被误读的地方在于很多人以为拖进去一个控件、调用两行SetItemText就完事了结果一运行发现单元格点不动、列头一排序就崩、数据一过万滚起来卡成幻灯片。实际上CGridCtrl 的价值不在“显示表”而在“把 MFC 的对话框变成一个能编辑、能排序、能自绘的 Excel 最小子集”。这个 demo 程序出现得如此频繁恰恰是因为它把这条骨架上最难的三段——创建挂载、数据填充、交互状态管理——全部压缩在了一个可编译的工程里。本文适合正在维护 MFC 桌面程序、想把 CListCtrl 换掉、或者准备自己封装表格控件的开发者。接下来我会按“跑通 demo → 填数据 → 做交互 → 查坑”的顺序把它拆开讲所有代码都是可以直接抄进工程的最小版本。2. 先把 CGridCtrl 挂进 MFC 对话框demo 工程里最基础的三个动作2.1 CGridCtrl 的类结构为什么它由 CWnd 派生而不是 CListCtrlCGridCtrl 核心类是继承自CWnd的窗口控件不是CListCtrl的子类。这一点很关键它没有复用系统列表控件的虚拟窗口而是在自己的WM_PAINT里自绘所有单元格。整个类的内部由两个层次构成——最外层是CGridCtrl负责响应鼠标键盘消息、管理行高列宽内层是CGridCell负责单个格子的文本、颜色、图标和编辑状态。这个分工决定了你在用 demo 的时候看到的所有操作其实都是“坐标命中测试 消息转发到单元格”。理解这一点你才知道 demo 里能学到的第一个技巧不要想着去改CGridCtrl继承体系而是利用它提供的消息接口。比如你要捕获双击拦截NM_CLICK或重写Virtual OnGridEndEdit并不会影响整体绘制逻辑。我一般会在对话框类里直接操作m_grid这个成员而不是派生新控件这样修改范围最小。2.2 解压 CGridCtrl_demo.rar 后把 source 文件集成到你的工程CGridCtrl_demo.rar解压后你会看到典型的 CodeProject 时代工程结构源代码目录里包含GridCtrl.h/.cpp、GridCell.h/.cpp、GridCellBase.h/.cpp以及 InPlaceEdit 相关的几个文件。把这些文件全部拖进你的 MFC 工程的 Source 目录然后在对话框头文件里声明控件成员// MainDlg.h #pragma once #include GridCtrl.h class CMainDlg : public CDialogEx { public: CGridCtrl m_grid; // CGridCtrl 控件成员注意不是指针 };在OnInitDialog中创建并初始化它BOOL CMainDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 创建一个矩形区域先占住对话框位置 CRect rc; GetDlgItem(IDC_PLACEHOLDER)-GetWindowRect(rc); ScreenToClient(rc); // 创建控件风格指定为可见 带边框 m_grid.Create(rc, this, IDC_GRIDCTRL, WS_CHILD | WS_VISIBLE | WS_BORDER); m_grid.SetRowCount(20); m_grid.SetColumnCount(5); m_grid.SetFixedRowCount(1); // 固定第一行作为列头 m_grid.SetFixedColumnCount(1); // 固定第一列形成行头 m_grid.SetColumnWidth(0, 80); m_grid.SetColumnWidth(1, 200); return TRUE; }这段代码的要点有三个Create时传的是this作为父窗口这是与其他控件不同的地方它要求父窗口必须支持反射消息SetFixedRowCount必须在设置行数之后调否则列头区域计算会错IDC_PLACEHOLDER可以用一个真实的Static Text控件占位也可以直接写死CRect(10, 10, 800, 400)。如果工程是_UNICODE编译且你在旧版本代码中看到TCHAR数组注意字符集一致性问题否则编译会报C2664之类的转换错误。2.3 初始化网格必调的三个参数行数、列数、固定行列很多 demo 的初始化代码会把SetGridLines、EnableDragAndDrop和SetEditable一股脑全写上但真正决定网格“能不能用”的只有下面这一组参数。方法作用常见陷阱SetRowCount(n)设置总行数含固定行在设置列宽之后再调会按默认行高重建行导致前面的列宽丢失SetColumnCount(n)设置总列数含固定列不要在SetFixedColumnCount之前调用基于列号的操作SetFixedRowCount(1)设定顶部固定区域固定行会参与绘制但不会进入编辑状态SetColumnWidth(col, w)设定列宽像素单位中途修改该列内容不会自动重算宽度SetEditable(TRUE)允许编辑所有单元格局部只读需要用SetItemState单独指定填充数据之前我把m_grid.SetRedraw(FALSE)调用加上因为每次SetItemText都会触发一次局部刷新几千行数据时肉眼可见地闪烁。全部填完后再SetRedraw(TRUE)并Invalidate()。这是 demo 里最不起眼、但对体验提升最大的一句话后面所有大数据量操作都应该沿用这个模式。提示CGridCtrl 的原版 demo 工程默认是 MFC 静态链接。如果你改用动态链接运行库请保证编译选项一致否则会在运行时出现奇怪的句柄释放断言。3. 填充数据之前先想清楚表格和业务数据的同步模型3.1 定义一个数据行结构不要让网格成为唯一数据源常见错误写法是程序启动后用一堆SetItemText把数据写进网格用户编辑完单元格后再从网格逐行读出来。这在 demo 里能跑但一旦涉及排序、筛选或者撤销你会立刻陷入“网格里的行号和业务数据行号对不上”的泥潭。我建议的做法是在对话框类里定义一个RowData结构网格只负责显示业务逻辑只操作这个结构数组。这样“从数据源到网格”是单向映射排序和编辑后同步回来即可。struct FileRowData { CString name; CString path; CString modifyTime; ULONGLONG fileSize; }; // 对话框成员 std::vectorFileRowData m_rows;填充时循环m_rows写入网格void CMainDlg::RefreshGridFromData() { m_grid.SetRedraw(FALSE); m_grid.SetRowCount((int)m_rows.size() 1); // 1 是固定列头行 for (size_t i 0; i m_rows.size(); i) { int row (int)i 1; // 从第 1 行开始第 0 行留给列头 m_grid.SetItemText(row, 0, m_rows[i].name); m_grid.SetItemText(row, 1, m_rows[i].path); m_grid.SetItemText(row, 2, m_rows[i].modifyTime); // 文件大小转成可读的 MB 字符串 CString sizeStr; sizeStr.Format(L%.2f MB, m_rows[i].fileSize / 1048576.0); m_grid.SetItemText(row, 3, sizeStr); } m_grid.SetRedraw(TRUE); m_grid.Invalidate(); }注意这里SetRowCount的1如果数据有 100 行固定行占 1 行网格总行数就是 101。很多第一次抄 demo 代码的人会在后续点击列头排序时发现索引越界多半就是这里少了固定行偏移。SetItemText的第一个参数始终是网格坐标而不是数据源下标。3.2 列头与单元格样式SetItemFormat 覆盖面板不要逐格重绘demo 里的列头通常是要做背景色和字体区分的。CGridCtrl 提供了一套缺省格式面板你可以在初始化时统一配置而不是对每个单元格单独调样式。// 设置列头文字居中背景灰色 m_grid.SetItemFormat(0, 0, DT_CENTER | DT_VCENTER | DT_SINGLELINE); m_grid.SetItemBkColor(0, 0, RGB(240, 240, 245));如果表格有大量行需要做斑马纹可以直接在填充循环里判断行号奇偶性for (int row 1; row m_grid.GetRowCount(); row) { if (row % 2 0) m_grid.SetItemBkColor(row, 0, RGB(255, 255, 255)); else m_grid.SetItemBkColor(row, 0, RGB(245, 248, 252)); }性能上要注意SetItemBkColor对每个单元格都会触发一次InvalidateRect所以必须把这段代码放在SetRedraw(FALSE)和SetRedraw(TRUE)之间。超过 5000 行时建议只在滚动后按可见区刷新或者直接用后面第 4 章的虚拟列表模式。3.3 从 demo 程序到可编辑网格前三步让单元格进入编辑态你可能会遇到一个奇怪现象默认 demo 里双击单元格能编辑但你的工程不行。这是因为原版 CGridCtrl 在创建时如果父窗口没有处理WM_NOTIFY反射EditCell的触发就会被吞掉。首先要确认SetEditable(TRUE)被调用然后处理列头行禁止编辑m_grid.SetEditable(TRUE); // 禁止固定行进入编辑模式 for (int col 0; col m_grid.GetColumnCount(); col) m_grid.SetItemState(0, col, m_grid.GetItemState(0, col) | GVIS_READONLY);GVIS_READONLY是 CGridCtrl 内部的单元格状态标志与CGridCellBase的编辑逻辑挂钩。这个标志位在你希望“某些列只读”时同样适用。例如path列不想让用户改动循环里多判断字符串等于那一列即可。别忘了控件默认的编辑触发方式是“单击选中再次单击或回车进入编辑”。如果你的用户体验目标是“获得焦点即编辑”需要在派生类里重写OnGetDispInfo之后强制调用EditCellveto 掉第一个单击定时器。提示每次单元格修改结束后网格会发送GVN_ENDLABELEDIT通知。demo 里最常见的改写点就在这个通知里你需要在这里把新文本写回m_rows对应的结构字段否则排序时数据源和网格立刻失去同步。4. 让 demo 更像正经产品排序、虚拟模式与自绘边界4.1 点击列头排序先排数据源再整表重建避免破坏坐标状态CGridCtrl 自带一套排序机制调用SetSortColumn(col)并实现派生类的CompareItems虚函数即可。但我在实际工程里更喜欢绕开它直接用std::sort排业务数据m_rows然后RefreshGridFromData()。理由很朴素——demo 的CompareItems里写列号和比较逻辑一旦列头被拖拽换位列号和字段名的映射就乱了。#include algorithm void CMainDlg::SortByColumn(int col, bool ascending) { std::sort(m_rows.begin(), m_rows.end(), [col](const FileRowData a, const FileRowData b) { if (col 0) return a.name.CompareNoCase(b.name) 0; if (col 3) return a.fileSize b.fileSize; // 按数值而非显示字符串排序 return a.modifyTime.Compare(b.modifyTime) 0; }); if (!ascending) std::reverse(m_rows.begin(), m_rows.end()); RefreshGridFromData(); }注意这里对“文件大小”列排序必须用ULONGLONG的比较而不是字符串比较。否则“1.05 MB”和“1.5 MB”会按字符串0 5的规则排出错误结果。排序完记住把列头的三角标记画出来m_grid.SetSortColumn(col, ascending)。这两个调用一前一后完成视觉和逻辑都对齐。4.2 超过一万条记录用 SetVirtualMode 而不是继续逐行填如果你的 list 数据量上了十万级逐行SetItemText会在启动和切数据源时卡上几百毫秒。CGridCtrl 从版本 3.0 左右开始支持虚拟模式运行时不保存数据只保存类型真实数据在你重写的虚函数里返回。这是一种适合只读浏览场景的模式。void CMainDlg::InitVirtualGrid() { m_grid.SetVirtualMode(TRUE); m_grid.SetRowCount(100000); m_grid.SetColumnCount(4); m_grid.SetFixedRowCount(1); m_grid.SetRedraw(FALSE); // 填充列头名称 m_grid.SetItemText(0, 0, L文件名); m_grid.SetItemText(0, 1, L路径); m_grid.SetItemText(0, 2, L修改时间); m_grid.SetItemText(0, 3, L大小); m_grid.SetRedraw(TRUE); m_grid.Invalidate(); } CString CMainDlg::OnVirtualCellText(int nRow, int nCol) const { if (nRow 0) return L; // 列头由 SetItemText 设置 const FileRowData rd m_rows[nRow - 1]; switch (nCol) { case 0: return rd.name; case 1: return rd.path; case 2: return rd.modifyTime; case 3: { CString sizeStr; sizeStr.Format(L%.2f MB, rd.fileSize / 1048576.0); return sizeStr; } } return L; }OnVirtualCellText只负责返回文本数据的真正所有权仍然属于m_rows。虚拟模式下排序变得无比轻松——你照样用std::sort排m_rows随后调用一次m_grid.Invalidate()即可因为网格在滚动和刷新时会重新向虚拟数据源取值。4.3 自绘的边界能用 SetItemFormat 解决的别重写 DrawCellCGridCtrl 的扩展点很多最激进的是从CGridCtrl派生并重写DrawCell这类虚函数。以我的经验除非你要做那种界面夸张的仪表盘否则不值得。让单元格显示状态点、进度条、内嵌图标绝大多数都能用现有的格式面板完成设置SetItemFormat为DT_CENTER、前景色用SetItemTextColor、背景色用SetItemBkColor。只有当你需要画“单元格右下角带一个小红点”这种非常规内容时才去继承和重绘。重绘时注意菱形坐标CGridCtrl 内部会先计算单元格矩形再调用绘制函数DC的原点在控件左上角不是窗口左上角。直接在重绘函数里写rc.left rc.right / 2这种常见错误会让图形在横向滚动时错位。正确写法是CPoint center((rc.left rc.right) / 2, (rc.top rc.bottom) / 2);自绘代码里不要保存CDC指针跨函数使用因为多显示器 DPI 切换会导致设备上下文失效。这也是 demo 里覆盖较少的场景遇到图形闪烁时优先怀疑BeginPaint/EndPaint配对。5. 排查 CGridCtrl demo 运行期的五个高频故障与一个性能自测技巧5.1 从黑屏到崩溃CGridCtrl demo 运行期故障速查表现象直接原因处理方式网格区域空白无任何边框Create失败或父窗口WS_CLIPCHILDREN未设置检查返回值在OnParentNotify中确认 IDC 不冲突输入中文显示乱码工程字符集为Multi-Byte代码里使用了L字符串统一使用_T()宏或全部转Unicode字符集双击编辑无反应CGridCtrl的消息反射未被父窗口处理重写父类OnNotify调用ReflectLastMsg点击列头崩溃排序回调中访问了空的CGridCell在CompareItems里先用GetItem判断非空表格行数过多时拖动滚动条卡死每行都触发了InvalidateRect改用SetVirtualMode(TRUE)或SetRedraw(FALSE)批量操作遇到黑屏不要急着改代码先用 Spy 确认窗口是否正确创建接着查看输出窗口有没有Assertion Failed。CGridCtrl 原版对断言很严格行号越界会在调试运行时立刻弹出这反而是好事能帮你快速定位行列偏移的 bug。5.2 一把尺子验证填充耗时与滚动帧率的可复现度量优化前先量化。下面这个函数用QueryPerformanceCounter测量“从数据源到网格填充完毕”的耗时精确到毫秒并在标题栏显示帧率基线void CMainDlg::MeasureFillPerformance() { LARGE_INTEGER freq, start, end; QueryPerformanceFrequency(freq); std::vectorFileRowData testData; for (int i 0; i 10000; i) { FileRowData rd; rd.name.Format(LFile_%06d, i); rd.path.Format(LC:\\data\\%06d.bin, i); rd.modifyTime L2024-01-01 00:00:00; rd.fileSize i * 1024; testData.push_back(rd); } QueryPerformanceCounter(start); // 假设 m_rows testData然后调用 RefreshGridFromData() m_rows testData; RefreshGridFromData(); QueryPerformanceCounter(end); double elapsedMs (end.QuadPart - start.QuadPart) * 1000.0 / freq.QuadPart; SetWindowText(LCGridCtrl demo - Fill Time: FormatDoubleToCString(elapsedMs) L ms); }10 万行数据在普通机器上逐行填充通常处于 200 到 600 毫秒区间开启虚拟模式后同样的数据量填充耗时会掉到接近 0因为没有任何单元格数据被写入。之后你可以用IsRowVisible和GetTopRow()判断滚动性能核心观测指标是单次滚动回调耗时是否 16 ms。超过这个值就需要考虑缓存行高计算结果或减少列数。把这段代码放在初始化末尾作为每次改动后的回归基线比肉眼感觉可靠得多。提示如果调试运行时发现填充耗时有极大抖动先排除杀毒软件实时扫描和性能监视器干扰再用任务管理器锁定 CPU 频率避免睿频造成测量误差。本文还有配套的精品资源点击获取