
简介这是一份面向MFC开发者的CGridCtrl与ODBC数据库整合演示程序主要用于解决Windows桌面应用中数据表格展示与编辑的常见需求。示例将CGridCtrl控件与CMyODBC封装类结合完整演示了从建立数据库连接、执行SQL查询、填充网格到用户编辑后回写数据库的经典流程并包含异常处理与连接释放的规范写法适合正在学习MFC控件扩展或需要快速搭建数据管理界面的中高级开发人员参考。压缩包共36个文件以15个头文件h和12个实现文件cpp为主体同时包含对话框资源、图标位图及工程配置文件压缩后仅102KB结构紧凑、便于下载研读。已有274人浏览学习说明该demo具备不错的实践参考价值。通过研读源码开发者可以快速掌握CGridCtrl与CMyODBC在真实项目中的协作方式为自研表格类数据管理工具提供可直接复用的代码骨架。1. 从 CGridCtrl_demo 压缩包开始先搞清楚这个控件解决什么问题接手一个维护了三年的 MFC 桌面项目业务方突然要求把物料清单做成 Excel 那种可点可改的表格。CListCtrl 虽然能显示行和列但让用户双击单元格改数据、点列头排序、拖列宽代码量立刻失控。翻遍手上的旧代码找到这个CGridCtrl_demo.rarCGridCtrl 的 demo 程序。解压后是一个基于自绘owner-draw的网格控件示例可能是 MFC 生态里最实用的表格控件包之一。CGridCtrl 把每个单元格当作独立对象管理可以在运行时填文本、插按钮、加下拉框、改背景色。这个 demo 的价值不在控件本身而是它演示了最常见的交互场景编辑单元格、拖拽列宽、数据排序、把自定义控件塞进单元格。适合嫌 CListCtrl 不够用的老 MFC 开发也适合想快速看懂网格控件事件模型的人。2. 先从 CGridCtrl 的控件模型说起网格、单元格与容器2.1 为什么 MFC 项目里选 CGridCtrl 而不是 CListCtrl先看 CListCtrl 的边界。它在 report 模式下本质是“行 列”的扁平视图每个子项只能有一个文本标签和一幅图标。想给某个单元格放一个 ComboBox需要自己处理子窗口的创建、移动、销毁还要在滚动时同步位置。想改变某一行的底色必须处理 NM_CUSTOMDRAW按行、按列、按状态写分支。如果表格数据每天来自数据库列数动态变化CListCtrl 的 InsertColumn 逻辑会变成一堆补丁。CGridCtrl 换了一种思路控件自己管理一个二维单元格矩阵每个单元格是 CGridCell 或其派生对象有自己的文本、图片、样式、状态、编辑控件类型。绘制走自绘滚动负责计算可见区域数据与视图的边界比 CListCtrl 清晰。也就是说你不必为了“单元格里出现一个下拉框”去重写整个控件只需要把这个位置上的单元格换成 CGridCellCombo剩下的事情控件自己接管。这就是 demo 最能说明问题的部分它把“单元格作为对象”这个模型通过代码展示出来而不是停留在概念里。2.2 核心类层次CGridCtrl、CGridCell 与 CGridColumnHeader打开 CGridCtrl 的头文件会看到这几个类。CGridCtrl 是主窗口派生自 CWnd负责消息循环、键盘输入、鼠标点击、滚动条、选区状态和行/列几何。CGridCell 是单元格对象保存文本、图像、背景色、字体、对齐方式还负责在需要的时候创建一个 CEdit 或其他编辑子窗口来完成编辑。CGridColumnHeader 是列头区域处理拖拽列宽、点击排序和列头绘制。此外还有 CGridCellBase 作为单元格的公共基类CGridRange 和 CGridCellRange 表示选区或合并区域。下面是常见 demo 工程里会出现的文件我一般用这个列表来判断一个网格包裁剪到什么程度文件职责CGridCtrl.h / .cpp网格主控行列管理、消息处理、排序、选区CGridCell.h / .cpp默认单元格类型文本显示与编辑CGridCellBase.h / .cpp单元格虚基类定义绘制和编辑接口CGridColumnHeader.h / .cpp列头绘制与拖拽交互CGridCellCombo.h / .cpp下拉框单元格扩展demo 里常见CellRange.h区域选择与数据交换辅助类要理解这套类之间怎么协作可以看一个最简单的创建流程。通常在对话框的 OnInitDialog 里构造网格然后立刻设置行列数和固定行列// 创建网格 m_grid.Create(CRect(10, 20, 640, 400), this, 1001); // 设置 5 行 4 列 m_grid.SetRowCount(5); m_grid.SetColumnCount(4); // 第 0 行和第 0 列作为固定表头和行头 m_grid.SetFixedRowCount(1); m_grid.SetFixedColumnCount(1);这个流程里 CGridCtrl 会根据行列数创建 CGridCell 对象数组固定行列区域内的单元格默认使用 CGridCell 的“表头”绘制样式。如果你不调用 SetFixedRowCount那么所有单元格都会进入普通数据区滚动时表头也会跟着滚。demo 里的表格页通常都会先设固定行列因为业务上“表头永远可见”是刚需。2.3 先弄清楚 demo 里有多少个页面再决定从哪个看起来CGridCtrl_demo 这类压缩包解压后一般是一个 VC 工程常见的结构是一个多文档或对话框程序外壳负责菜单和切换内部用几个页面分别演示网格的基本特性、单元格扩展和打印预览。我第一次看时不会急着按 F5而是先打开 MainFrm 或 Dialog 的初始化函数找到创建 CGridCtrl 的地方看它 SetRowCount 之前还做了什么。一个值得留意的点是字符集。旧 demo 工程经常使用 MBCS 编码而现代 Visual Studio 默认 Unicode。如果编译时报一堆 _T 宏相关的 C2664 错误第一件事不是改代码而是把项目属性里的字符集改成“使用多字节字符集”或者全局替换 CString 参数。这个坑在打开任何老 MFC 示例时都会遇到CGridCtrl 也不能幸免。这里先不展开具体页面后面第 4 章我会重点讲 demo 里最常见的交互代码。现在你已经清楚CGridCtrl 不是把表格画出来就完了它是把“单元格”当成可替换的对象一切高级交互都建立在这个对象模型上。3. 把 CGridCtrl_demo 跑起来编译、最小示例与参数调整3.1 用 Visual Studio 打开 demo 工程先改三个配置拿到解压后的目录用 Visual Studio 打开其中的 .sln 或 .dsw。如果是 .dsw 说明是 VC6 时代的工程VS2015 以上打开会提示转换直接确认即可。转换后先做三件事把解决方案平台设为 x86 或 x64按目标环境字符集设为 Unicode除非你需要 MBCS把“使用 MFC”设置为“在共享 DLL 中使用 MFC”这样 demo 里的资源文件不用逐个复制。配置完成后按 F7 编译。常见的第一个报错是#include stdafx.h找不到这是因为旧工程预编译头设置不同。项目属性里检查“预编译头”是否为“使用”如果是则必须保证所有 .cpp 第一行包含 stdafx.h如果不想用预编译头就把“预编译头”改成“不使用”并把 stdafx.h 的内容合并到每个含依赖的 .cpp 中。这个操作不伤逻辑但能节约不少折腾时间。编译通过后运行起来会看到菜单或者选项卡。demo 程序一般会列出 Basic Demo、Combo Cell、Printing 之类的页面。先点 Basic Demo拖一下列宽双击数据区输入文字感受一下默认交互。这里的信息量比看代码大因为你会立刻知道哪些行为是控件默认拥有的哪些是需要写代码加的。3.2 从空白对话框创建一个最小 CGridCtrl看完 demo 后照着自己建一个最小工程。新建“MFC 应用程序”选择对话框模板然后在对话框类里加一个成员变量。注意 CGridCtrl 不是资源编辑器里能直接拖出来的控件所以没有对应的 DDX 绑定必须在 OnInitDialog 里用代码创建#include CGridCtrl.h class CGridDemoDlg : public CDialogEx { public: virtual BOOL OnInitDialog() override; afx_msg void OnBnClickedLoadData(); private: CGridCtrl m_grid; };BOOL CGridDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 位置和 ID 可以自定义 m_grid.Create(CRect(10, 10, 620, 300), this, 10086); // 基础行列设置 m_grid.SetRowCount(5); m_grid.SetColumnCount(4); m_grid.SetFixedRowCount(1); m_grid.SetFixedColumnCount(1); // 表头内容 m_grid.SetItemText(0, 0, _T(编号)); m_grid.SetItemText(0, 1, _T(名称)); m_grid.SetItemText(0, 2, _T(数量)); m_grid.SetItemText(0, 3, _T(备注)); // 允许单元格编辑 m_grid.SetEditable(TRUE); return TRUE; }这段代码的要点在几个函数的行为上。Create的第一个参数是窗口矩形网格会自己处理滚动条位置你只需要保证矩形合理。SetRowCount和SetColumnCount会立即重建内部单元格数组如果你先填了内容再调它内容会丢失所以顺序固定为先设行列数再写数据。SetFixedRowCount(1)表示第 0 行是固定行不会跟随垂直滚动滚出视野SetFixedColumnCount(1)同理。如果你需要表头显示图标或允许列头点击排序还需要额外设置SetHeaderSort(TRUE)以及在列头点击时提供排序回调。这个我会在下一章结合 demo 演示展开。此时先保证窗口上能看到一个有边框的 5x4 表格并且双击任意单元格能出现输入光标。3.3 demo 里常用的初始化参数按需抄进自己的工程CGridCtrl 的初始化参数很多但 demo 里出现频率最高的就是这几个参数方法作用使用位置SetRowCount / SetColumnCount设置总行数/总列数创建后第一件事SetFixedRowCount / SetFixedColumnCount设置固定表头行/列数量数据展示前SetEditable控制整个网格是否可编辑初始化SetGridLines是否显示网格线初始化SetListMode是否启用行选模式类似 ListView需要整行选中时SetColumnResize是否允许拖拽列宽默认开启SetHeaderSort是否允许点击列头排序有排序需求时SetRowResize是否允许拖拽行高行高可变时我一般会把这些调用收进一个InitGrid()私有函数因为网格一旦跑起来之后再改行数或固定行会导致滚动范围和选区状态变化混在业务代码里不好排查。demo 里由于是演示环境往往会直接在消息处理函数里设置但你自己的产品代码最好把“网格结构初始化”和“业务数据填充”分成两个阶段。这样后续从数据库加载数据时重复调用填充函数也不会重建整个控件。4. 深入 demo 的交互特性编辑、下拉框与排序事件4.1 数据填充从数组或数据库写入 CGridCtrldemo 里展示的数据大多写死在代码里但真实项目里数据来自 SQL 或 CSV。填充数据要记住一点先设行列再填充最后再刷新。因为每次 SetRowCount 或 SetColumnCount 都可能重新分配单元格如果填充过程中有用户正在滚动界面会闪。常见的做法是先用 SetRedraw(FALSE) 关闭重绘m_grid.SetRedraw(FALSE); m_grid.SetRowCount((int)records.size() 1); m_grid.SetColumnCount(6); m_grid.SetFixedRowCount(1); for (int row 0; row records.size(); row) { for (int col 0; col 6; col) { m_grid.SetItemText(row 1, col, records[row][col]); } } m_grid.SetRedraw(TRUE); m_grid.Invalidate();这里为什么要 SetRedraw(FALSE)因为 SetItemText 每调用一次如果某个单元格在可见区域控件就会触发局部重绘。批量填充几百行时重绘次数等于单元格数量即使每次只画一个矩形累积起来也能感觉到迟滞。关闭重绘后再统一刷新是最直接的性能提升做法。如果你还要保存数据到对象可以用 SetItemData 把数据指针绑到单元格上但要注意 CGridCtrl 不负责释放这个指针。如果存在行删除操作析构或删除行时你需要自己遍历单元格释放数据。demo 里通常会简化这一点只保存 int 型 ID这是最省心的做法。4.2 把单元格改成下拉框运行时替换单元格类型网格控件真正的优势在“单元格类型可以变”。你要做的不是绘制一个假的 ComboBox而是把单元格对象替换成 CGridCellCombo。在 demo 里切换到 Combo 页面可以看到效果对应的代码模式如下#include CGridCellCombo.h // 假设第 3 列需要做状态选择 m_grid.SetCellType(3, 3, RUNTIME_CLASS(CGridCellCombo)); CGridCellCombo* pCell (CGridCellCombo*)m_grid.GetCell(3, 3); if (pCell) { pCell-SetStyle(CBS_DROPDOWNLIST); pCell-AddString(_T(待处理)); pCell-AddString(_T(处理中)); pCell-AddString(_T(已完成)); }调用SetCellType之后指定位置的单元格会被替换成新的类实例旧对象由 CGridCtrl 自己释放。之后当你点击这个单元格进入编辑状态时控件发现其类型是 CGridCellCombo就会创建真正的 ComboBox而不是默认的 CEdit。SetStyle传入的是 Windows ComboBox 样式CBS_DROPDOWNLIST意味着用户只能从列表中选择不能手工输入。如果要用可输入的样式改成CBS_DROPDOWN并调SetMaxEdit限制输入长度。这里有一个容易踩的坑GetCell返回的是 CGridCellBase 指针你必须先确认类型再强转。如果直接转而该位置恰好是固定行或非 Combo 单元格会读出未定义数据。安全做法是先查GetCell返回对象的运行时类或者用IsKindOf(RUNTIME_CLASS(CGridCellCombo))判断。4.3 排序与编辑结束事件从接口到回调列头点击排序在 demo 里是另一块常演示的内容。开启SetHeaderSort(TRUE)后点击列头会调用SortItems排序规则由 CGridCtrl 内部的一个回调函数决定。默认实现对字符串比较如果你需要按数字排序就要自己写比较函数。一种常见模式是重载 CGridCtrl 的CompareItems虚函数class CDemoGridCtrl : public CGridCtrl { protected: virtual int CompareItems(const CGridCellBase* pCell1, const CGridCellBase* pCell2) const; }; int CDemoGridCtrl::CompareItems(const CGridCellBase* pCell1, const CGridCellBase* pCell2) const { int nVal1 _ttoi(pCell1-GetText()); int nVal2 _ttoi(pCell2-GetText()); return nVal1 - nVal2; }这个比较函数做的工作很简单取两个单元格的文本转成数字后相减。但注意它不感知升序还是降序CGridCtrl 会在内部根据列头方向自动处理。如果你把 CGridCtrl_demo 跑起来点一次列头是升序再点一次是降序你不需要在回调里维护方向状态。编辑结束事件则是另一个接口。CGridCtrl 在单元格编辑完成按下回车或鼠标点出时会触发虚拟函数OnEndEdit你可以在自己的派生类里拿到行列号和字符串class CDemoGridCtrl : public CGridCtrl { protected: virtual void OnEndEdit(int nRow, int nCol, CString str); }; void CDemoGridCtrl::OnEndEdit(int nRow, int nCol, CString str) { // 这里写回业务数据比如更新你的结构体。 TRACE(_T(结束编辑 [%d][%d] %s\n), nRow, nCol, str); CGridCtrl::OnEndEdit(nRow, nCol, str); }写事件处理时有个细节OnEndEdit和OnEndInPlaceEdit是两条路径。前者是数据已经提交到单元格时做通知后者是编辑控件将要销毁时做的清理。如果只是记录最终文本用 OnEndEdit 就够如果你还需要在失去焦点时先校验合法性、非法则取消编辑就得介入 OnEndInPlaceEdit 返回 FALSE 的逻辑。demo 里一般只演示前者但真实项目里校验逻辑常常在后者里写。这些接口的触发层次不同我整理成一张表方便对照接口触发时机典型用途OnEndEdit编辑文本提交后数据回写、校验OnEndInPlaceEdit编辑控件销毁前取消编辑、保存控件状态CompareItems点击列头且 SetHeaderSort(TRUE)自定义排序规则GetCell任意时刻获取单元格指针不可长期持有4.4 批量更新与数据回写我常用的三个封装除了 demo 直接展示的功能实际开发中我会给 CGridCtrl 再加几个辅助方法避免调用点到处都是行列号魔法值。第一个是int AddRow(const std::vectorCString data)内部按数据宽度列号填充并自动增加行数第二个是void SetCellData(int row, int col, int nData)包装 SetItemData 并记录到自建索引第三个是void OnLoadFromDB(...)统一做 SetRedraw(FALSE) 到 Invalidate 的过程。这样当用户从 demo 里的硬编码数据迁移到真实数据源时改动能限制在一个文件里。5. 避开 CGridCtrl 的陷阱重绘闪烁、内存边界与自绘扩展5.1 重绘闪烁的 3 个修复点CGridCtrl 本身有双缓冲但在固定行列数量多、频繁 SetItemText 时仍然会闪。第一个修复点是在批量更新前关掉重绘也就是第 4.1 节说的 SetRedraw(FALSE)第二个修复点是继承 CGridCtrl 重写 OnEraseBkgnd 直接返回 TRUE阻止背景色填充产生的白闪第三个修复点是合理使用 InvalidateRect 而不是 Invalidate。如果你发现滚动时闪烁严重大概率是这三个点中的一个没做。5.2 GetCell 指针不要长期保存CGridCtrl 在 InsertRow、DeleteRow、SetRowCount 操作后内部数组会重建旧 GetCell 指针立刻失效。常见的崩溃场景是用户在某个按钮回调里保存了 GetCell(2,3)然后滚动几下再访问程序就访问到已释放内存。安全做法是每次使用时临时获取不要缓存。如果你必须跨函数保存请保存 row 和 col再访问时重新 GetCell。5.3 用一个自绘单元格实现状态灯日常业务里最常见需求是让某个单元格显示一个带颜色的圆点而不是一段文字。要实现它不需要改 CGridCtrl只需要派生一个 CGridCell 子类并重载 Draw这个扩展方式和 demo 里的自定义单元格页是同一套路。下面是一个最小实现#include CGridCell.h class CStatusCell : public CGridCell { public: void SetStatusColor(COLORREF color) { m_color color; } protected: virtual void Draw(CDC* pDC, int nRow, int nCol, CRect rect, BOOL bEraseBkgnd TRUE); COLORREF m_color RGB(0, 0, 0); }; void CStatusCell::Draw(CDC* pDC, int nRow, int nCol, CRect rect, BOOL bEraseBkgnd) { CGridCell::Draw(pDC, nRow, nCol, rect, bEraseBkgnd); int radius min(rect.Width(), rect.Height()) / 3; CPoint center(rect.left radius 2, rect.top rect.Height() / 2); CBrush brush(m_color); CBrush* oldBrush pDC-SelectObject(brush); pDC-Ellipse(center.x - radius, center.y - radius, center.x radius, center.y radius); pDC-SelectObject(oldBrush); }然后在初始化时把它设到目标位置m_grid.SetCellType(2, 1, RUNTIME_CLASS(CStatusCell)); CStatusCell* pStatus (CStatusCell*)m_grid.GetCell(2, 1); if (pStatus) { pStatus-SetStatusColor(RGB(255, 0, 0)); }这段代码的关键是 Draw 里先调基类把你的文本、背景画完再在矩形区域内画圆。GetCell 后强转为 CStatusCell 是安全的因为 SetCellType 已经把该位置对象的运行时类替换掉了但为了严谨强转前仍然建议用 IsKindOf 判断。下一步要改颜色深浅只需在 SetStatusColor 里保存更多状态并参与 Draw 的刷子计算比如把颜色拆成 RGB 三个分量按数据数值计算亮度这样状态灯就能像 demo 里的热力图一样随业务数据变化。本文还有配套的精品资源点击获取