ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

MFC表格控件升级:CListCtrl自绘封装与性能优化实战

MFC表格控件升级:CListCtrl自绘封装与性能优化实战 简介面向需要高性能表格展示的VC/MFC开发者这套表格控件源码提供了可替代MSFlexGrid的轻量方案重点解决大表格数据量下刷新卡顿甚至报错的问题。控件原生支持虚拟Buffer模式实测2000行×2000列表格刷新流畅且全部源码开放、无需注册便于直接集成到项目。资源包共90个文件压缩后仅422KB。其中27个.h与23个.cpp构成核心控件源码另有vs2008/vs2003等多版本工程文件.sln、.vcproj、.dsw、.dsp与演示程序、ico/bmp图标资源方便在不同开发环境中快速编译和查看Demo。由于文件结构紧凑既适合入门者阅读源码学习MFC自绘控件原理也能让有经验的开发者快速移植到实际项目。目前已有4758人浏览学习口碑和实用性经过验证。下载后不仅包含完整标题控件、单元格编辑、下拉框等常用功能实现还有GridCtrl_for_VS2013的二次修改包省去版本适配的麻烦适合在报表、数据管理系统等场景中直接复用。 MFC这套东西虽然年头不短了但在工控、上位机、桌面工具这些领域依然随处可见。最近帮朋友调一个设备参数看板几百行数据要频繁刷新、单元格要能编辑、列头要能排序还得忍受各种花里胡哨的显示需求。折腾下来发现MFC里最常用的表格控件其实就那几个选择但想做到“好用”原生CListCtrl是远远不够的。今天就把我在项目里实测过的方案、封装思路、以及踩过的坑一次性整理出来给正在做MFC界面开发的同行做个参考。这篇文章适合谁如果你正在用MFC做上位机、内部工具或者被CListCtrl的各种“反人类默认行为”折磨过那这篇内容应该能帮你省不少事。我会从控件选型讲起然后给出一套可以直接抄作业的封装方案最后把高频问题排查清单也一起放上基本覆盖从“能用”到“好用”的完整升级路径。1. 控件选型能用和好用之间差在哪里1.1 原生 CListCtrl 默认状态的硬伤先说实话MFC里的CListCtrl本身并不差报表模式Report配合setExtendedStyle能实现网格线、整行选中、复选框、排序图标这些基础能力。但如果你真拿它直接怼到正式项目里很快会发现几个劝退瞬间第一单元格编辑能力约等于零。双击表项默认什么都不发生要编辑必须自己弹CEdit或者CComboBox还要处理输入框位置同步、焦点切换、数据回写一套流程下来代码量直接起飞。第二列头排序只负责换图标数据排序逻辑完全不管点一下列头数据纹丝不动用户会觉得这是个bug。第三视觉风格停留在上古时代白底黑字、细线网格、选中行一片深蓝放到今天的产品里确实有点寒碜。第四数据量一上来性能就崩几千行数据全量插入再刷新界面卡顿明显。这些问题的本质是CListCtrl给了你一个基础的交互框架但没有给你一个“面向产品”的表格组件。所以大部分MFC项目都会走两条路要么引入第三方增强控件要么在CListCtrl基础上自己封装一层。我个人的结论是除非项目授权和预算都宽裕否则自绘封装是性价比最高的路子。1.2 第三方控件与自绘方案的取舍关于第三方控件我在不同项目里试过CGridCtrl、BCGSoft的网格控件还有一些早期的免费皮肤控件。CGridCtrl功能确实全单元格合并、虚拟模式、公式回调都有但它的问题在于代码体积大、继承链深出了bug不好定位而且和现代编译器的兼容性偶尔会有小坑。BCGSoft那套属于商业库界面风格现代化但如果你只是为了一张表格去引入整个界面库后续的定制成本反而更高。相比之下在CListCtrl基础上做增强封装初期投入看起来多一点但收益非常明确无授权风险代码完全可控只写自己要用的功能不会有一堆用不上的逻辑干扰排查依赖系统的控件机制升级编译器或迁移到更高版本MFC时兼容性好界面风格可以完全自定义做出来的效果上限更高所以下面这套方案的核心思路就是利用CListCtrl的底层链表和消息机制通过重写自绘、消息反射、子类化控件等手段把原生控件改造成一个支持编辑、排序、虚拟数据、高DPI、界面可自定义的通用表格组件。2. 一个能“直接抄作业”的封装方案2.1 整体设计继承 CListCtrl打开 Ownership 自绘开关先说设计思路。我不要重新发明轮子而是让表格类继承CListCtrl通过重写几个关键虚函数和响应特定消息来实现增强。类名我这里叫CGridCtrlEx虽然和网上那个同名控件有点像但咱这是自己写的轻量版。基础配置在PreSubclassWindow里统一处理省得每次创建后都要手动调一堆SetExtendedStylevoid CGridCtrlEx::PreSubclassWindow() { CListCtrl::PreSubclassWindow(); // 扩展样式全行选中、网格线、双缓冲、整行高亮 DWORD dwStyle GetExtendedStyle(); dwStyle | LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_DOUBLEBUFFER; SetExtendedStyle(dwStyle); // 启用自绘这是实现自定义界面和交互的基础 SetOwnerDrawRequired(TRUE); }这里有几个关键点LVS_EX_DOUBLEBUFFER很重要没有它刷新时控件会闪到怀疑人生LVS_EX_FULLROWSELECT必须开否则用户点击第二列以后选中的只是那个单元格而不是整行OwnerDrawRequired(TRUE)对应的是自绘行这个后面会细说需要注意的是自绘模式下系统不会再帮你绘制单元格底色、选中色这些都要在DrawItem里自己画。如果你对GDI不熟悉一开始会有点疼但画上两三个回调就习惯了。2.2 界面细节行高、斑马纹、列头美化行高这块用的是LVS_OWNERDRAWFIXED配合WM_MEASUREITEM消息。自绘列表如果想要不同行高需要设置LVS_OWNERDRAWVARIABLE如果统一行高用FIXED就够。实际项目中一般是统一行高比如28像素方便阅读又不会太密集。void CGridCtrlEx::OnMeasureItem(int nIDCtl, LPMEASUREITEMSTRUCT lpMeasureItemStruct) { lpMeasureItemStruct-itemHeight 28; // 根据DPI需要做缩放 CListCtrl::OnMeasureItem(nIDCtl, lpMeasureItemStruct); }斑马纹奇数行/偶数行不同底色在自绘里很好实现DrawItem里根据行号取模判断即可if (nItem % 2 0) pDC-FillSolidRect(rc, RGB(0xFF, 0xFF, 0xFF)); else pDC-FillSolidRect(rc, RGB(0xF7, 0xF9, 0xFC));至于列头美化CListCtrl的Header Control也是可以子类化的。实际操作中比较实用的做法是捕获HDN_ITEMCLICK消息响应排序同时用NM_CUSTOMDRAW对列头做自绘绘制渐变背景和分隔线。这里我偷了个懒直接给Header设置了一个简单的两色渐变效果视觉上比默认的灰色横条好不少代码量也不大。2.3 交互能力单元格编辑、排序、复选这三个能力是表格控件“好用”的分水岭。原生控件双击没反应那我就在NM_DBLCLK里判断点击位置是否在选中行上如果是就在那个单元格的位置创建一个CEdit子窗口填充当前文本等用户回车或失焦后把新值写回报数据源。排序则通过SortItems配合回调函数实现。注意对未使用虚拟列表的普通CListCtrl排序后数据项和行号绑定的是内存中的LVITEM顺序所以排序前一定要先备份数据源索引否则排序后就对应不上了。虚拟列表模式下会更轻松因为LVITEM的lParam直接指向数据源索引排序只需要改索引数组。复选框这块我选择在自绘里画自绘checkbox而不是用LVS_EX_CHECKBOXES扩展样式。为什么因为扩展样式自带的checkbox样式很难看而且占用的列宽固定想对齐表头也麻烦。自绘checkbox只需要在状态列里根据状态画一个矩形框加对勾再在点击时做命中测试手感完全可控。3. 核心代码实现与参数讲解3.1 初始化与扩展样式以一个典型的参数看板场景为例窗口上放了一个CListCtrl我子类化成CGridCtrlEx后在OnInitDialog里初始化列m_gridCtrl.SetColumns({ { L设备编号, 80, LVCFMT_LEFT }, { L参数名称, 160, LVCFMT_LEFT }, { L当前值, 100, LVCFMT_RIGHT }, { L单位, 60, LVCFMT_CENTER }, { L状态, 80, LVCFMT_CENTER }, });这里我写了一个SetColumns辅助函数批量插入列头并记住列宽避免一堆InsertColumn散落在各处。列宽的设定要用DPIAware的思路也就是把像素值根据当前DPI做缩放否则高分屏下文字被截断是常见问题。我的做法是编译期或运行期读取当前DPI然后乘一个缩放系数int CFunUiHelper::ScaleByDpi(int iPixel) { CClientDC dc(AfxGetMainWnd()); int iLogPixelsY dc.GetDeviceCaps(LOGPIXELSY); return MulDiv(iPixel, iLogPixelsY, 96); }3.2 自绘与选中状态处理自绘的核心是DrawItem。别被这名字吓到其实工作就三件事画背景、画文本、画边框。void CGridCtrlEx::DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC* pDC CDC::FromHandle(lpDrawItemStruct-hDC); CRect rcItem(lpDrawItemStruct-rcItem); int nItem lpDrawItemStruct-itemID; // 1. 背景 if (lpDrawItemStruct-itemState ODS_SELECTED) { // 选中行即使失焦也保持一个可辨识的高亮色 if (GetFocus() this || GetParent()-GetFocus() this) pDC-FillSolidRect(rcItem, RGB(0xCC, 0xE5, 0xFF)); else pDC-FillSolidRect(rcItem, RGB(0xE8, 0xE8, 0xE8)); } else { pDC-FillSolidRect(rcItem, nItem % 2 ? RGB(0xF7, 0xF9, 0xFC) : RGB(0xFF, 0xFF, 0xFF)); } // 2. 文本按列循环 for (int col 0; col m_colCount; col) { CRect rcCol GetSubItemRect(nItem, col, LVIR_BOUNDS); // 设置字体颜色、对齐方式、绘制背景透明 pDC-SetBkMode(TRANSPARENT); pDC-DrawText(GetItemText(nItem, col), rcCol, DT_VCENTER | DT_SINGLELINE | m_colAlign[col]); } }这段代码里有个小细节很多人容易忽略选中行失焦后高亮色会消失变成和普通行没有区别的灰色。这个现象在MFC默认控件里很常见用户点击了行但焦点跑到别的控件上选中状态就看不见了体验很不好。解决办法就是上面代码里写的在失焦状态下也绘制一个浅灰色的选中底色至少让用户知道刚才选中了哪行。3.3 双击编辑与数据同步双击编辑的实现思路比较多我采用过两种。一种是子类化CEdit覆盖到单元格上适合绝大多数场景另一种是直接把CListCtrl设成编辑状态配合LVN_BEGINLABELEDIT和LVN_ENDLABELEDIT消息但这需要列表本身带编辑标签能力限制比较多。下面这种子类化CEdit的方案更通用。步骤拆开来看响应NM_DBLCLK调用GetSubItemRect拿到点击单元格的矩形把这个矩形转成屏幕坐标再转成父窗口客户区坐标避免坐标错乱创建CEdit子窗口设置初始文本、字体、边框CEdit获得焦点并全选方便直接输入覆盖在CEdit的WM_KILLFOCUS或WM_KEYDOWN(回车)里结束编辑把内容写回数据源一段核心代码示意void CGridCtrlEx::OnDblclk(NMHDR* pNMHDR, LRESULT* pResult) { NMITEMACTIVATE* pNMItem (NMITEMACTIVATE*)pNMHDR; if (pNMItem-iItem 0 pNMItem-iSubItem 0) { m_iEditRow pNMItem-iItem; m_iEditCol pNMItem-iSubItem; CRect rcCell; GetSubItemRect(m_iEditRow, m_iEditCol, LVIR_BOUNDS, rcCell); ClientToScreen(rcCell); GetParent()-ScreenToClient(rcCell); m_editCtrl.Create(WS_CHILD | ES_AUTOHSCROLL | ES_LEFT, rcCell, GetParent(), IDC_EDIT_CELL); m_editCtrl.SetFont(GetFont()); m_editCtrl.SetWindowTextW(GetItemText(m_iEditRow, m_iEditCol)); m_editCtrl.ShowWindow(SW_SHOW); m_editCtrl.SetFocus(); m_editCtrl.SetSelAll(); } *pResult 0; }这里要注意CEdit的父窗口必须是表格控件的父窗口也就是对话框而不是表格自身否则编辑框会被列表控件的裁剪区域挡住显示不完整。这个细节让我当初排查了好一阵子。数据回写也不能马虎。结束编辑时把CEdit的文本取出来先更新数据源里的结构体再调用SetItemText刷新界面。如果数据源是vector或者CArray那同步起来很简单如果数据源是数据库表就需要额外考虑事务和刷新时机不要每敲一个字符就写一次库。4. 常见问题排查实测中踩过的坑4.1 选中行失焦变灰自绘里自己画选中色这个问题前面提了一嘴这里展开讲。MFC默认列表控件在失去焦点后选中行会变成灰色很多用户会以为自己的点击没生效。网上流传最广的解法是把扩展样式里的LVS_SHOWSELALWAYS加上但实测在某些场景下依然不够好——因为现代MFC的视觉主题还在有些Windows版本会忽略这个标志。我的做法就是自绘时判断如果行是选中状态不管控件是否拥有焦点都画一个淡蓝色或浅灰色背景。同时用GetFocus判断到底焦点在不在自己身上如果不在就用更淡一点的背景色既保留了状态提示又不至于让界面显得太跳。这一个改动对体验的提升非常明显。4.2 表格刷新时疯狂闪烁闪烁几乎是CListCtrl最常见的问题。尤其在数据刷新频率高、行数多的时候黑块、白块交替闪现观感很差。解决思路从三个方面入手打开LVS_EX_DOUBLEBUFFER扩展样式这是基础刷新数据时不要一次DeleteAllItems再重新Insert而是先BeginUpdate/EndUpdate包住整个更新过程或者用虚拟列表模式只刷新可见区域如果是整表数据更新可以调用RedrawItems只重绘变化范围而不是整个控件刷新我在实际项目里把以上三条都做了。上千行的表格每次刷新控制在十几毫秒以内肉眼基本看不到闪动。如果还闪那就要检查是不是有别的线程在SetWindowText之类的跨线程刷新也会有干扰。4.3 CString 转 char 的乱码问题做MFC字符串转换是绕不开的活。CString默认可能是宽字符也可能是窄字符取决于工程字符集配置。把它转给printf或文件流时如果直接强制指针转换很容易出现乱码或截断。我自己常用的做法是先用WideCharToMultiByte或者干脆用CStringA和CStringW互相转换简单可靠CString strValue L123.45; CStringA strA CW2A(strValue); const char* szBuf strA.GetString();反过来从char界面拿数据进CString就用CA2W。这个套路在MFC项目里我用了一年多没有出过乱码。注意GetString返回的指针在CStringA生命周期内有效别把它存起来跨作用域使用就行。4.4 文件路径超过260个字符导致操作失败这在MFC表格控件里其实是个关联问题——很多表格控件有导出Excel或文件的功能用户选了很深的路径结果CreateFile或CFile直接失败。原因在于经典的MAX_PATH限制。解决方法是启用长路径支持。Windows 10/11上组策略里开启“启用Win32长路径”在代码里则是用\?\前缀或者干脆使用支持长路径的API比如GetFullPathNameW配合RemoveExtraBackslashes。这个坑在日志查看器和文件列表类表格里特别常见建议提前规划。4.5 大数据量滚动卡顿与虚拟列表模式表格行数过万之后即使用了双缓冲滚动时依然可能掉帧。此时就要考虑虚拟列表。所谓虚拟列表就是不给CListCtrl插入具体数据而是告诉它总共有多少行等它需要显示某一行时通过LVN_GETDISPINFO消息回调去取数据。这个改动对于既有程序来说有一定侵入性需要把数据源整理成可按行索引访问的结构体数组或vector。但换来的收益是惊人的十万行数据滚动也流畅内存占用低到可以忽略。我的建议是如果表格行数长期超过5000行就直接上虚拟列表不用犹豫。4.6 动态缩放与子窗口布局最后一个坑和前面提的DPI有关。MFC工程默认DPI感知没设置时在高分屏下整个界面会模糊。表格控件里字体、列宽、行高都必须按DPI缩放。简单做法是开头调用SetProcessDpiAwareness然后所有像素值用MulDiv换算或者配合OnDpiChanged消息重算布局。如果是Tab Control里面嵌表格那么切页时还要重算表格大小否则切回来尺寸不对。我写了一个在父窗口OnSize里调用表格控件的MoveWindow铺满客户区的函数配合上面提到的DPI换算基本能做到和系统DPI设置保持一致。5. 常见问题速查表问题现象根本原因解决方案选中行失焦变灰默认主题行为自绘时根据选中状态自定义底色表格刷新闪烁缺少双缓冲或全量刷新加LVS_EX_DOUBLEBUFFER用BeginUpdate包流程双击不能编辑未实现单元格编辑逻辑子类化CEdit响应NM_DBLCLK列头排序无效未处理排序回调重写SortItems维护数据索引数组大数据量卡顿InsertItem过多开启虚拟列表配合LVN_GETDISPINFO中文乱码字符集转换错误使用CStringA/CStringW互相转换高DPI错位未按DPI缩放像素值使用MulDiv换算所有像素尺寸文件导出失败路径超过MAX_PATH加\?\前缀或启用长路径策略编辑框被列表遮挡CEdit父窗口设置错误把父窗口设为对话框这个表格里的每一条我都在实际项目里遇到过至少一次。每次排查完记下来后面再做类似的MFC表格就不会再走回头路。6. 最后的几个经验总结如果让我给同行一个最中肯的建议那就是不要一开始就追求把所有功能都堆到控件里。先把行高、选中色、斑马纹、双击编辑这四个最小功能集做扎实然后根据业务需求逐步加排序、虚拟列表、自绘checkbox。初期代码量少排查问题快等模式成熟了再扩展反而是最快的路径。另外微软的官方文档里CListCtrl的Message Map和Notification Code非常齐全遇到问题先翻查MSDN很多看似玄学的行为其实都是既定规则。比如NM_CUSTOMDRAW的分阶段绘制机制理解了CDDS_ITEM和CDDS_SUBITEM的组合逻辑很多列表定制问题都能一通百通。我在做这套封装的时候还有个额外收获——后来把同样的绘制逻辑迁移到DirectUI风格的框架里代码复用度很高。所以哪怕你以后不写MFC了这套自绘控件的思维模式在Windows客户端开发里依然通用。表格控件的本质就是“数据渲染交互状态管理”把这层抽象想透彻了用什么都一样。本文还有配套的精品资源点击获取
返回列表