简介:面向C# Windows Forms开发者的一份ListView内嵌ComboBox等控件的示例工程,适合希望突破ListView默认展示能力、在列表行或列中加入下拉选择与交互控件的初中级读者。压缩包共25个文件,约93KB,以8个cs源码为核心,配合sln/csproj项目文件、resx与resources资源、exe和pdb编译产物及配置文件,构成可直接运行和调试的完整解决方案。源码围绕子项控件挂载、控件布局适配和SelectedIndexChanged等事件绑定展开,能帮助读者理解在ListView中动态创建并管理ComboBox、处理选择变化及保持界面协调的实现思路。已有3130人学习下载,可作为WinForms列表交互改造的参考样例,用于课程练习、功能原型或既有项目扩展。
1. 一次界面评审逼出来的需求:ListView 的格子要能下拉
上周有人拿着一个 C# WinForms 的界面截图来问,ListView 的某一列能不能像 Excel 那样点开就是下拉框。答案是可以,但做法跟直觉完全不一样——C# ListView 里添加 ComboBox,真正做的事是把一个 ComboBox 控件贴到 ListView 上方,再靠坐标换算让它看起来长在格子里。ListView 本身只是 Win32 SysListView32 的一层托管包装,那些“子项”是画出来的像素,不是窗口,没有独立句柄,所以你翻遍 API 也找不到Item.Controls这种东西。这套做法我在几个内部工具里都落地过,最麻烦的一步不是把控件放上去,而是滚动条一拉、列宽一拖、窗口一缩放之后还能对齐。下面把整条链路拆开讲:坐标怎么算、滚动怎么跟、控件怎么复用不泄漏、哪些场景压根不该用 ListView 硬扛。
2. 先看清 ListView 的底层实现:子项只是一段绘制结果
2.1 子项没有句柄,控件是挂在 ListView 自己身上的
WinForms 的 ListView 继承自 Control,但它的实际窗口类是SysListView32,由系统通用控件库实现。这个窗口只有一层,里面所有的行、列、图标、文字全部是自己画出来的,不存在“第 3 行第 2 列”这样一个子窗口。这解释了一连串看起来很怪的现象:为什么不能给单元格设背景色、为什么改不了一行的字体、为什么没有 CellClick 事件。
既然子项不是窗口,那 ComboBox 只能挂到 ListView 这个父窗口的控件集合里,也就是调用listView.Controls.Add(combo)。加进去之后 ComboBox 就是一个普通的子窗口,它的 Bounds 以 ListView 客户区左上角为原点。剩下的事情全是计算:根据行号、列号反推出这块像素矩形,然后把 ComboBox 摆过去。这和 DataGridView 内部的编辑控件是同一套思路,区别在于 DataGridView 帮你算了,ListView 需要你自己算。
有一层隐含代价要提前知道:控件挂到 ListView 之后,ListView 并不会在滚动时帮它搬家。原生控件的滚动是内部偏移位图,不通知子窗口。所以必须自己监听滚动、自己重排,这是后面所有代码的核心。
2.2 四条技术路线的取舍
真要做到“格子里能编辑”,不止一条路。我在不同项目里四样都用过,判断依据主要是数据量和交互复杂度。
| 方案 | 实现成本 | 大数据量表现 | 适用场景 |
|---|---|---|---|
| ListView + 真控件托管 | 中高,需要处理坐标同步 | 千行以内可接受,控件要池化 | 只读为主、少数列需要编辑 |
| DataGridView + ComboBoxColumn | 低,开箱即用 | 千行以上要开虚拟模式 | 整体就是表格编辑器的界面 |
| ListView OwnerDraw 自绘 | 中,只画不编辑 | 好,无额外句柄 | 展示型下拉、颜色标签、进度条 |
| 点一下弹小面板 | 低 | 好 | 编辑动作低频、不要求常驻显示 |
我一般的判断是这样的:如果整张表超过一半的列都需要编辑,直接换 DataGridView,不要在 ListView 上硬做,省下来的时间够写三个业务模块。如果只是某两三列需要下拉,其余全是只读文本,ListView 的性能和视觉更轻,值得花半天把托管层写扎实。自绘那条路适合“看起来像控件但不能真实编辑”的场合,比如状态徽章、带颜色的小方块,画出来比真塞控件便宜一个数量级。
还有一种折中方案值得提:单元格平时显示文本,双击时在这个位置临时放一个 ComboBox,选完立刻移除并把文本写回去。整张表同一时刻只有一个真控件,句柄压力为零,坐标计算逻辑和常驻方案完全一样。列表很长、编辑很稀疏的时候,我会优先选这个。
2.3 只有 Details 视图才有“列”的概念
这一点经常被忽略。ListView 有五种视图:LargeIcon、SmallIcon、List、Details、Tile。只有 Details 视图存在列对象,LVM_GETSUBITEMRECT这个关键消息也只在 Details 下返回有意义的结果。切到其它视图,子项索引和屏幕矩形的对应关系直接失效,代码会算出一堆越界坐标,表现就是控件满天飞。
所以托管方案的第一步应该是加个视图校验:
// 托管层入口统一做一次守卫,视图不对就直接不绑定 private bool CanHostControls(ListView view) { if (view.View != View.Details) return false; if (view.VirtualMode) return false; // 虚拟模式没有真实 ListViewItem if (view.Columns.Count == 0) return false; // 没列就没格子 return true; }VirtualMode是另一个必须提前排除的选项。虚拟模式下 ListView 不持有 ListViewItem 实例,Items[i]是个按需生成的临时对象,GetItemRect和GetSubItemRect对未实现的行返回不可靠的值,而行号到数据的映射完全掌握在你自己的集合里。要在虚拟模式里嵌控件,得自己维护“可视行区间 + 行高”两套坐标,基本等于重写布局,我试过一次,最后放弃了,改用自绘下拉。
2.4 换控件之前先算一笔账
有人会问,既然 ListView 这么麻烦,为什么不直接用DataGridView?因为 DataGridView 也有自己的负担:默认单元格样式偏重、行高调整麻烦、数据绑定和手工行混用容易出错、几千行以上不开虚拟模式会明显卡顿。我在一个内部配置工具里试过把原本的 ListView 换成 DataGridView,代码量少了三分之二,但为了把外观调回原来的紧凑风格,又花了差不多同样的时间。
结论是:选型看的是“表格的编辑密度”而不是“表格的行数”。行数多但只读,ListView 加自绘最舒服;行数不多但每格都要改,DataGridView 更划算;行数多、编辑也密,那就该考虑分页或者换成专门的表格控件了,硬扛没有赢家。
3. 用 LVM_GETSUBITEMRECT 把 ComboBox 精确放到单元格位置
3.1 行高不够,先解决高度
默认的 ListView 行高大概 17 到 20 像素,一个 ComboBox 最小也要 22 像素以上,直接放进去会被压扁或者盖住相邻行。Details 视图下行高不是独立属性,而是由SmallImageList.ImageSize.Height间接决定的——这是个老技巧,但很多人不知道 ImageList 里必须真的有一张图才生效,空集合有时候不触发重排。
// 用一张 1x30 的透明位图把行高撑到 30 var rowHeight = new ImageList { ImageSize = new Size(1, 30), // 宽度给 1,只借用高度 ColorDepth = ColorDepth.Depth32Bit }; rowHeight.Images.Add(new Bitmap(1, 30)); listView.SmallImageList = rowHeight;这里ImageSize的宽设成 1 是为了不占位,因为 Details 视图下 SmallImageList 的宽度只影响图标位置的预留空间。如果同时设置了StateImageList,行高会取两个 ImageList 中较高的那个高度,这一点在排查“行高改不动”时最容易被忽略。改完行高后已存在的行会重排一次,所以最好在添加数据之前设置,避免中途闪烁。
注意:行高一旦确定,所有行都一样高,ListView 不支持单行变高。所以 ComboBox 的高度要跟着行高走,不要反过来。
3.2 取单元格矩形的正确姿势
.NET 包装层没有暴露“第 i 行第 j 列的屏幕矩形”,得走 P/Invoke。两个消息最常用:LVM_GETITEMRECT拿整行,LVM_GETSUBITEMRECT拿某个子项。后者的 lParam 指向一个 RECT 结构,进去时Top字段要填子项索引,Left字段填LVIR_BOUNDS。
internal static class ListViewGeometry { private const int LVM_FIRST = 0x1000; private const int LVM_GETITEMRECT = LVM_FIRST + 14; // 0x100E private const int LVM_GETSUBITEMRECT = LVM_FIRST + 56; // 0x1038 private const int LVM_GETTOPINDEX = LVM_FIRST + 39; // 0x1027 private const int LVIR_BOUNDS = 0; [StructLayout(LayoutKind.Sequential)] private struct RECT { public int Left, Top, Right, Bottom; } [DllImport("user32.dll", CharSet = CharSet.Auto)] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, ref RECT lParam); [DllImport("user32.dll", CharSet = CharSet.Auto)] private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam); // 当前视口里第一条可见行的索引,比 ListView.TopItem 安全 public static int GetTopIndex(ListView lv) { if (lv.IsHandleCreated == false || lv.Items.Count == 0) return 0; return SendMessage(lv.Handle, LVM_GETTOPINDEX, IntPtr.Zero, IntPtr.Zero).ToInt32(); } // displayColumn 是视觉上的列序号,需要先映射成子项索引 public static Rectangle GetSubItemBounds(ListView lv, int row, int displayColumn) { if (row < 0 || row >= lv.Items.Count) return Rectangle.Empty; int subIndex = SubItemIndexFromDisplay(lv, displayColumn); // 第 0 列不能走 GETSUBITEMRECT,它返回的是整行 if (subIndex == 0) { Rectangle rowRect = lv.GetItemRect(row, ItemBoundsPortion.Entire); return new Rectangle(rowRect.Left, rowRect.Top, lv.Columns[0].Width, rowRect.Height); } var rc = new RECT { Top = subIndex, Left = LVIR_BOUNDS }; IntPtr ok = SendMessage(lv.Handle, LVM_GETSUBITEMRECT, (IntPtr)row, ref rc); if (ok == IntPtr.Zero) return Rectangle.Empty; return Rectangle.FromLTRB(rc.Left, rc.Top, rc.Right, rc.Bottom); } // 用户拖动过列头之后,显示顺序和子项索引不再一致 private static int SubItemIndexFromDisplay(ListView lv, int displayColumn) { foreach (ColumnHeader col in lv.Columns) if (col.DisplayIndex == displayColumn) return col.Index; return displayColumn; } }几个参数值得单独说。wParam传行索引,用(IntPtr)row强转即可,行数不会超过 int 范围。lParam.Top传的是子项索引不是显示序号,用户拖动过列头之后这两者会错位,SubItemIndexFromDisplay就是干这个映射的,很多网上的示例代码都省了这一步,结果一拖列头就全乱。第 0 列是特殊情况,LVM_GETSUBITEMRECT对它返回整行宽度,必须单独走GetItemRect再按列宽截断。
另外ListView.TopItem的 getter 在空列表时会抛InvalidOperationException,setter 在某些状态下会静默失败,所以我统一用LVM_GETTOPINDEX拿顶层可见行,稳得多。
3.3 一个能跑起来的最小例子
先不追求通用性,把“第一行第二列放一个下拉框”跑通,确认坐标系是对的,再往上加逻辑。
public partial class DemoForm : Form { private readonly SyncListView _list = new SyncListView(); private readonly ComboBox _editor = new ComboBox(); public DemoForm() { Text = "ListView 单元格托管演示"; Size = new Size(720, 480); // 撑行高,必须带一张真图,空 ImageList 有时不触发重排 var rowHeight = new ImageList { ImageSize = new Size(1, 30) }; rowHeight.Images.Add(new Bitmap(1, 30)); _list.Dock = DockStyle.Fill; _list.View = View.Details; _list.FullRowSelect = true; _list.GridLines = true; _list.HideSelection = false; _list.SmallImageList = rowHeight; _list.Columns.Add("编号", 80); _list.Columns.Add("状态", 180); _list.Columns.Add("备注", 260); _editor.DropDownStyle = ComboBoxStyle.DropDownList; _editor.Items.AddRange(new object[] { "待处理", "进行中", "已完成" }); _editor.Visible = false; for (int i = 0; i < 6; i++) _list.Items.Add(new ListViewItem(new[] { (i + 1).ToString(), "待处理", "" })); Controls.Add(_list); _list.Controls.Add(_editor); // 挂到 ListView 上,坐标就是客户区坐标 _list.ViewportChanged += (s, e) => Relayout(); _list.SelectedIndexChanged += (s, e) => Relayout(); Load += (s, e) => { _list.Items[0].Selected = true; _list.Focus(); }; } private void Relayout() { if (_list.Items.Count == 0 || _list.SelectedIndices.Count == 0) { _editor.Visible = false; return; } int row = _list.SelectedIndices[0]; Rectangle rowRect = _list.GetItemRect(row, ItemBoundsPortion.Entire); // 行滚出可视区时立刻隐藏,否则会出现“幽灵下拉框” if (rowRect.Bottom <= 0 || rowRect.Top >= _list.ClientSize.Height) { _editor.Visible = false; return; } Rectangle cell = ListViewGeometry.GetSubItemBounds(_list, row, 1); if (cell.IsEmpty) { _editor.Visible = false; return; } // 内缩 1 像素,留出 GridLines 的边框,否则会把网格线盖掉 _editor.Bounds = new Rectangle(cell.Left + 1, cell.Top + 1, cell.Width - 2, cell.Height - 2); _editor.Visible = true; _editor.BringToFront(); _editor.SelectedItem = _list.Items[row].SubItems[1].Text; } }Relayout里做了三件事:算行矩形、判可见、算单元格矩形。判可见这一步非常关键,少了它,滚动之后看不见的行对应的控件仍然停在客户区边缘,视觉上就是屏幕上多出几个孤零零的下拉框。内缩 1 像素是为了不盖住 GridLines,这个细节能让界面看起来干净很多。
SyncListView是后面 3.4 要写的子类,先记住它负责在滚动时触发ViewportChanged。
3.4 滚动、列宽、窗口缩放的同步
ListView 没有Scroll事件,这是整个方案最大的坑。原生控件滚动时只做两件事:内部偏移、重绘。托管层收不到任何通知,控件就留在原地。解决办法是把 ListView 派生一层,拦窗口消息。
public class SyncListView : ListView { public event EventHandler ViewportChanged; private const int WM_VSCROLL = 0x0115; private const int WM_HSCROLL = 0x0114; private const int WM_MOUSEWHEEL = 0x020A; private const int WM_SIZE = 0x0005; private const int WM_PAINT = 0x000F; private const int WM_KEYDOWN = 0x0100; private bool _painting; protected override void WndProc(ref Message m) { base.WndProc(ref m); switch (m.Msg) { case WM_VSCROLL: // 拖滚动条、滚轮、键盘翻页最终都会走到这里 case WM_HSCROLL: // 横向滚动会改变列起点,同样要重排 case WM_MOUSEWHEEL: case WM_SIZE: case WM_KEYDOWN: // 方向键、PageUp/PageDown RaiseViewportChanged(); break; case WM_PAINT: // 等背景画完再摆控件,否则控件会先露出来再被行背景盖住 if (!_painting) { _painting = true; try { RaiseViewportChanged(); } finally { _painting = false; } } break; } } private void RaiseViewportChanged() { var handler = ViewportChanged; if (handler != null) handler(this, EventArgs.Empty); } }WM_PAINT里必须加重入保护。Relayout里如果调用了Invalidate或者改动会触发重绘的属性,会形成WM_PAINT → Relayout → Invalidate → WM_PAINT的循环,界面直接卡死,这是血泪经验。WM_KEYDOWN不放进去的话,用键盘 PageDown 翻页时控件不跟,用户会觉得这套界面“时灵时不灵”。
除了窗口消息,还有两个托管层事件要接:
// 列宽拖动过程中实时重排,否则要等松手才对齐 _list.ColumnWidthChanging += (s, e) => Relayout(); _list.ColumnWidthChanged += (s, e) => Relayout(); _list.Resize += (s, e) => Relayout();ColumnWidthChanging在拖动过程中连续触发,能做出“跟着走”的效果;只在ColumnWidthChanged里重排的话,拖的时候格子和控件是分离的,松手才啪地贴上去,观感很差。代价是拖动过程会频繁重排,如果控件很多,要在这个事件里做一个轻量版本——只改 Bounds,不做数据同步。
4. 从 ComboBox 到任意控件:写一个可复用的单元格托管器
4.1 共享控件模式:一列只留一个真控件
第一版代码里我把 ComboBox 绑在“当前选中行”上,整张表只有一个控件。这是最省资源的模式,也是 DataGridView 内部的做法:编辑时才放出一个编辑控件,编辑完收走。缺点是用户看不到“哪些格子可以编辑”,需要靠列样式或者背景色去暗示。
另一种做法是给每个可编辑单元格都放一个常驻控件。30 行没问题,300 行就是 300 个窗口句柄,滚动时 Windows 要处理 300 个重叠子窗口的裁剪,肉眼可见地掉帧。我在一个 500 行的配置表上试过,滚动直接变成幻灯片,任务管理器里句柄数比平时高了四位数。
所以托管器的设计目标定为:任意单元格按需创建,离开视口就回收进池,同一时刻的活跃控件数量不超过“可见行数”。这样既能常驻显示,又不会句柄爆炸。
4.2 CellControlHost 的完整实现
public sealed class CellControlHost : IDisposable { private readonly ListView _view; private readonly Func<int, int, Control> _factory; // 行、列 → 新控件 private readonly Dictionary<long, Control> _bound = new Dictionary<long, Control>(); private readonly Stack<Control> _pool = new Stack<Control>(); private bool _syncing; public CellControlHost(ListView view, Func<int, int, Control> factory) { _view = view ?? throw new ArgumentNullException(nameof(view)); _factory = factory ?? throw new ArgumentNullException(nameof(factory)); } // 用 row 高位 + column 低位拼一个 long 当复合键,避免元组装箱 private static long Key(int row, int column) => ((long)row << 20) | (uint)column; public Control GetOrCreate(int row, int column) { long key = Key(row, column); if (_bound.TryGetValue(key, out var exist)) return exist; Control ctrl = _pool.Count > 0 ? _pool.Pop() : _factory(row, column); ctrl.Visible = false; if (ctrl.Parent != _view) _view.Controls.Add(ctrl); _bound[key] = ctrl; return ctrl; } public void Release(int row, int column) { long key = Key(row, column); if (!_bound.TryGetValue(key, out var ctrl)) return; _bound.Remove(key); ctrl.Visible = false; _view.Controls.Remove(ctrl); // 先摘掉,避免它继续参与布局和绘制 _pool.Push(ctrl); } // 滚动之后统一调用:把不在可视区的格子全部回收 public void ReleaseOutside(int firstVisibleRow, int lastVisibleRow) { if (_syncing) return; _syncing = true; try { var stale = new List<long>(); foreach (var pair in _bound) { int row = (int)(pair.Key >> 20); if (row < firstVisibleRow || row > lastVisibleRow) stale.Add(pair.Key); } foreach (long key in stale) { int row = (int)(key >> 20); int col = (int)(key & 0xFFFFF); Release(row, col); } } finally { _syncing = false; } } public void Dispose() { foreach (var ctrl in _bound.Values) { ctrl.Visible = false; _view.Controls.Remove(ctrl); ctrl.Dispose(); } _bound.Clear(); while (_pool.Count > 0) _pool.Pop().Dispose(); } }Key用位运算拼 long 是为了避免每次查找都创建元组、产生 GC 压力,滚动时这个方法每秒会被调用几百次,细节能省一点是一点。Release里必须Controls.Remove之后再Dispose,只把Visible设成 false 是最常见的错误写法,控件还在父容器的子窗口链表里,句柄没释放,遍历时也照样参与。_syncing标志用来防重入:回收过程中如果把某个行删掉了,会连带触发选中变化、又触发一次回收,不加锁会抛集合修改异常。
绑定的时候需要把“数据写回”这一步接上:
var editor = (ComboBox)host.GetOrCreate(row, 1); editor.SelectedIndexChanged -= OnComboChanged; // 先解绑,避免重入 editor.SelectedIndex = IndexOfStatus(_view.Items[row].SubItems[1].Text); editor.SelectedIndexChanged += OnComboChanged; void OnComboChanged(object sender, EventArgs e) { var combo = (ComboBox)sender; int row = RowOf(combo); // 反查行号,建议在 Tag 里存 if (row < 0) return; _view.BeginUpdate(); try { _view.Items[row].SubItems[1].Text = combo.Text; } finally { _view.EndUpdate(); } }BeginUpdate/EndUpdate包住文本写入,否则每改一次就重绘一次整行,行多了肉眼可见地闪。事件先解绑再绑定是标准做法,不然SelectedIndex赋值本身就会触发一次回调,造成递归。
4.3 复选框和按钮的两条更简单替代路径
CheckBox 不要塞控件。ListView 自带CheckBoxes = true,够用;如果要多态(灰/勾/空),用StateImageList配三张图,成本比托管低一个数量级,而且排序、选中、键盘操作全都是原生的。
// 三态复选框的标准写法,比托管真 CheckBox 便宜太多 listView.CheckBoxes = false; // 关掉原生两态 var states = new ImageList { ImageSize = new Size(16, 16) }; states.Images.Add(Properties.Resources.Unchecked); states.Images.Add(Properties.Resources.Checked); states.Images.Add(Properties.Resources.Indeterminate); listView.StateImageList = states; listView.Items[0].StateImageIndex = 1; // 1 = 勾选按钮类的东西更简单:一列里同时只可能有一个按钮被点,所以共享一个 Button 就够了,点哪个格子就把 Button 挪到哪个格子上。甚至可以直接 OwnerDraw 画一个假按钮,在MouseClick里做命中测试,连那个共享控件的定位逻辑都能省掉,代价是没法用 Tab 键聚焦。
5. 避坑排查:控件跑偏、闪烁、句柄暴涨的五个现场
5.1 坐标与绘制类问题
现象一:滚动条一拉,下拉框留在原地,屏幕底部还会多出几个不该存在的控件。原因:ListView 滚动是内部偏移,不发任何托管事件,ViewportChanged从来没被触发过。另一个原因是Relayout里没做可见性判断,行滚出视口后控件仍然Visible = true。 解决:把 SyncListView 的WM_VSCROLL / WM_HSCROLL / WM_MOUSEWHEEL / WM_KEYDOWN全接上,统一走一次重排;重排里用GetItemRect拿行矩形,Bottom <= 0或Top >= ClientSize.Height就直接Visible = false,别指望它能自己消失。
现象二:快速滚动时控件闪一下才出现,像在放幻灯片。原因:控件是独立窗口,Visible = true之后立刻合成到屏幕,而此时 ListView 的行背景还没画完,于是先看到控件、后看到行,形成撕裂感。 解决:把同步时机放在WM_PAINT之后(3.4 的写法),并且给 ListView 开双缓冲——SetStyle(ControlStyles.OptimizedDoubleBuffer, true)对原生控件无效,得用SendMessage(handle, LVM_SETEXTENDEDLISTVIEWSTYLE, 0, LVS_EX_DOUBLEBUFFER),或者反射设置DoubleBuffered属性。批量新增行时套BeginUpdate/EndUpdate,能少掉一大半闪烁。
现象三:125% 或 150% 缩放下,下拉框比格子大一圈,位置偏右下。原因:GetSubItemRect返回的是设备像素,而 WinForms 在 DPI 感知模式下会对控件 Bounds 再做一次缩放换算,两边不同源就对不上。 解决:整个窗体统一AutoScaleMode.Dpi,不要在代码里手工乘缩放因子;如果宿主进程是 PerMonitorV2,用listView.PointToClient(...)统一折算一次,再在 Relayout 末尾做一次自检——把算出来的矩形和Columns[i].Width对一下,宽度差超过 2 像素就说明缩放被应用了两次。
5.2 数据与生命周期类问题
现象四:刷新几轮之后界面越来越卡,任务管理器里句柄数一路往上涨。原因:每次刷新都new ComboBox()然后Controls.Add,旧的只是Visible = false,既没Remove也没Dispose。ListView 的 Controls 集合不会自动回收,几百轮之后就是上千个僵尸子窗口。 解决:走 4.2 的池化路径,Release里必须Remove + 入池,Dispose里把池子清空。判断有没有泄漏很简单:滚动 20 次前后各读一次Process.GetCurrentProcess().HandleCount,差值超过 20 就说明池子里没回收干净。
现象五:在SelectedIndexChanged里改SubItems文本,偶尔抛索引越界或者界面卡死。原因:改文本会触发 ListView 重排,重排触发ViewportChanged,ViewportChanged又去遍历_bound调用Release,而这时候正在遍历Items,集合被改了。 解决:加_syncing布尔标志,事件回调里只写数据不碰布局,把所有重排集中到BeginUpdate/EndUpdate之后再调一次。另外绝对不要在SelectedIndexChanged里删行,删除动作要挪到MouseUp或者用BeginInvoke延迟一拍。
注意:
VirtualMode = true下这套托管方案不能用。虚拟模式没有真实的 ListViewItem,GetItemRect对未实现的行返回的值不可靠,硬用会算出一堆无效坐标。真要做,只能自绘或改用 DataGridView。
6. 验收清单:拿 1000 行数据跑一遍再交付
写完托管层之后,我会用一个固定脚本压一遍,看四个指标,任何一项不过就不算完成。
| 检查项 | 操作方法 | 通过标准 |
|---|---|---|
| 坐标一致性 | 滚动到底再回顶,逐行比对控件 Bounds 与GetSubItemBounds | 偏差 ≤ 2 像素 |
| 句柄稳定 | 来回滚动 20 次,读HandleCount | 前后差值 < 20 |
| 数据回写 | 每行都改一次下拉值,全部提交后导出比对 | 无一丢失、无串行 |
| 重排耗时 | Stopwatch包住单次 Relayout,1000 行 | 单次 < 8 ms |
重排耗时这一项最容易被忽略。测试方法是把Relayout包起来打日志:
var sw = Stopwatch.StartNew(); Relayout(); sw.Stop(); if (sw.ElapsedMilliseconds > 8) Debug.WriteLine($"Relayout 超时 {sw.ElapsedMilliseconds}ms, 活跃控件 {_bound.Count}");超过 8 毫秒基本就意味着有控件没被回收,或者Relayout里干了数据同步这种重活。我遇到过一次,日志显示活跃控件数是可见行数的三倍,排查半天发现是ReleaseOutside的区间上限算错了一行,导致最下面那行永远回收不掉,每次重排都在它身上多挂一个控件。
另外一个我必做的动作是故意把列拖窄到 10 像素。列宽小于控件最小宽度时,ComboBox 会撑出格子、盖住右边的列,视觉上非常明显。正确的处理是在 Relayout 里加一句if (cell.Width < 40) { ctrl.Visible = false; continue; },宁可暂时隐藏也不让它溢出。
从那以后我每次写这类托管代码,都强制先把压测脚本跑一遍,而不是靠肉眼在小窗口里点两下就交付。肉眼永远看不出句柄在涨,也看不出 8 毫秒的累积会在滚动时变成什么样子。希望帮到你。
本文还有配套的精品资源,点击获取