简介使用MFC开发Windows桌面程序的工程师处理大型数据表时常会遇到MSFlexGrid控件报错或卡顿的情况。这套表格控件为此提供了可靠的替代方案采用虚拟缓冲模式实测可流畅显示并刷新2000乘2000的虚拟表格从根源上解决大表格场景下的性能瓶颈非常适合实时监控、设备参数列表等高频率刷新界面也是报表系统、数据监控面板的理想底层组件。资源包共90个文件、仅422KB以27个头文件和23个源文件为核心覆盖表格控件本体、多种单元格类型、下拉编辑、就地输入等常用功能模块同时附带VS2003、VS2008工程文件和两个可运行示例分别演示在对话框与视图框架中的用法方便快速移植压缩包内另含针对VS2013修改过的版本解决较新环境的兼容问题。全部源码无需注册、无DLL依赖可直接嵌入项目也可按需定制单元格表现核心类结构清晰按功能模块组织便于阅读与二次开发。已有4758人学习下载是MFC表格开发中值得备用的实用组件。 前阵子帮人收拾一个MFC上位机的遗留项目对话框上那张表格一到数据刷新就白屏闪烁选中一行后窗口失去焦点整行立刻变成灰不拉几的颜色客户张口就问这个控件是不是坏了。这类问题我在MFC项目里见得太多了。说句实在话MFC的表格控件CListCtrl并不难用大多数人觉得它难是因为一开始就把控件样式、扩展样式和自绘机制用错了。这篇文章就围绕MFC里最常用的表格控件把选型、初始化、选中态处理、排序编辑以及大数据量优化这些点一次讲透适合用MFC写上位机、数据采集工具、桌面管理软件的开发者参考。1. 选型CListCtrl、CGridCtrl还是自绘先想清楚你要的是表格还是数据网格1.1 三种方案的边界MFC里做表格绕不开三条路直接用系统公共控件CListCtrl、引入第三方网格控件CGridCtrl、完全自己自绘。CListCtrl是Win32 ListView控件的MFC封装天生支持报表视图、图标视图、列表视图还能开虚拟列表模式。它适合做记录列表、设备清单、日志窗口、参数配置表这类以展示为主的场景优势是稳定、占用低、和系统主题融合自然。CGridCtrl是社区里流传很广的网格控件行为更接近Excel。它支持单元格级编辑、行列选择、复制粘贴、公式回调之类的能力适合做数据录入、报表编辑这类需要跟格子打交道的需求。自绘则是完全绕过控件的默认绘制逻辑在NM_CUSTOMDRAW或OwnerDraw里自己画每一笔。适合项目里对UI风格有强烈定制要求的场景比如整体走深色主题、圆角边框、特殊高亮效果。1.2 我见过的最典型的选型翻车现场有一个项目要用CListCtrl实现多行表头、单元格编辑、公式联动结果改了一个多月网格线画出来还是歪的最后换了第三方网格控件两天就搞定。也见过有人用CGridCtrl做实时日志列表数据每秒进来上百条CGridCtrl每次都全量重算单元格CPU占用直接拉满界面假死。这两个都是典型的选型没想清楚。CGridCtrl的设计重心是录入和编辑不是高频刷新CListCtrl的设计重心是展示和规模你非让它做Excel的事情它当然干不好。多行表头、单元格合并这种特殊需求要么换商业控件要么自己在表头区域或内容区域接管绘制别硬塞给CListCtrl。1.3 我的选型底线我自己的判断标准很简单只读展示、数据量大、刷新频繁CListCtrl 虚拟列表不用犹豫。需要单元格级编辑、操作像Excel选CGridCtrl或成熟的网格控件。需要完全定制外观自绘但先把双缓冲、命中测试、焦点管理做明白再动手。如果需求还没完全清晰先拿CListCtrl跑通原型比一上来就引入第三方控件靠谱。很多人被第三方控件牵着手走最后发现问题比CListCtrl还多那时候想回退就晚了。2. CListCtrl初始化扩展样式、列宽、随窗口缩放一次配齐2.1 扩展样式不是装饰是使用体验的分水岭CListCtrl有一个SetExtendedStyle方法决定表格交互体验的90%都在这。我基本每次都会开LVS_EX_FULLROWSELECT整行选中、LVS_EX_GRIDLINES网格线、LVS_EX_DOUBLEBUFFER双缓冲。DWORD dwStyle m_list.GetExtendedStyle(); dwStyle | LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER; m_list.SetExtendedStyle(dwStyle);有几个坑要提醒。LVS_EX_DOUBLEBUFFER在Windows XP以上的系统才真正起作用而且不是所有主题下都生效如果程序要跑老系统或者界面仍然闪烁只能靠自绘双缓冲兜底。LVS_EX_LABELTIP在列宽偏窄时可以自动弹出完整文本提示字段多的场景建议加上。开LVS_EX_CHECKBOXES之前要考虑清楚它会让第一列变窄而且和某些自绘逻辑冲突。2.2 列宽设置的三个隐藏问题第一InsertColumn之后必须马上SetColumnWidth顺序颠倒会导致列宽设置不生效。第二LVSCW_AUTOSIZE按当前内容自动调整列宽如果你在插入数据前调用它只会按表头宽度去算数据填完列宽不会变。第三LVSCW_AUTOSIZE_USEHEADER只参照表头文本适合初始化时用但它不会考虑内容长文本会被截断。我习惯的做法是初始化时按表头设置一个合理宽度数据填充完再对需要自适应的列调用一次SetColumnWidth。比如m_list.InsertColumn(0, _T(时间), LVCFMT_LEFT, 140); m_list.InsertColumn(1, _T(设备), LVCFMT_LEFT, 100); m_list.InsertColumn(2, _T(数值), LVCFMT_RIGHT, 80);插入行时注意InsertItem返回的是行下标后续SetItemText都要用这个下标int nRow m_list.InsertItem(0, strTime); m_list.SetItemText(nRow, 1, strDevice); m_list.SetItemText(nRow, 2, strValue);2.3 窗口缩放时让列表列宽按比例跟随这个问题经常被忽略。对话框放大或DPI切换后表格列宽不会自动拉伸右边空出一大块很难看。我的做法是保存每列的初始宽度在OnSize里按当前客户区宽度重新分配。void CMyDialog::OnSize(UINT nType, int cx, int cy) { CDialogEx::OnSize(nType, cx, cy); if (!m_bInitFinished || !m_list.GetSafeHwnd()) return; int nTotal 0; for (int i 0; i m_nColCount; i) nTotal m_nColWidth[i]; if (nTotal 0) return; int nAvail cx - ::GetSystemMetrics(SM_CXVSCROLL) - 20; for (int i 0; i m_nColCount; i) { int nNewWidth nAvail * m_nColWidth[i] / nTotal; m_list.SetColumnWidth(i, MAX(nNewWidth, 40)); } }OnSize会被反复调用初始化标志位一定要处理好否则窗口创建阶段就会崩。另外如果想固定某列不跟随缩放可以在按比例分配后单独把那列宽度Set回去。2.4 顺手解决CString转char的开销热搜词里有c mfc cstring 转 char【】正好是表格数据填充时高频出现的问题。字符串转换本身不慢但放在循环里频繁构造临时对象内存分配次数一多卡顿就来了。CString str _T(hello); char szBuf[256] {0}; CT2A tmp(str); strcpy_s(szBuf, tmp);如果循环里要插入上千行先把要转换的字符串一次性转成CStringA数组循环里只做赋值不要反复构造CT2A。CT2A在循环里每一次都会分配和释放内存积累起来对刷新性能影响很明显。3. 失焦变灰和选中高亮一条NM_CUSTOMDRAW走天下3.1 先拆解失焦变灰的成因很多做MFC上位机的人都遇到过一个诡异现象表格选中一行时明明是蓝色一旦点击窗口其他区域或者弹出对话框选中行立刻变成灰色。这个问题的根源不是你的代码写错而是系统主题在绘制ListView时区分了激活选中和未激活选中两种状态。当CListCtrl持有焦点时主题绘制选中项用高亮蓝失去焦点后主题自动改为非活动状态的灰色。LVS_EX_FULLROWSELECT只影响选中区域是整行还是第一个子项根本管不了失焦颜色。网上有人建议处理WM_KILLFOCUS和WM_SETFOCUS实际上只能刷新改不了绘制结果。3.2 CustomDraw方案的具体落地最省事的路子是给CListCtrl去掉主题让系统不再用主题绘制选中背景然后通过NM_CUSTOMDRAW自己控制文本和背景色。去主题只需要一行SetWindowTheme(m_list.GetSafeHwnd(), _T(), _T());去掉主题后处理NM_CUSTOMDRAW消息在每个子项绘制前判断选中状态指定文本颜色和背景色void CMyTable::OnNMCustomdraw(NMHDR* pNMHDR, LRESULT* pResult) { NMLVCUSTOMDRAW* pLVCD reinterpret_castNMLVCUSTOMDRAW*(pNMHDR); *pResult CDRF_DODEFAULT; if (pLVCD-nmcd.dwDrawStage CDDS_PREPAINT) { *pResult CDRF_NOTIFYITEMDRAW; } else if (pLVCD-nmcd.dwDrawStage CDDS_ITEMPREPAINT) { *pResult CDRF_NOTIFYSUBITEMDRAW; } else if (pLVCD-nmcd.dwDrawStage (CDDS_ITEMPREPAINT | CDDS_SUBITEM)) { if (pLVCD-nmcd.uItemState CDIS_SELECTED) { pLVCD-clrTextBk RGB(0, 120, 215); pLVCD-clrText RGB(255, 255, 255); } } }这里有个经验要分享只处理NM_CUSTOMDRAW不配合SetWindowTheme很多时候会被系统主题盖回去表现不稳定。去掉主题后网格线、文字都还在只是少了主题的高光边缘效果但对上位机工具来说能换来选中色稳定统一值。去掉主题再设置clrTextBk和clrText失焦后选中行也会保持这个蓝色不会再变灰。3.3 与皮肤库共存时的注意事项MFC项目里喜欢用皮肤库统一界面风格但皮肤库通常挂钩了ListView的绘制可能把你CustomDraw里设置的RGB再盖掉一次。如果你开了皮肤库先别急着硬编码颜色去皮肤库的配置里找ListView节点看看能不能直接设置选中背景色。如果皮肤库没有开放这个属性才考虑禁掉它对该控件的绘制自己去接管。否则每换一套皮肤表格选中色就乱一次排查起来很痛苦。4. 排序、双击编辑、右键菜单的完整实现笔记4.1 SortItems排序回调里不能GetItemText表头点击排序是表格控件最常用的功能之一但这里藏着一个大坑。很多人写排序回调时直接在函数里调用GetItemText取文本再比较轻则排序极慢重则断言崩溃。原因是SortItems回调执行期间ListView内部正在调整项状态这时候再去查询项文本状态根本不可靠。正确的做法是插入数据时用SetItemData绑定数据索引排序回调里通过lParam取回这个索引然后访问自己的数据容器m_list.InsertItem(nRow, strTime); m_list.SetItemData(nRow, nDataIndex);回调函数里只做数据比较static int CALLBACK MyCompare(LPARAM lParam1, LPARAM lParam2, LPARAM lParamSort) { CMyTable* pThis reinterpret_castCMyTable*(lParamSort); RowData d1 pThis-m_data[pThis-m_list.GetItemData((int)lParam1)]; RowData d2 pThis-m_data[pThis-m_list.GetItemData((int)lParam2)]; return d1.strTime.CompareNoCase(d2.strTime); }排序完成后不需要重新SetItemDataSortItems会自动维护ItemData和项位置的绑定关系只需要Invalidate重绘即可。另外排序回调里用GetItemText还有一个额外问题它返回的是CString每次都要拷贝数据量大时性能开销直接爆掉。4.2 任意列双击编辑的通用做法CListCtrl自带的LVS_EDITLABELS只能编辑第一列而且点击任意位置都会进入编辑并不好用。我推荐的方案是子类化CListCtrl在NM_DBLCLK或LVN_ITEMACTIVATE里定位到单元格动态创建一个CEdit覆盖在上面。CRect rcCell; m_list.GetSubItemRect(iItem, iSubItem, LVIR_BOUNDS, rcCell); m_edit.Create(WS_CHILD | WS_VISIBLE | WS_BORDER | ES_AUTOHSCROLL, rcCell, m_list, IDC_CELLEDIT); m_edit.SetFont(m_list.GetFont()); m_edit.SetWindowText(m_list.GetItemText(iItem, iSubItem)); m_edit.SetFocus(); m_edit.SetSel(0, -1);关键细节有三个。CEdit字体必须和列表字体一致否则文字错位编辑框创建前把列表的滚动位置考虑进去GetSubItemRect返回的坐标是客户区坐标直接可用CEdit失去焦点时把文本写回SetItemText然后销毁不要在WM_DESTROY里再触发SetItemText否则焦点和重绘顺序会乱。4.3 右键菜单与当前选中行不同步的坑右键菜单最经典的问题是鼠标点在某一行的空白处但菜单操作的是之前左键选中的另一行用户一脸懵。处理方式是在NM_RCLICK里先判断点到了哪一行如果存在先选中这一行再弹菜单。void CMyTable::OnNMRClick(NMHDR* pNMHDR, LRESULT* pResult) { LPNMITEMACTIVATE pNMItem reinterpret_castLPNMITEMACTIVATE(pNMHDR); if (pNMItem-iItem 0) { m_list.SetItemState(pNMItem-iItem, LVIS_SELECTED, LVIS_SELECTED); m_list.SetFocus(); } CMenu menu; menu.LoadMenu(IDR_TABLE_CONTEXT); CMenu* pPopup menu.GetSubMenu(0); CPoint pt; GetCursorPos(pt); pPopup-TrackPopupMenu(TPM_LEFTALIGN | TPM_RIGHTBUTTON, pt.x, pt.y, this); *pResult 0; }注意SetFocus配合第3章的CustomDraw方案可以确保右键选中后颜色也是统一的。不然左键是蓝、右键变灰用户又该怀疑控件坏了。5. 大规模数据下的性能优化从SetRedraw到虚拟列表5.1 逐条插入为什么会卡一万条数据每条InsertItem加SetItemText就是给ListView发两万条消息而且如果没关重绘每条消息都可能引发一次重算和绘制。批量插入前一定要SetRedraw(FALSE)插完再恢复m_list.SetRedraw(FALSE); for (int i 0; i nCount; i) { int nRow m_list.InsertItem(i, ...); // SetItemText... } m_list.SetRedraw(TRUE); m_list.Invalidate();SetRedraw(TRUE)之后不调用Invalidate界面可能不刷新这个坑很多人踩过。另外日志场景里如果每插入一行都调用EnsureVisible去滚动到底部每次插入都触发滚动重算性能会雪崩。正确做法是插入期间不滚动插完一次性EnsureVisible。5.2 虚拟列表模式为什么值得优先考虑CListCtrl的虚拟列表也就是LVS_OWNERDATA模式是处理大数据量的终极方案。它不要求你把数据逐条InsertItem只需要调用SetItemCountEx告诉控件有多少行然后控件滚动时会通过LVN_GETDISPINFO按需请求可见区域的数据。十万行、百万行滚动照样流畅。m_list.ModifyStyle(0, LVS_OWNERDATA); m_list.SetItemCountEx((int)m_data.size(), LVSICF_NOINVALIDATEALL);处理通知时把数据从自己的容器里拷贝到系统缓冲区void CMyTable::OnGetdispinfo(NMHDR* pNMHDR, LRESULT* pResult) { LV_DISPINFO* pDispInfo reinterpret_castLV_DISPINFO*(pNMHDR); if (pDispInfo-item.mask LVIF_TEXT) { int nRow pDispInfo-item.iItem; _tcscpy_s(pDispInfo-item.pszText, pDispInfo-item.cchTextMax, m_data[nRow].strTime); } *pResult 0; }pDispInfo-item.pszText指向系统缓冲区写字符串必须限制长度绝不能把CString直接赋给pszText。虚拟列表排序要排序自己的数据容器排完Invalidate让控件重新拉数据。还要注意虚拟列表和LVS_EDITLABELS不兼容如果需要双击编辑就不能用虚拟列表那就只能分页展示。5.3 最后再补一点内存相关的优化细节表格字段多、行数多时数据最好存在struct数组里别用嵌套CStringArray。频繁写入时文本列用std::string或CString都可以但整个表的结构体分配要提前reserve避免vector扩容反复拷贝。刷新高频数据时只更新变化的行不要全表SetItemText。CString转char的坑在5.3场景下更加明显大数据量循环里尽量少做临时字符串转换能直接存宽字符就用宽字符能一次转换就别反复转换。我最近维护的一个上位机项目把日志列表从普通InsertItem改成虚拟列表后数据量从几千条涨到了几十万条界面滚动依旧跟手。这个改进比任何界面美化都值也是CListCtrl这种老控件在MFC项目里仍然站稳脚跟的根本原因。本文还有配套的精品资源点击获取
