简介:「CListCtrl表格可编辑」面向在VC6/MFC环境下开发Windows单文档应用的开发者,聚焦列表控件运行时创建、标签编辑与消息映射处理,适合已具备C++与MFC基础、希望增强数据表格交互能力的中级读者。rar压缩包共58个文件,约3.8MB,含h头文件、cpp源文件、rc资源、ico/bmp位图、dsp/dsw工程文件及exe、pdb、obj等编译与调试产物,并附ReadMe说明,便于直接打开工程对照学习。资源已有841人学习下载。示例以ControlTracker_Demo组织,配合ListCtrlEx封装展示动态创建控件、LVN_BEGINLABELEDIT/LVN_ENDLABELEDIT响应、编辑校验与控件追踪等实现,可帮助读者理解单文档类扩展、窗口尺寸变化时控件布局及编辑过程数据更新,并从中提炼可复用的列表编辑与事件处理思路。
1. CListCtrl 表格可编辑:为什么得自己往表格上盖一个编辑框
接手一个 VC6 老工程,界面上摆着一张 CListCtrl 报表,用户张口就要“双击单元格直接改”。CListCtrl 自己只认得 LVS_EDITLABELS,那东西只管第一列的标签,想改后面几列,就得自己算好矩形、动态创建一个 CEdit 盖上去。难点从来不是把编辑框画出来,而是把它做稳:子项矩形要算准,滚动要跟着收,回车、Tab、Esc、焦点丢失这几路事件要归到一个出口,不然用户改三行就能把程序点崩。
这份 CListCtrl 表格可编辑的工程,思路是把“就地编辑”整体收进一个 CListCtrl 派生类,视图层只负责建表、插列、塞数据,编辑器的创建、定位、提交、撤销全在控件内部闭环。适合手上已经有一套 MFC 单文档框架、想把只读报表改成可录入表格的人,也适合想搞清楚 vc6 编程里动态创建控件和事件处理该怎么分层的人。
2. 工程骨架与运行环境:SDI 单文档里把列表控件动态创建起来
VC6 的单文档工程和现在的手感差得远,向导生成的代码里一个控件都没有,视图类是空的,列表控件得自己在 OnCreate 里 Create 出来。这一章先把工程骨架和编译前置条件交代清楚,后面的编辑逻辑才有地方落脚。
2.1 目录结构:视图、文档、控件三层分开
工程里真正干活的就是六七个文件,剩下的都是向导生成的。把编辑器从视图里拆出去,是这个工程最值得抄的一点——不然视图类会膨胀到两千行,改个边框颜色都得翻半天。
| 文件 | 职责 | 改动频率 |
|---|---|---|
TableEditApp.cpp/.h | CWinApp,InitInstance 里注册文档模板 | 几乎不改 |
MainFrm.cpp/.h | CFrameWnd,工具栏和状态栏 | 几乎不改 |
TableEditDoc.cpp/.h | CDocument,持有行数据CArray<RowData, RowData&> | 随业务改 |
TableEditView.cpp/.h | CView,创建列表控件、插列、把文档数据刷进表 | 随界面改 |
EditableListCtrl.cpp/.h | CListCtrl 派生类,就地编辑的全部逻辑 | 核心 |
CellEdit.cpp/.h | CEdit 派生类,只负责键盘和焦点 | 核心 |
StdAfx.h | _WIN32_IE宏和公共头,顺序不能乱 | 一次定死 |
视图和文档之间用标准的OnUpdate单向同步:文档改数据、发UpdateAllViews,视图收到后重刷列表。列表控件完全不认识CDocument,它只对外暴露GetCellText/SetCellText和一条“某格编辑结束”的自定义消息。这一层解耦看着有点小题大做,但等你后来要在同一个视图里放两张可编辑表的时候,就知道省了多少事。
2.2 编译环境:_WIN32_IE必须写在 afxwin.h 前面
VC6 配 comctl32 这套组合,最容易翻车的地方是宏的顺序。CListCtrl::GetSubItemRect和SubItemHitTest在 MFC 6 的头文件里是被#if _WIN32_IE >= 0x0300包起来的,宏没定义或者定义晚了,编译直接报“no member”。这不是玄学,是 MFC 6 的老毛病。
// StdAfx.h —— 这两行必须放在所有 include 之前 #define VC_EXTRALEAN #define _WIN32_IE 0x0400 // 低于 0x0300 就没有 GetSubItemRect #include <afxwin.h> #include <afxext.h> #include <afxcview.h> #include <afxcmn.h> // CListCtrl / CHeaderCtrl_WIN32_IE定到 0x0400 只是编译期的事,运行期还得保证系统里的 comctl32.dll 版本够。VC6 默认调InitCommonControls(),那个老函数不认版本,得换成InitCommonControlsEx:
// TableEditApp.cpp BOOL CTableEditApp::InitInstance() { INITCOMMONCONTROLSEX icc = { sizeof(INITCOMMONCONTROLSEX), 0 }; icc.dwICC = ICC_LISTVIEW_CLASSES | ICC_STANDARD_CLASSES; ::InitCommonControlsEx(&icc); // 必须显式要 listview,否则 LVS_REPORT 建不出来 CWinApp::InitInstance(); // ... 文档模板注册、主窗口创建 return TRUE; }参数说明:dwSize固定填结构体大小,写错了函数直接返回 FALSE;dwICC是位掩码,ICC_LISTVIEW_CLASSES对应SysListView32,把它漏掉的话Create会失败但GetSafeHwnd()又不为 NULL,新手很容易在这卡一下午。另外ICC_STANDARD_CLASSES带上,后面CEdit、CButton才稳。
提示:如果目标机器是没打过补丁的旧系统,comctl32 可能是 4.0,
LVM_GETSUBITEMRECT会返回 FALSE。工程里留了一个手写SendMessage的降级版本,第 3 章会贴出来。
2.3 视图层:动态创建控件的最小改动
向导生成的CTableEditView::OnCreate里本来只有一行CView::OnCreate,列表控件就在这里动态建出来。注意样式里必须带LVS_REPORT,否则是图标视图,压根没有“单元格”这个概念。
// TableEditView.h class CTableEditView : public CView { protected: CEditableListCtrl m_wndList; // 静态成员 + 自己 Create,比 new 更省心 afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct); afx_msg void OnSize(UINT nType, int cx, int cy); DECLARE_MESSAGE_MAP() }; // TableEditView.cpp int CTableEditView::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CView::OnCreate(lpCreateStruct) == -1) return -1; CRect rcClient; GetClientRect(&rcClient); // 父窗口是 this,子窗口 ID 由资源头文件统一分配 if (!m_wndList.Create( WS_CHILD | WS_VISIBLE | WS_BORDER | LVS_REPORT | LVS_SINGLESEL | LVS_SHOWSELALWAYS, rcClient, this, IDC_MAIN_LIST)) { TRACE(_T("列表控件创建失败,检查 InitCommonControlsEx\n")); return -1; } // 扩展风格要在 Create 之后设,顺序反了不生效 m_wndList.SetExtendedStyle( LVS_EX_FULLROWSELECT | LVS_EX_GRIDLINES | LVS_EX_HEADERDRAGDROP); // 编辑框继承列表字体,不设的话中文会瘦一圈 m_wndList.SetFont(GetFont()); InitColumns(); // 插列:编号 / 名称 / 数量 / 备注 return 0; } void CTableEditView::OnSize(UINT nType, int cx, int cy) { CView::OnSize(nType, cx, cy); if (::IsWindow(m_wndList.GetSafeHwnd())) m_wndList.MoveWindow(0, 0, cx, cy); // 不做这步,窗口一拉大表格就不跟 }LVS_SINGLESEL是故意的——多选状态下“当前编辑行”会有歧义,用户按住 Ctrl 点两行,你到底提交哪一行?LVS_SHOWSELALWAYS则是为了窗口失去焦点后还能看出选中行,不然编辑框一弹出来,蓝底就没了,视觉上像是表格被“吃掉”了。LVS_EX_FULLROWSELECT让整行高亮,配合单元格编辑体验更自然。
InitColumns用InsertColumn插四列,宽度可以和文档里保存的列宽联动,这里不展开。到这里,一张能看不能改的表就立起来了,接下来的问题全在“怎么改”。
3. 就地编辑的完整链路:从双击命中到文本回写
一次编辑动作要经过四步:双击落到哪个格子、算出这个格子的屏幕矩形、把编辑框挪过去并显示、编辑结束后取值写回。这四步任何一步算错,表现都是“编辑框跑到别的地方去”或者“改完没生效”。下面按链路顺序拆。
3.1 命中测试:双击落在哪一行哪一列
NM_DBLCLK通知里带的是整张表的重绘区域,不是单元格信息,别指望它。正确做法是在WM_LBUTTONDBLCLK里调SubItemHitTest,它一次返回行号和列号,比自己拿坐标去减列宽靠谱得多。
// EditableListCtrl.cpp void CEditableListCtrl::OnLButtonDblClk(UINT nFlags, CPoint point) { LVHITTESTINFO lvhi; ::ZeroMemory(&lvhi, sizeof(lvhi)); lvhi.pt = point; int nItem = SubItemHitTest(&lvhi); // nItem == -1 表示点在空白区;iSubItem == -1 表示点在了行的右侧留白 if (nItem >= 0 && lvhi.iSubItem >= 0) { SetItemState(nItem, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED); StartEdit(nItem, lvhi.iSubItem); return; // 不往下传,避免默认处理把焦点抢走 } CListCtrl::OnLButtonDblClk(nFlags, point); }LVHITTESTINFO的pt是客户区坐标,直接用point就行,不要再ClientToScreen。iSubItem在点到行尾空白区时会是 -1,这个分支必须判,不判就会出现“编辑框贴在最后一列右边外面”的怪现象。SubItemHitTest内部会处理列宽拖动后的偏移,所以哪怕用户把列拖得乱七八糟,命中依然是准的。
还有一个细节:双击前通常有一次单击,单击已经让某行变成选中态了。这里再SetItemState一次是冗余的,但它保证了“双击的格子”和“选中的行”一致,后面CommitAndMove按行号递推时才不会错位。
3.2 单元格矩形:LVIR_BOUNDS 与第 0 列的特殊处理
矩形算不准是这套方案里最高频的翻车点。LVM_GETSUBITEMRECT的用法有讲究:wParam传行号,lParam指向一个 RECT,其left字段要预先填子项索引、top字段填LVIR_BOUNDS,返回时结构体被改写成真正的坐标。
// EditableListCtrl.cpp —— 取单元格矩形,第 0 列单独处理 BOOL CEditableListCtrl::GetCellRect(int nRow, int nCol, CRect& rc) const { rc.SetRectEmpty(); if (nRow < 0 || nRow >= GetItemCount()) return FALSE; CHeaderCtrl* pHeader = GetHeaderCtrl(); if (pHeader == NULL || nCol >= pHeader->GetItemCount()) return FALSE; if (nCol == 0) { // 第 0 列用 LVM_GETSUBITEMRECT + LVIR_BOUNDS 会返回整行, // 必须用 GetItemRect 拿到行矩形,再按第一列宽度截断 CRect rcRow; GetItemRect(nRow, &rcRow, LVIR_BOUNDS); rcRow.right = rcRow.left + GetColumnWidth(0); rc = rcRow; return TRUE; } rc.left = nCol; // 入参:子项索引 rc.top = LVIR_BOUNDS; // 入参:要整格区域,不是标签区域 if (!SendMessage(LVM_GETSUBITEMRECT, (WPARAM)nRow, (LPARAM)&rc)) return FALSE; // comctl32 太老会走到这里 return TRUE; }LVIR_BOUNDS和LVIR_LABEL的区别值得说一句:前者包含图标和缩进,后者只包含文本区域。做编辑框要覆盖整个格子,所以用前者。第 0 列的坑是 comctl32 的既定行为,文档里写得不显眼,但实测确实返回整行——不截断的话,双击第一列会看到一个横跨四列的编辑框。
手写版本其实和上面那句SendMessage是一回事,只是不走 MFC 的封装,方便在_WIN32_IE定得低或者怀疑封装出错时对照排查。真机上如果LVM_GETSUBITEMRECT返回 FALSE,八成是 comctl32 版本不够,退回去检查 2.2 节的InitCommonControlsEx。
3.3 把 CEdit 盖上去:创建、定位、字体
编辑框不要在每次编辑时 new 一个再 delete,那是最容易漏内存的写法。整个控件只持有一个CEdit实例,第一次编辑时创建,之后只做MoveWindow和ShowWindow。父窗口设成列表控件自己,这样编辑框的坐标系天然跟着列表走,窗口移动、父窗口重绘都不用额外处理。
// EditableListCtrl.cpp #define IDC_CELL_EDIT 3001 void CEditableListCtrl::StartEdit(int nRow, int nCol) { CRect rcCell; if (!GetCellRect(nRow, nCol, rcCell) || rcCell.IsRectEmpty()) return; EndEdit(TRUE); // 先收掉上一个,防止两框并存 m_nEditRow = nRow; m_nEditCol = nCol; m_strOldText = GetItemText(nRow, nCol); if (m_pEdit == NULL) { m_pEdit = new CCellEdit; m_pEdit->SetOwner(this); // 父窗口传 this,编辑框才会被列表裁剪 if (!m_pEdit->Create(WS_CHILD | ES_AUTOHSCROLL | ES_LEFT, rcCell, this, IDC_CELL_EDIT)) { delete m_pEdit; m_pEdit = NULL; return; } } // 字体必须显式设,不设就是系统默认字体,中文高度对不上 CFont* pFont = GetFont(); m_pEdit->SetFont(pFont != NULL ? pFont : CFont::FromHandle( (HFONT)::GetStockObject(DEFAULT_GUI_FONT))); rcCell.DeflateRect(1, 1); // 内缩 1px,盖住网格线更整齐 m_pEdit->MoveWindow(&rcCell); m_pEdit->SetWindowText(m_strOldText); m_pEdit->ShowWindow(SW_SHOW); m_pEdit->SetSel(0, -1); // 全选,用户一打字就覆盖原值 m_pEdit->SetFocus(); }ES_AUTOHSCROLL不能少,长文本靠它横向滚动,否则编辑框里看到的是被截断的半截内容。DeflateRect(1, 1)让编辑框边框和网格线错开,视觉上更服帖,不内缩也能用,就是边缘会压住网格线显得脏。
还有一点:SetFocus之后光标在编辑框里,列表控件就失去了焦点,LVS_SHOWSELALWAYS此时开始发挥作用——没有它,选中行会变成灰色,用户根本不知道自己正在改哪一行。
3.4 提交与撤销:三路事件归一到一个 EndEdit
回车、Tab、Esc、点到别处导致焦点丢失,这四件事最后都要走同一个函数,否则四种路径四份逻辑,迟早有一份写漏。工程的写法是一个EndEdit(BOOL bSave)兜底,CancelEdit和CommitAndMove都只是它的薄封装。
// EditableListCtrl.cpp void CEditableListCtrl::EndEdit(BOOL bSave) { if (m_pEdit == NULL || !::IsWindow(m_pEdit->GetSafeHwnd())) return; if (m_nEditRow < 0) return; m_bInCommit = TRUE; // 防重入,见第 5 章 int nRow = m_nEditRow; int nCol = m_nEditCol; m_nEditRow = m_nEditCol = -1; CString strNew; m_pEdit->GetWindowText(strNew); m_pEdit->ShowWindow(SW_HIDE); if (bSave && strNew != m_strOldText) { SetItemText(nRow, nCol, strNew); RedrawItems(nRow, nRow); // 不重绘会残留旧文本 UpdateWindow(); // 自定义消息通知视图:行、列、旧值、新值 NMLISTEDIT nm; nm.hdr.hwndFrom = GetSafeHwnd(); nm.hdr.idFrom = GetDlgCtrlID(); nm.hdr.code = NM_CELLEDIT_END; nm.nRow = nRow; nm.nCol = nCol; nm.strOld = m_strOldText; nm.strNew = strNew; GetParent()->SendMessage(WM_CELLEDIT_END, 0, (LPARAM)&nm); } else { RedrawItems(nRow, nRow); // 取消也要重绘,擦掉编辑框残留 } m_bInCommit = FALSE; } void CEditableListCtrl::CancelEdit() { EndEdit(FALSE); SetFocus(); // 焦点还给列表,键盘还能继续走 } void CEditableListCtrl::CommitAndMove(int nColDelta, int nRowDelta) { int nRow = m_nEditRow, nCol = m_nEditCol; EndEdit(TRUE); if (nRow < 0) return; int nNextRow = nRow + nRowDelta; int nNextCol = nCol + nColDelta; CHeaderCtrl* pHeader = GetHeaderCtrl(); if (pHeader == NULL) return; // 越界就回绕,别让用户按 Tab 到最后一格后卡死 if (nNextCol >= pHeader->GetItemCount()) { nNextCol = 0; nNextRow++; } if (nNextCol < 0) { nNextCol = pHeader->GetItemCount() - 1; nNextRow--; } if (nNextRow >= 0 && nNextRow < GetItemCount()) { SetItemState(nNextRow, LVIS_SELECTED | LVIS_FOCUSED, LVIS_SELECTED | LVIS_FOCUSED); EnsureVisible(nNextRow, FALSE); // 跨屏时必须滚动到可见 StartEdit(nNextRow, nNextCol); } }RedrawItems加UpdateWindow这个组合不能省。只调SetItemText的话,列表控件会把自己的重绘排进消息队列,而编辑框刚隐藏,那一块区域在重绘到来之前是空白的,用户看到的是“改完的字闪了一下才出现”。UpdateWindow强制立刻重绘,体验上干脆很多。
SendMessage而不是PostMessage是有意的:视图收到消息后可能要立刻校验输入、甚至把非法值回滚,这些动作必须在编辑状态彻底结束之前完成。用PostMessage的话,消息还在队列里,用户可能已经点到下一个格子了。
4. 控件封装与事件处理:CEdit 子类化、自定义通知和文档视图对接
第 3 章把链路拉通了,但还有三件事没解决:回车在CEdit里默认是“叮”一声什么都不做,Tab 会被对话框机制吃掉,焦点丢失时提交会引发重入。这三件事都指向同一个手段——把CEdit子类化。
4.1 为什么把编辑器塞进控件派生类,而不是写在视图里
最直观的写法是视图类里ON_WM_LBUTTONDBLCLK、ON_WM_VSCROLL、ON_WM_KILLFOCUS一把梭,反正都能跑。但这么写有三个后患:视图里要额外维护“当前编辑行/列/旧文本”三个成员;滚动条消息是列表控件收到的,视图拦不到,只能靠反射或者子类化;最要命的是,第二张表加进来的时候,所有这些都要复制一遍。
把CEditableListCtrl做成派生类的收益就在这:编辑状态是控件自己的私有数据,滚动消息天然能收到,谁想用就在自己的视图里放一个成员。视图那边只需要处理一条自定义通知,剩下的完全不关心。代价是多两个文件、多点DECLARE_DYNAMIC,值。
// EditableListCtrl.h #define WM_CELLEDIT_END (WM_USER + 120) // 视图 → 控件 的方向不用它 struct NMLISTEDIT { NMHDR hdr; int nRow; int nCol; CString strOld; CString strNew; }; class CEditableListCtrl : public CListCtrl { DECLARE_DYNAMIC(CEditableListCtrl) public: CEditableListCtrl(); virtual ~CEditableListCtrl(); BOOL GetCellText(int nRow, int nCol, CString& str) const; BOOL SetCellText(int nRow, int nCol, LPCTSTR lpszText); void StartEdit(int nRow, int nCol); void EndEdit(BOOL bSave); void CancelEdit(); void CommitAndMove(int nColDelta, int nRowDelta); void OnEditorLostFocus(); // 给 CCellEdit 回调 protected: CCellEdit* m_pEdit; int m_nEditRow, m_nEditCol; CString m_strOldText; BOOL m_bInCommit; BOOL GetCellRect(int nRow, int nCol, CRect& rc) const; afx_msg void OnLButtonDblClk(UINT nFlags, CPoint point); afx_msg void OnVScroll(UINT nSBCode, UINT nPos, CScrollBar* pBar); afx_msg void OnHScroll(UINT nSBCode, UINT nPos, CScrollBar* pBar); afx_msg void OnMouseWheel(UINT nFlags, short zDelta, CPoint pt); afx_msg void OnDestroy(); DECLARE_MESSAGE_MAP() };NMLISTEDIT的第一个成员必须是NMHDR,这样视图那边可以按标准通知的写法走OnNotify统一分发,也可以直接用ON_MESSAGE接WM_CELLEDIT_END。工程里选了后者,因为WM_USER + 120这个自定义消息号在本视图里独占,用起来更直接。
4.2 CCellEdit:拦键盘、吃掉 Tab、避免重入
CEdit默认对回车没反应(单行编辑框会响一声系统提示音),对 Tab 则是把焦点交给下一个控件——在表格编辑场景里这两个行为都不对。子类化之后在OnKeyDown里截住就行。
// CellEdit.cpp IMPLEMENT_DYNAMIC(CCellEdit, CEdit) BEGIN_MESSAGE_MAP(CCellEdit, CEdit) ON_WM_KEYDOWN() ON_WM_KILLFOCUS() END_MESSAGE_MAP() void CCellEdit::OnKeyDown(UINT nChar, UINT nRepCnt, UINT nFlags) { switch (nChar) { case VK_RETURN: if (m_pOwner) m_pOwner->CommitAndMove(0, 1); // 提交,下移一行 return; // 不调基类,避免提示音 case VK_TAB: if (m_pOwner) m_pOwner->CommitAndMove(1, 0); // 提交,右移一列 return; case VK_ESCAPE: if (m_pOwner) m_pOwner->CancelEdit(); // 丢弃,还原旧值 return; } CEdit::OnKeyDown(nChar, nRepCnt, nFlags); } void CCellEdit::OnKillFocus(CWnd* pNewWnd) { CEdit::OnKillFocus(pNewWnd); if (m_pOwner != NULL) m_pOwner->OnEditorLostFocus(); // 统一出口,内部有重入保护 }三个return的位置很关键。如果在VK_RETURN分支里忘了return,基类CEdit::OnKeyDown还会处理一遍,表现就是“提交了两次”或者“提交后又响一声”。VK_TAB同理,基类会调GetNextDlgTabItem把焦点甩出去,编辑框还没提交就失焦了。
OnKillFocus里调OnEditorLostFocus而不是直接EndEdit(TRUE),是因为CommitAndMove内部会先EndEdit再SetFocus到列表,这条路径上焦点会变两次,第二次触发KillFocus时m_nEditRow已经是 -1,直接EndEdit也能挡住,但中间会多做一次GetWindowText。加一层回调,语义更清楚:
void CEditableListCtrl::OnEditorLostFocus() { if (m_bInCommit) return; // 正在提交,别插一脚 EndEdit(TRUE); }4.3 自定义通知:把“某格改完了”干净地回传给视图
SetItemText之后,父窗口能收到的通知有限,LVN_ITEMCHANGED主要在选中态和状态位变化时来,指望它感知文本修改容易踩空。与其去猜,不如自己发一条消息,字段想带什么带什么。
// TableEditView.cpp BEGIN_MESSAGE_MAP(CTableEditView, CView) ON_WM_CREATE() ON_WM_SIZE() ON_MESSAGE(WM_CELLEDIT_END, OnCellEditEnd) END_MESSAGE_MAP() LRESULT CTableEditView::OnCellEditEnd(WPARAM wParam, LPARAM lParam) { NMLISTEDIT* p = (NMLISTEDIT*)lParam; if (p == NULL) return 0; // 1) 业务校验:数量列必须是正整数 if (p->nCol == COL_QTY) { int nVal = _ttoi(p->strNew); if (nVal <= 0) { AfxMessageBox(_T("数量必须是大于 0 的整数")); m_wndList.SetItemText(p->nRow, p->nCol, p->strOld); // 回滚 return 0; } } // 2) 写回文档,文档负责脏标记和撤销栈 CTableEditDoc* pDoc = GetDocument(); pDoc->SetCellValue(p->nRow, p->nCol, p->strNew); pDoc->SetModifiedFlag(TRUE); // 关窗口时才会提示保存 return 0; }注意lParam指向的是栈上的局部结构体,SendMessage是同步调用,视图处理完才返回,所以指针有效。换成PostMessage就悬空了——这是很多人都踩过的坑,改个函数名程序就随机崩。
SetModifiedFlag(TRUE)那一行容易被忘。忘了的后果是关窗口不提示保存,用户改了半天点叉直接没了,血泪教训。
4.4 与文档/视图对接:改数据,不重建表
OnUpdate里最省事的写法是DeleteAllItems()然后全量重刷,数据量小的时候无所谓,几千行的时候每次编辑都闪一下,体验很差。工程里用一个m_nRefreshing标志挡住回环,只在列结构变化时才重建:
void CTableEditView::OnUpdate(CView* pSender, LPARAM lHint, CObject* pHint) { if (m_nRefreshing > 0) return; // 自己触发的更新,直接忽略 CTableEditDoc* pDoc = GetDocument(); if (lHint == HINT_COLUMNS_CHANGED) { m_wndList.DeleteAllItems(); FillAllRows(pDoc); // 结构变了才全量重建 } else { m_wndList.RefreshVisibleRows(pDoc); // 只回写可见行的文本 } }RefreshVisibleRows大致就是遍历GetTopIndex()到GetTopIndex() + GetCountPerPage()之间的行,逐个SetItemText。行数上千的时候这一招很有效,因为它只碰屏幕上那二十来行,其余的下次滚到了再刷。
5. 排查清单:编辑框错位、闪退、焦点乱跳的五种现场
这套代码在几台机器上跑过之后,问题基本都集中在下面五类。每一条我都按“现象—原因—解决”记下来,照着对,能省不少调试时间。
5.1 现象:编译报GetSubItemRect不是 CListCtrl 的成员
原因几乎只有一个:StdAfx.h里_WIN32_IE没定义,或者定义在了#include <afxwin.h>之后。MFC 6 的afxcmn.h用预处理把这两个函数包了起来,宏不到位就等于不存在。VC6 的工程设置里C/C++ → Preprocessor definitions那一栏也能加,但工程选项和StdAfx.h两处都写反而容易打架,统一放StdAfx.h最省事。
解决:把#define _WIN32_IE 0x0400挪到文件第一行,#include全部放到它后面,然后 Rebuild All。只做 Build 有时候增量编译不会重编StdAfx.h依赖的文件,宏改了跟没改一样。
5.2 现象:双击第一列,编辑框横跨整行
原因在 3.2 节讲过——LVM_GETSUBITEMRECT对iSubItem == 0加LVIR_BOUNDS的组合,返回的是整行矩形,不是第一列。这个行为在文档里描述得很含糊,实测才看得出来。
解决:第 0 列走GetItemRect(nRow, &rc, LVIR_BOUNDS)拿行矩形,再把rc.right改成rc.left + GetColumnWidth(0)。如果想更精细,也可以把LVIR_BOUNDS换成LVIR_LABEL再加回缩进量,但列宽版本更直观。
5.3 现象:一滚滚轮或者拖滚动条,编辑框就悬在半空
原因:编辑框是列表控件的子窗口,父窗口客户区原点一动,子窗口不会自动跟着重定位。列表控件滚动时移动的是内部绘制内容,不是窗口本身,所以编辑框还停在原来的屏幕位置,看起来就像“贴在表格上的一块补丁”。
解决:在派生类里拦WM_VSCROLL、WM_HSCROLL、WM_MOUSEWHEEL,先EndEdit(TRUE)再交给基类:
void CEditableListCtrl::OnVScroll(UINT nSBCode, UINT nPos, CScrollBar* pBar) { EndEdit(TRUE); // 先收,再滚 CListCtrl::OnVScroll(nSBCode, nPos, pBar); }OnMouseWheel的写法一样,参数签名是(UINT nFlags, short zDelta, CPoint pt)。别只拦WM_VSCROLL就完事,滚轮走的是另一条消息,只拦一个的话鼠标滚轮照样出问题。
5.4 现象:改完一格后偶尔崩溃,调用栈停在SetItemText里面
原因:重入。OnKillFocus里调EndEdit→EndEdit里SetItemText→ 列表重绘 → 某些情况下焦点再次变化 → 又进OnKillFocus→m_nEditRow已被改成 -1 但m_pEdit还在 → 取到非法行号。
解决:加m_bInCommit标志,EndEdit进来先置 TRUE,退出前置 FALSE,OnEditorLostFocus第一行判断这个标志直接返回。另外EndEdit里取完窗口文本要立刻ShowWindow(SW_HIDE),让编辑框先消失,再做写值动作,顺序反了也会加重绘冲突。
5.5 现象:中文显示成方框,或者编辑框比单元格高一截
原因:编辑框没设字体,用的是系统默认 GUI 字体;而列表控件可能被SetFont换成了宋体 9 号。两者字高不一样,编辑框就会出现“文字偏上”或者“边框压住下一行”的观感。多显示器不同 DPI 的环境下更明显。
解决:StartEdit里统一m_pEdit->SetFont(GetFont()),GetFont()返回 NULL 时退回DEFAULT_GUI_FONT。如果列表用了自定义字体,记得CFont对象的生命周期要长于编辑框,别在局部作用域里CreateFont然后让编辑框引用——那是标准的悬空句柄,表现是文字随机变成乱码。
6. 二次开发:从单格编辑到列校验、虚表和大数据量提交
这套骨架能跑之后,真正的工作量在“往上加东西”。我一般的顺序是先加列级校验,再考虑数据量大时的虚表改造。
列级校验最简单的做法是把校验规则抽成一个小结构体数组,按列索引查表,别在OnCellEditEnd里堆if-else:
struct COL_RULE { int nCol; LPCTSTR lpszTip; BOOL (*pfnCheck)(LPCTSTR); }; static BOOL CheckPositive(LPCTSTR s) { return _ttoi(s) > 0; } static BOOL CheckNotEmpty(LPCTSTR s) { return s != NULL && *s != _T('\0'); } static const COL_RULE g_Rules[] = { { COL_NAME, _T("名称不能为空"), CheckNotEmpty }, { COL_QTY, _T("数量必须是正整数"), CheckPositive }, };查表命中就调函数指针,没命中直接放行。好处是新增一列只加一行配置,不用动主流程;坏处是函数指针没法捕获上下文,遇到“数量不能超过库存”这种要查库的规则就得换虚函数或者std::function——VC6 没std::function,用抽象基类加new更实际。
再往上是虚表。行数过万的时候,InsertItem一行一行塞会明显卡顿,改成LVS_OWNERDATA加LVN_GETDISPINFO,只在系统要显示某行时才回填文本,内存和速度都能接受。但虚表有个副作用:SetItemText不再能改数据,得改成“写回数据源 +RedrawItems”,第 3 章的EndEdit要动一处。改造前先确认数据源本身支持按行随机访问,用CArray就行,用链表就得先换容器。
还有一个我踩过的坑:批量粘贴。用户从 Excel 复制一片区域贴进来,OnCellEditEnd会连着触发几十次,每次都SendMessage回视图去做校验和SetModifiedFlag,中间还夹着重绘请求,卡得肉眼可见。后来改成编辑期间只往一个待提交队列里塞,用PostMessage起一个合并处理,一轮消息循环只刷一次界面,才顺畅起来。
从那以后我每次动这类表格编辑的代码,都强制自己先在真机上把“双击—改字—滚轮—Tab—Esc—关窗口”这一串走一遍,再去看内存。因为这套东西的问题从来不写在编译错误里,它只在用户手里出现。希望帮到你。
本文还有配套的精品资源,点击获取