特种作业理论考试机房

资讯详情

基于CWnd自绘CListCtrl:双缓冲、滚动条与命中测试全解析

基于CWnd自绘CListCtrl:双缓冲、滚动条与命中测试全解析 搞Windows桌面客户端的兄弟应该都体会过原生CListCtrl的憋屈行高锁死、列头丑、样式改不动、数据一多滚动还闪屏。以前接了个项目要做深色主题UI稿上手一看原生控件根本没法看最后只能走上自绘这条路。这篇就把我基于CWnd从零自绘CListCtrl、连滚动条都自己画的完整方案拿出来聊聊从类设计、双缓冲绘制、命中测试到滚轮联动附代码和踩坑记录适合要用MFC做界面定制、或者正在被原生列表控件折腾的朋友参考。1. 整体方案设计与技术选型1.1 为什么非要把CListCtrl自绘到这种程度先说结论如果你只是想把表头换个颜色、列表项加个背景色用NM_CUSTOMDRAW子类化就够了根本不至于从CWnd重新画。但当你遇到下面这些需求组合时原生CListCtrl再怎么扩展也救不回来行高要求可变比如每行40px甚至60px还要按内容动态调整列表项要支持渐变背景、圆角高亮、自定义字体、甚至嵌入图形元素需要横向纵向滚动条都统一成皮肤风格和整体UI一致大数据量几万行下还要保持滚动流畅原生控件在部分系统上滚动整个客户区重绘明显掉帧要求完全控制鼠标交互比如行悬停效果、自绘tooltip、列头排序箭头在MFC里CListCtrl是基于系统通用控件的封装很多外观行为受系统主题控制想彻底换肤很费劲。有人会选择直接子类化CListCtrl然后处理LVN_DRAWITEM或NM_CUSTOMDRAW这种方式做部分定制可以但要实现“连滚动条都自定义皮肤”基本不可能因为系统非客户区不是你随便能拿DC画干净的。所以最终方案是基于CWnd派生一个全新的列表控件类我给它命名为CMyListCtrl窗口行为完全自己控制。数据模型用简单的std::vector存储行列字符串绘制时用双缓冲把整帧画好再一次性BitBlt到屏幕滚动条也是自己画的连WM_NCCALCSIZE都不用管——因为我就把滚动条放在客户区的右侧客户区整个拿来用。提醒一句这套方案的代价是工作量变大事件处理、命中测试、滚动逻辑全是自己的。但它带来最大的好处是——控件的每个像素都在你掌控之下UI给什么效果都能还原这在需要深度定制的项目里价值极高。1.2 整体架构与消息流转控件整体结构分四层数据层维护列信息、行数据、排序状态布局层计算列宽、行高、客户区尺寸、滚动范围绘制层负责列表内容绘制、滚动条绘制、动画效果绘制交互层处理鼠标点击、悬停、滚轮、滚动条拖动类继承关系很直接class CMyListCtrl : public CWnd { DECLARE_DYNAMIC(CMyListCtrl) public: BOOL Create(CWnd* pParent, const CRect rc, UINT nID); protected: afx_msg void OnPaint(); afx_msg BOOL OnEraseBkgnd(CDC* pDC); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnLButtonUp(UINT nFlags, CPoint point); afx_msg void OnMouseMove(UINT nFlags, CPoint point); afx_msg void OnMouseLeave(); afx_msg BOOL OnMouseWheel(UINT nFlags, short zDelta, CPoint pt); afx_msg void OnSize(UINT nType, int cx, int cy); DECLARE_MESSAGE_MAP() };消息流向大概是WM_PAINT触发重绘OnPaint里把所有内容画到内存DC再拷贝到窗口鼠标按下时先判定命中区域是列表区还是滚动条区分别处理滚轮消息只负责改变滚动偏移量然后Invalidate触发重绘拖动滚动条时鼠标捕获在控件上LButtonMove里更新滑块位置这里有一个容易被新手忽略的关键点数据更新和绘制必须解耦。比如AddRow之后不立即重绘只是设置脏标记或者直接Invalidate确保在滚动过程中不会因为数据变动导致绘制线程和UI线程打架。虽然MFC都是单线程UI但大量数据更新时连续重绘会白白消耗CPU加个防抖机制能明显改善体验。2. 核心实现细节拆解2.1 窗口创建与基础框架首先需要注册并创建窗口。因为CWnd基类不提供现成的窗口类名所以Create函数里要自己调用AfxRegisterWndClass注册一个类然后调用CWnd::Create。参考实现BOOL CMyListCtrl::Create(CWnd* pParent, const CRect rc, UINT nID) { CString strClass AfxRegisterWndClass( CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS, ::LoadCursor(nullptr, IDC_ARROW), (HBRUSH)(COLOR_WINDOW 1), nullptr); if (!CWnd::Create(strClass, _T(), WS_CHILD | WS_VISIBLE | WS_CLIPSIBLINGS, rc, pParent, nID)) return FALSE; // 初始化字体、默认行高、列信息 m_font.CreatePointFont(90, _T(Microsoft YaHei)); m_nLineHeight 42; m_nHeaderHeight 38; m_nScrollOffset 0; m_bTrackingScroll FALSE; // 注册窗口消息用于接收鼠标离开通知 TRACKMOUSEEVENT tme { sizeof(tme) }; tme.dwFlags TME_LEAVE; tme.hwndTrack m_hWnd; tme.dwHoverTime 0; TrackMouseEvent(tme); return TRUE; }创建时要注意两个细节窗口样式务必带上WS_CLIPSIBLINGS否则自绘时兄弟窗口可能绘制到控件区域上造成各种奇怪的残留CS_DBLCLKS样式很重要如果之后要支持双击操作必须在注册类时加上否则系统不产生双击消息字体这块建议直接用CreatePointFont不要用CreateFontIndirect指定像素高度因为不同DPI下像素高度会出问题。行高和字体有关每次设置字体后都要重新用GetTextMetrics计算一次不能写死。2.2 数据模型与可见区域裁剪数据层用两个简单的结构体struct COLUMN_INFO { CString strName; // 列头显示文本 int nWidth; // 列宽像素 UINT uAlign; // DT_LEFT / DT_CENTER / DT_RIGHT }; struct ROW_DATA { std::vectorCString cells; // 每行的单元格文本 bool bSelected; // 是否选中 bool bHot; // 鼠标是否悬停 };这里偷了个懒单元格只存字符串。如果后续要支持富文本、进度条、状态图标建议把cells改成variant或者干脆存一个自定义结构体指针绘制时再根据类型分发。我一直建议做这类自绘控件时数据模型要预留类型扩展字段不然后期改结构代价很大。绘制裁剪的逻辑是核心性能关键点。假设数据有5万行你不可能把5万行都画一遍。做法是算出滚动偏移量后定位到当前可见的第一行和最后一行只绘制这些行。计算方式很直接// m_nScrollOffset是当前滚动偏移像素值 int nTopRow m_nScrollOffset / m_nLineHeight; int nBottomRow nTopRow (m_nClientHeight - m_nHeaderHeight) / m_nLineHeight 1; if (nBottomRow (int)m_rows.size()) nBottomRow (int)m_rows.size() - 1; for (int row nTopRow; row nBottomRow; row) { // 绘制这一行 }走到这里就理解为什么要用像素偏移而不是行号偏移了支持了任意行高以后要支持行分组、行合并或者行高动态变化像素偏移都直接成立。2.3 双缓冲绘制与列表项渲染闪烁问题几乎是所有自绘控件的头号敌人。原因很简单如果直接在窗口DC上逐项绘制每次GDI操作都会让屏幕局部更新视觉上就是闪烁。解决方案是内存DC双缓冲。MFC里最朴素的写法void CMyListCtrl::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(rcClient); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp memDC.SelectObject(bmp); // 设置字体、填充背景 CFont* pOldFont memDC.SelectObject(m_font); // 1. 绘制整个客户区背景 memDC.FillSolidRect(rcClient, m_clrBackground); // 2. 绘制列表内容 DrawList(memDC, rcClient); // 3. 绘制滚动条 DrawScrollBar(memDC, rcClient); // 一次性拷贝到屏幕 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldFont); memDC.SelectObject(pOldBmp); }同时把OnEraseBkgnd直接返回TRUE禁止系统擦背景否则双缓冲白做BOOL CMyListCtrl::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; }这里有个常见的坑有些人在WM_ERASEBKGND里做了FillSolidRect然后OnPaint又画一遍背景色等于做了两遍无用功还容易闪。正确的做法是擦除一律不做全部交给Paint统一处理。绘制单行内容时我一般按这个顺序画用FillSolidRect画整行背景色区分正常、选中、悬停三种状态如果行末需要分割线画一条1px的下边线逐个单元格DrawText注意文本颜色和背景色要统一处理如果该行有特殊状态比如图标额外绘制文本绘制时DrawText用的矩形要预留左右边距我习惯留8到12像素。对齐方式从列信息的uAlign取传给DrawText的format。文本截断用DT_END_ELLIPSIS避免超长文本跑出列边界。2.4 列头的绘制与排序指示列头虽然是列表的一部分但逻辑上应该单独画。它固定在客户区顶部不随滚动偏移变化。绘制列头时包括三个元素背景、列名文本、排序箭头如果需要。void CMyListCtrl::DrawHeader(CDC* pDC, CRect rcClient) { CRect rcHeader rcClient; rcHeader.bottom m_nHeaderHeight; // 列头背景色 pDC-FillSolidRect(rcHeader, m_clrHeaderBk); // 逐列绘制 int x 0; for (size_t i 0; i m_columns.size(); i) { int w m_columns[i].nWidth; // 绘制文字居中显示 CRect rcText(x, 0, x w, m_nHeaderHeight); // 绘制文本 pDC-SetTextColor(m_clrHeaderText); pDC-DrawText(m_columns[i].strName, rcText, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS); // 绘制列之间的分隔线1px深色线 pDC-FillSolidRect(x w - 1, 0, 1, m_nHeaderHeight, m_clrBorder); x w; } }如果要做列头点击排序需要维护一个排序状态在列头点击的命中测试里取反排序方向然后对m_rows做排序并Invalidate。注意排序操作要放在UI线程之外可能会卡顿数据量小时无所谓数据量大时建议排序放到后台线程完成后PostMessage通知UI刷新。排序状态结构struct SORT_STATE { int nCol; bool bAscending; }; SORT_STATE m_sortState;3. 自绘滚动条的设计与实现3.1 为什么滚动条也要自己画滚动条是这个方案里最容易翻车、也最容易被忽略的部分。刚开始我图省事用系统滚动条窗口样式加上WS_VSCROLL然后处理WM_VSCROLL消息。测试后发现两个问题系统滚动条样式和深色UI完全不匹配看起来像贴了一块补丁某些系统主题下系统滚动条宽度和滑块高度不可控想改都改不了所以后来干脆把WS_VSCROLL去掉滚动条画在客户区右侧。自己画的最大好处是滑块尺寸、颜色、hover状态全部可控而且可以做出一些细节效果比如滑块圆角、滑道高亮等。滚动条区域我用一个常量固定宽度一般取14像素到16像素。因为客户区宽度本来就不大太宽浪费空间太窄鼠标不好点。我的建议是至少12px鼠标命中精度才能有保障。3.2 滚动条的分区与命中测试我把滚动条分成四个热区上箭头滑道上部分滑块上方滑块本身滑道下部分滑块下方绘制顺序是滑道背景 - 滑块 - 上下箭头。滑块的位置根据当前滚动偏移计算范围映射公式是// 可用滑道高度 客户区高度 - 上下箭头高度 int trackHeight rcClient.Height() - 2 * ARROW_HEIGHT; // 滑块高度比例 可视区域高度 / 内容总高度 int thumbHeight max(MIN_THUMB_HEIGHT, (int)(trackHeight * (float)visibleHeight / totalHeight)); // 滑块滚动范围 可滚动像素范围 / 最大滚动像素偏移 int maxThumbPos trackHeight - thumbHeight; int thumbPos (m_nScrollOffset * maxThumbPos) / m_nMaxScrollOffset;滑块高度不能小于某个最小值否则内容特别多的时候滑块会变成一条缝既难看又难点。我一般最小值取20px。此处有一个关键公式直接决定滚动手感滑块位置和滚动偏移是线性映射所以滚轮每滚动一行滑块移动量等于滑块可用移动范围除以最大滚动偏移量最大滚动偏移量 max(0, 内容总高度 - 可视化区域高度)滚动偏移量永远在0和最大偏移量之间不能越界命中测试的代码注意判定顺序int CMyListCtrl::HitTestScrollBar(CPoint pt) { CRect rcScroll GetScrollBarRect(); // 先判断是否在滚动条区域内 if (!rcScroll.PtInRect(pt)) return HTSCROLL_NONE; // 上箭头区域 CRect rcUp(rcScroll.left, rcScroll.top, rcScroll.right, rcScroll.top ARROW_HEIGHT); if (rcUp.PtInRect(pt)) return HTSCROLL_UP; // 下箭头区域 CRect rcDown(rcScroll.left, rcScroll.bottom - ARROW_HEIGHT, rcScroll.right, rcScroll.bottom); if (rcDown.PtInRect(pt)) return HTSCROLL_DOWN; // 滑块区域注意滑块必须在滑道区域内优先判定 if (GetThumbRect().PtInRect(pt)) return HTSCROLL_THUMB; // 剩下的就是滑道上下部分 if (pt.y GetThumbRect().top) return HTSCROLL_PAGEUP; return HTSCROLL_PAGEDOWN; }这里有个细节容易弄错判定滑块命中时滑块矩形要包含上箭头和下箭头的区域吗不滑块区域完全在滑道内部箭头的矩形和滑道的矩形不能重叠。我是这样划分的先画箭头再在剩下的中间区域计算滑道和滑块。这样箭头的命中区域是独立的不受滑块影响。3.3 拖动滑块与滚轮联动拖动滑块是交互里最需要小心的部分。按下滑块时记录两个值鼠标在屏幕上按下时相对于滑块顶部的偏移量、以及鼠标在控件客户区中的位置。之后在鼠标移动过程中通过鼠标当前位置反推滚动偏移void CMyListCtrl::OnLButtonDown(UINT nFlags, CPoint point) { int ht HitTestScrollBar(point); if (ht HTSCROLL_THUMB) { // 记录拖动起点相对滑块顶部的偏移 CRect rcThumb GetThumbRect(); m_nDragOffset point.y - rcThumb.top; m_bTrackingScroll TRUE; SetCapture(); return; } // ... 其他区域处理 } void CMyListCtrl::OnMouseMove(UINT nFlags, CPoint point) { if (m_bTrackingScroll) { CRect rcScroll GetScrollBarRect(); int trackHeight rcScroll.Height() - 2 * ARROW_HEIGHT; int thumbHeight GetThumbRect().Height(); int maxThumbPos trackHeight - thumbHeight; // 计算新的滑块左上角Y位置 int newThumbPos point.y - m_nDragOffset - ARROW_HEIGHT; // 转换成滚动偏移量 float ratio (float)newThumbPos / maxThumbPos; int newScrollOffset (int)(ratio * m_nMaxScrollOffset); UpdateScrollPos(newScrollOffset); return; } // ... 悬停状态处理 }鼠标松开时记得ReleaseCapture并把m_bTrackingScroll置为FALSE。我踩过几次坑拖动滑块时如果鼠标移出控件范围MouseMove收不到消息结果滑块卡住不动。解决方法是SetCapture之后只要控件有捕获鼠标移动消息还是会送到本窗口所以只要拖动过程中不丢捕获基本没问题。但要注意如果弹窗或者切换窗口导致捕获丢失要在OnCaptureChanged里同步把拖动状态复位。滚轮消息也别忘了。MFC里处理WM_MOUSEWHEEL注意这个消息是发给焦点窗口的如果鼠标悬停在控件上但焦点在别的窗口可能收不到。更稳妥的做法是在PreTranslateMessage里处理鼠标位置判断坐标是否在控件范围内属于当前窗口再处理滚轮。滚轮滚动量我一般按行数走BOOL CMyListCtrl::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { // 每次滚动行数 int nLines (zDelta 0) ? -3 : 3; int newOffset m_nScrollOffset nLines * m_nLineHeight; UpdateScrollPos(newOffset); return TRUE; }有几个老手才会注意的细节滚轮滚动时应该把滚动量取整到行高倍数否则会出现“半行”停在那里视觉上很别扭连续快速滚动时要保证偏移量始终落在合法范围越界要夹紧数据量不大时滚动条可以不显示此时客户区宽度要重新计算——这个在WM_SIZE里处理4. 实操过程与集成示例4.1 核心类的完整骨架把上面讲的串起来形成一个可直接用的类。头文件大概是这样// MyListCtrl.h #pragma once #include afxwin.h #include vector class CMyListCtrl : public CWnd { DECLARE_DYNAMIC(CMyListCtrl) public: CMyListCtrl(); virtual ~CMyListCtrl(); BOOL Create(CWnd* pParent, const CRect rc, UINT nID); // 数据操作接口 void SetHeader(const std::vectorCString arrNames, const std::vectorint arrWidths); int AddRow(const std::vectorCString cells); void DeleteAllRows(); void SetRowCount(size_t nCount); size_t GetRowCount() const; // 外观设置 void SetLineHeight(int nHeight); void SetBackgroundColor(COLORREF clr) { m_clrBackground clr; Invalidate(); } protected: struct COLUMN_INFO { CString strName; int nWidth; UINT uAlign; }; struct ROW_DATA { std::vectorCString cells; bool bSelected; }; std::vectorCOLUMN_INFO m_columns; std::vectorROW_DATA m_rows; CFont m_font; int m_nLineHeight; int m_nHeaderHeight; int m_nScrollOffset; int m_nMaxScrollOffset; COLORREF m_clrBackground; COLORREF m_clrHeaderBk; COLORREF m_clrText; COLORREF m_clrSelectedBk; COLORREF m_clrHotBk; bool m_bTrackingScroll; int m_nDragOffset; bool m_bMouseInClient; int m_nClientWidth; int m_nClientHeight; // 滚动条相关 static const int SCROLLBAR_WIDTH 14; static const int ARROW_HEIGHT 14; static const int MIN_THUMB_HEIGHT 20; CRect GetScrollBarRect() const; CRect GetThumbRect() const; int HitTestScrollBar(CPoint pt) const; void UpdateScrollPos(int nNewPos); void CalcLayout(); void DrawList(CDC* pDC, CRect rcClient); void DrawHeader(CDC* pDC, CRect rcClient); void DrawScrollBar(CDC* pDC, CRect rcClient); // 消息处理 afx_msg void OnPaint(); afx_msg BOOL OnEraseBkgnd(CDC* pDC); afx_msg void OnLButtonDown(UINT nFlags, CPoint point); afx_msg void OnLButtonUp(UINT nFlags, CPoint point); afx_msg void OnMouseMove(UINT nFlags, CPoint point); afx_msg void OnMouseLeave(); afx_msg BOOL OnMouseWheel(UINT nFlags, short zDelta, CPoint pt); afx_msg void OnSize(UINT nType, int cx, int cy); DECLARE_MESSAGE_MAP() };具体实现里比较关键的是CalcLayout和UpdateScrollPos。void CMyListCtrl::CalcLayout() { CRect rcClient; GetClientRect(rcClient); m_nClientWidth rcClient.Width(); m_nClientHeight rcClient.Height(); // 判断是否需要显示滚动条 int nContentHeight (int)m_rows.size() * m_nLineHeight m_nHeaderHeight; m_bShowScrollBar (nContentHeight m_nClientHeight); // 可见区域高度 int nVisibleHeight m_nClientHeight - m_nHeaderHeight; // 内容总高度 int nTotalHeight (int)m_rows.size() * m_nLineHeight; // 最大滚动偏移量 m_nMaxScrollOffset max(0, nTotalHeight - nVisibleHeight); m_nScrollOffset min(m_nScrollOffset, m_nMaxScrollOffset); } void CMyListCtrl::UpdateScrollPos(int nNewPos) { int nMaxPos m_nMaxScrollOffset; if (nNewPos 0) nNewPos 0; if (nNewPos nMaxPos) nNewPos nMaxPos; if (nNewPos ! m_nScrollOffset) { m_nScrollOffset nNewPos; Invalidate(FALSE); } }CalcLayout有一个很重要的细节当内容不足一屏时m_nMaxScrollOffset为0此时应隐藏滚动条同时客户区宽度不缩。相反的当行数增加导致滚动条出现时客户区可用宽度减少所有列宽都要重新按比例调整否则最后一行文本会画到滚动条区域底下。这个逻辑在OnSize里每当尺寸变化时调用一次。4.2 集成到对话框与消息连接使用这个控件非常简单。在对话框的头文件中加入成员变量然后在OnInitDialog里动态创建m_listCtrl.Create(this, CRect(20, 20, 620, 420), IDC_MY_LIST);创建之后设置列std::vectorCString names; names.push_back(_T(姓名)); names.push_back(_T(部门)); names.push_back(_T(状态)); std::vectorint widths; widths.push_back(120); widths.push_back(200); widths.push_back(80); m_listCtrl.SetHeader(names, widths); m_listCtrl.SetLineHeight(40);填充数据std::vectorCString row; row.push_back(_T(张三)); row.push_back(_T(研发部)); row.push_back(_T(在线)); m_listCtrl.AddRow(row);因为CMyListCtrl是自绘控件对话框只需要管理它的生命周期和对外接口不需要响应WM_DRAWITEM这类通知。数据更新后控件内部会Invalidate刷新。要注意控件的大小调整。对话框OnSize里一般要移动或者改变控件尺寸此时控件的WM_SIZE会触发然后CalcLayout自动重算滚动范围。如果你把控件放在对话框中记得在父窗口OnSize里调用m_listCtrl.MoveWindow否则控件大小不变滚动布局也不会更新。4.3 鼠标悬停效果的实现悬停效果是自绘控件体验提升的一个重要细节。实现思路是鼠标移动时记录当前鼠标所在行如果行号变化就更新悬停状态并Invalidate。鼠标离开窗口时清除悬停状态。void CMyListCtrl::OnMouseMove(UINT nFlags, CPoint point) { // 先判断是否在列表内容区域 if (point.y m_nHeaderHeight) { int nRow (point.y - m_nHeaderHeight m_nScrollOffset) / m_nLineHeight; if (nRow (int)m_rows.size()) { // 更新悬停状态 for (size_t i 0; i m_rows.size(); i) { bool bHot (i nRow); if (m_rows[i].bHot ! bHot) { m_rows[i].bHot bHot; bChanged TRUE; } } if (bChanged) Invalidate(FALSE); } } // 建立鼠标离开通知 if (!m_bMouseInClient) { TRACKMOUSEEVENT tme { sizeof(tme) }; tme.dwFlags TME_LEAVE; tme.hwndTrack m_hWnd; tme.dwHoverTime 0; TrackMouseEvent(tme); m_bMouseInClient TRUE; } } void CMyListCtrl::OnMouseLeave() { m_bMouseInClient FALSE; m_nHotRow -1; // 清除所有行悬停状态 for (size_t i 0; i m_rows.size(); i) m_rows[i].bHot FALSE; Invalidate(FALSE); }这个实现里我偷偷做了一个小优化只有在行悬停状态发生变化时才Invalidate而不是每次鼠标移动都重绘。如果每帧都重绘鼠标扫过列表时CPU占用会明显升高尤其数据量大时会卡顿。在自绘控件里重绘频率直接影响性能能少画就少画。4.4 性能优化建议与实测体验几千行数据内上面这套实现是毫无压力的。到几万行时建议再做几点优化只在需要时绘制文本。如果行很少滚动很多行的内容完全不变可以考虑用缓存位图保存已绘制的行滚动时只移动像素再补画新露出的行。这个方案我还没实测但理论收益明显。绘制单元格时先把字符串转成char或宽字符减少DrawText内部的转换开销。大量数据更新时把Invalidate调用延后到空闲期。MFC里有个比较土的技巧用一个BOOL标志记录“数据已脏”在OnIdle循环里统一刷新可以避免循环AddRow时每行都触发一次重绘。字体对象的创建开销比想象中大。不要在每次OnPaint里CreateFont改成在SetFont或初始化时创建一次成员变量持有。我这边的实测数据5万行、5列的数据在普通办公机上滚轮滚动帧率稳定在60fps左右CPU占用率在10%以内。相比原生CListCtrl在大数据量下的表现自绘方案确实有优势主要归功于双缓冲 可见区域裁剪这两板斧。5. 常见问题与排查技巧实录5.1 控件疯狂闪烁双缓冲加了没用排查思路先确认WM_ERASEBKGND是否已返回TRUE。如果擦除背景消息还在系统默认处理那么即使OnPaint里做了双缓冲每次重绘前窗口背景都会被擦成灰色然后再画出内容视觉上就是闪烁。还有一种隐蔽的情况CS_HREDRAW | CS_VREDRAW样式导致窗口大小变化时全部重绘列表数据量大的时候会闪。如果不需要这个行为注册类时去掉这两个样式即可。最后检查控件是否设置了WS_CLIPSIBLINGS。没有这个样式时兄弟窗口的绘制可能覆盖到控件上造成奇怪的残影和闪烁。5.2 滚动条拖动时滑块跳动或卡住这是命中测试顺序导致的。最常见的问题是滑块区域判定放在了滑道区域判定之后结果点在滑块上却被当成PAGEUP处理。另一个容易忽略的是拖动过程中鼠标移动消息要全部经过GetThumbRect计算如果GetThumbRect的返回值依赖滚动偏移量那么拖动时更新偏移会导致滑块位置变化进而造成下一次命中测试区域也跟着变产生抖动。解决方法是拖动期间滑块矩形按按下那一刻的滚动偏移计算不要实时跟随更新。我实际实现时OnLButtonDown就把ThumbRect缓存到一个成员变量m_rcCachedThumb里拖动过程中一直用缓存值做命中测试直到鼠标松开再重新计算。5.3 滚轮滚动方向异常不少人会犯这个错误认为zDelta正数应该向下滚结果方向反了。windows语义是zDelta大于0表示滚轮向用户方向滚即上滚内容应该向上移动也就是滚动偏移量减小。我在初版实现时也搞反过后来在OnMouseWheel里打印zDelta才纠正过来。5.4 高DPI下字体和行高错位如果程序不做DPI awareWindows会对窗口做位图缩放处理结果文字模糊行高计算错误。建议在程序入口调用SetProcessDpiAwarenessContext然后所有尺寸计算都基于逻辑像素。CFont用CreatePointFont创建可以保证字号随DPI自适应但行高如果是写死的就会在125%、150%缩放下错位。我的做法是在WM_DPICHANGED_AFTERPARENT里重新计算行高以及所有列宽公式是行高乘以dpi比例系数。这个处理虽然啰嗦但真的上高分屏时效果差别很大。5.5 点击选中行背景不更新这通常不是绘制问题而是消息流程问题。比如你处理了OnLButtonDown但点击之后没有调用SetFocus导致控件虽然是自绘的但父对话框某个默认焦点行为让列表失去了视觉焦点用户无法感知选中状态。另外注意OnLButtonDown处理完后要调用CWnd::OnLButtonDown保证基类行为不被打断。如果你把语句写成了else分支独占可能出现点击某些区域时焦点更新丢失。5.6 自绘控件吞掉双击消息自绘控件如果要响应双击必须确保注册类时带CS_DBLCLKS样式否则双击时系统只发两次单击消息根本不会发WM_LBUTTONDBLCLK。这个问题排查起来非常隐蔽光看代码很难发现。清单整理成表格方便直接对号入座症状根因处理办法闪烁未返回TRUE的OnEraseBkgnd / 双缓冲缺失双缓冲OnEraseBkgnd置TRUE滑块跳动拖动时命中测试依赖实时偏移缓存ThumbRect拖动期间固定滚轮反向zDelta语义理解错误上滚偏移减小下滚偏移增大1440p下错位未处理DPI缩放注册DPI Aware并按比例换算选中不刷新焦点/消息路由问题点击后SetFocus确保消息透传点击列头无反应命中测试范围不精确检查列头矩形的边界计算6. 经验总结与后续扩展方向写到这里这个自绘列表控件的主体功能基本齐了。回顾一圈最核心的心得其实就三条一是数据与绘制分离滚动只改偏移量绘制只画可见行性能才有保障二是双缓冲要做得彻底擦背景动作全部禁止所有画面一次合成三是滚动条虽然看起来只是个小部件但命中测试和拖动逻辑涉及的边界情况特别多宁可多花半小时把交互想清楚也别急着往上堆代码。往后扩展的方向也比较明确支持列宽拖拽调整在列头区域捕获鼠标移动事件动态修改列宽后重算布局增加右键菜单支持在WM_RBUTTONUP里根据命中行弹出菜单单元格编辑模式双击行后创建一个子编辑框Enter确认Esc取消列排序箭头、行分组、首列固定这几类功能布局层需要较大调整但数据模型沿用现在的结构问题不大如果你在用MFC做Windows桌面客户端又恰好被原生列表控件的定制能力卡住了这套基于CWnd的自绘方案完全可以作为起点。刚开始工程量会让人有点犹豫但一旦跑通后续所有视觉需求都不再依赖系统主题自由度完全在自己手里。
SERVICES

这篇文章没讲透的,服务来补

把方法落到行动,报名、备考、查证三件事都有人接。

FAQ

看完文章,你可能还想问

高频问题先答一遍,没有你的问题直接问顾问。

考试批次、政策变化一有官方消息就同步更新;日常内容按周持续补充。首页资讯区和资讯中心都会同步,不会漏。

把你的工种、学历、工作内容发给我们,顾问给针对性的报考方案;涉及证书状态的,发证书照片或号码来,帮你核验给结论。

本页下方有「相关阅读」和「最新资讯」,资讯中心按考试通知、政策法规、培训辅导、行业资讯、证书知识、报考解答六大分类整理,按需查看。

能干建筑的电工证:学历卡脖子?3步搞定网上查询与报名

能干建筑的电工证:学历卡脖子?3步搞定网上查询与报名

能干建筑的电工证:学历卡脖子?3步搞定网上查询与报名 很多在工地摸爬滚打多年的兄弟,心里都有一块石头:手里有活儿,但怕没证。特别是想往建筑电气方向发展的,最担心的就是“学历不够,报不上名”。别慌,今天咱们就聊聊这个能干建筑的电工证,到底怎么破局。 很多人第一反应是去报名点问,其实第一步应该是在…

2026/10/12 10:50:45相关阅读
C、Java、Python三种语言对比:类与对象的本质与用法

C、Java、Python三种语言对比:类与对象的本质与用法

面向对象编程的“类”和“对象”,说难也不难,但要说彻底弄明白,光靠一门语言真的容易绕进去。我在带新人做项目时,经常发现很多人Java里写类、Python里写类都还行,可一旦追问一句“C语言里没有类,它是怎么面…

2026/10/12 10:50:08相关阅读
HTTP与RPC接口测试实战:从协议差异到用例设计与线上排错

HTTP与RPC接口测试实战:从协议差异到用例设计与线上排错

做接口测试这几年,我把 Postman、Jmeter、Apifox、grpcurl 这些工具换了一轮,最后发现工具只是表面,真正决定测试质量的是你对 HTTP 和 RPC 这两套协议的理解深度。很多人觉得接口测试就是发个请求、看个状态码,真到线上出问题的时…

阅读全文
贵阳安监局电工证培训官方回应:学历不高别慌,这样考稳过

贵阳安监局电工证培训官方回应:学历不高别慌,这样考稳过

贵阳安监局电工证培训官方回应:学历不高别慌,这样考稳过 很多在贵阳干电活的兄弟,心里都犯嘀咕:我没读过大学,甚至初中都没毕业,去 贵阳安监局电工证培训 报名,会不会因为学历不够直接给拒了?这种担心太正常了,毕竟现在啥都讲门槛。 其实大家多虑了。 官方回应…

阅读全文
AI漫剧工业化流水线:零基础量产的结构化工作流

AI漫剧工业化流水线:零基础量产的结构化工作流

1. 这不是“AI绘画课”,而是一套可复用的漫剧工业化流水线 “零基础 AI 漫剧智能量产创作营”——光看标题,很多人第一反应是:又一个教人用MidJourney画美女图的速成班?或者换个马甲的Stable Diffusion参数调优课?我最…

阅读全文

这篇文章没解决你的问题?

直接问顾问,把你的工种、学历、年龄说清楚,几分钟给你一个靠谱的报考方案。