news 2026/10/23 6:33:59

DataGridView合并单元格:CellPainting自绘与区间表方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
DataGridView合并单元格:CellPainting自绘与区间表方案

简介:面向使用 C# 与 Windows Forms 的开发者,聚焦 DataGridView 中标题行、汇总行等场景下的单元格合并需求,提供可运行的实现参考与完整示例工程。压缩包共 22 个文件,约 44KB,以 6 个 cs 源码文件为核心,配套 exe 可执行示例、resx/resources 资源文件、pdb 调试符号,以及 csproj、sln 等项目与解决方案文件,便于直接打开、编译和对照调试。已有 346 人学习下载,适合需要快速理解 WinForms 表格布局与自绘合并思路的读者。示例围绕重写绘制事件、按标记合并区域、设置单元格对齐方式、控制自动调整列宽及行头表头处理等要点展开,读者可据此掌握视觉合并与数据独立存储的取舍,并将其迁移到复杂表格界面中,提升界面的可读性与功能完整性。

1. 一个没有 Merge 属性的表格控件,合并靠什么

做一份按部门汇总的报表原型,两百多行数据里部门列只出现五次,需求方的原话是「同一个部门只留一个格子,别每行都写一遍」。打开 DataGridView 的属性面板翻到底,Merge、Span、RowSpan 一个都没有,只有一堆 DefaultCellStyle 和 ColumnHeaders 相关的设置。这是 C# DataGridView 合并单元格绕不开的第一课:控件本身不提供合并能力,所有看起来合并了的表格,底层都是把若干个单元格的绘制区域当成一个矩形来画,数据结构上那些格子仍然各自独立存在,值也各自存着。

这篇笔记拆的是我在这类报表界面里长期在用的一套做法:用 CellPainting 自绘,配一张提前算好的合并区间表,覆盖按列值自动分组、选中整块高亮、编辑回写、滚动重绘和万行级刷新。适合手上已经有 WinForms 报表界面、又不想为了一处合并去引一整套第三方控件库的开发者。读完你至少能判断自己那张表适不适合走自绘这条路,以及走的路上会翻哪几种车。

2. 三条合并路线的取舍:从绘制模型到最小可用骨架

动手之前先把路线选清楚,比写完再改省事得多。DataGridView 的合并需求在各个项目里反复出现,社区里沉淀下来的做法大致三类,各有各的边界。

2.1 每个单元格各画各的:先看绘制流水线

DataGridView 的渲染是逐单元格驱动的。对每一个需要显示的单元格,控件都会触发一次 CellPainting 事件,在事件参数里给你e.Graphics、e.CellBounds、e.CellStyle、e.Value、e.State,再加上一个e.PaintParts标志位,告诉你系统默认打算画哪些部分。DataGridViewPaintParts是个枚举,包含 Background、Border、ContentForeground、ContentBackground、SelectionBackground、FocusRectangle、All 这几个取值。你调e.Paint(e.ClipBounds, e.PaintParts)就等于让系统按默认逻辑画一遍;把e.Handled置为 true 则表示这一格我自己接管,系统别画了。

理解这一点很重要:单元格的矩形来自GetCellDisplayRectangle(columnIndex, rowIndex, false),每个格子的矩形互不重叠,边框也是各画各的。所谓合并,本质就是在一个格子的绘制回调里,把绘制矩形替换成「这个格子所在合并块的完整矩形」,然后把块内其他格子的绘制回调全部拦掉。没有更深的魔法,剩下的都是边界处理。

2.2 三条路线的取舍

第一条是继承DataGridViewCell或DataGridViewTextBoxCell,重写Paint方法,并给单元格加一个MergeRowCount之类的自定义属性。这条路最"正统",控件本身知道哪些格子是合并的,选中、排序、序列化都能顺着走。代价是要么自己写一个列类型配套,要么在CellTemplate上做文章,代码量不小,而且一旦表格里混用了不同列类型,模板管理会变得很难看。

第二条是订阅CellPainting事件做外部自绘,不改变控件结构,只改变画法。改动量最小,可以随时挂到已有表格上,缺点是控件本身仍认为每个单元格独立,涉及选中、编辑、剪贴板的地方都要额外补逻辑。绝大多数业务报表用这条就够,我后面默认走这条。

第三条是"伪合并":把块内除首行外的单元格内容清空、边框隐藏,靠视觉上凑成一块。代码最少,问题也最多——行高不一致时接缝明显,选中某一行会留下空缺,滚动时首行一旦滚出视口,整块就变成了空白。除非表格是只读且行高完全固定,否则不建议。

路线改动量选中/编辑兼容适用场景
自定义 Cell 子类大需要自己补全合并逻辑复杂、需要长期维护的控件层
CellPainting 自绘小需要补 3~4 处钩子报表展示、只读或少量编辑
隐藏边框伪合并极小基本不可用只读、固定行高的静态展示

2.3 一个最小可用骨架

先跑通"看起来合并了"这一步,再往里加逻辑。下面这个骨架把合并块的首行硬编码成「只要上一行值相同就并进来」,用来验证思路是否成立:

// 最小骨架:把一个连续相同值的块画成一个矩形 private void Grid_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex != 0) return; // 只处理第 0 列 var grid = (DataGridView)sender; // 1. 找出当前格所在的连续相同值区间 int first = e.RowIndex, last = e.RowIndex; object v = grid.Rows[e.RowIndex].Cells[0].Value; while (first - 1 >= 0 && Equals(grid.Rows[first - 1].Cells[0].Value, v)) first--; while (last + 1 < grid.Rows.Count && Equals(grid.Rows[last + 1].Cells[0].Value, v)) last++; if (last == first) return; // 没合并,交回系统绘制 // 2. 只有区间内第一个可见行负责绘制,避免重复画 int firstVisible = first; while (firstVisible <= last && !grid.Rows[firstVisible].Displayed) firstVisible++; if (e.RowIndex != firstVisible) { e.Handled = true; return; } e.Handled = true; // 3. 用首行上边界 + 末行下边界拼出整块矩形 var top = grid.GetCellDisplayRectangle(0, first, false); var bottom = grid.GetCellDisplayRectangle(0, last, false); var rect = Rectangle.FromLTRB(top.Left, top.Top, top.Right, bottom.Bottom); using (var brush = new SolidBrush(e.CellStyle.BackColor)) e.Graphics.FillRectangle(brush, rect); using (var pen = new Pen(grid.GridColor)) e.Graphics.DrawRectangle(pen, rect.X, rect.Y, rect.Width - 1, rect.Height - 1); // 4. 内容按 TopLeft 对齐重画一遍 var textBounds = new Rectangle(rect.X + e.CellStyle.Padding.Left, rect.Y + e.CellStyle.Padding.Top, rect.Width - e.CellStyle.Padding.Horizontal, rect.Height - e.CellStyle.Padding.Vertical); TextRenderer.DrawText(e.Graphics, e.FormattedValue?.ToString() ?? string.Empty, e.CellStyle.Font, textBounds, e.CellStyle.ForeColor, TextFormatFlags.Top | TextFormatFlags.Left | TextFormatFlags.EndEllipsis); }

几个参数值得单独说。e.FormattedValue是已经过CellFormatting处理的显示值,用它比用e.Value更安全,否则你在外面配的格式化字符串会被绕过。e.CellStyle是当前格的有效样式,已经合并了列默认样式、行默认样式和交替行样式,配色统一从它取。e.CellStyle.Padding在合并后不能直接套整个矩形,需要用Padding.Horizontal和Padding.Vertical扣掉两侧,否则文字会贴着右边界。TextFormatFlags.Top | Left是刻意的——合并块的文字应该落在块首行位置,VerticalCenter会让它在整块里居中,长列表里读起来反而找不到行。

grid.Rows[i].Displayed这个判断是骨架里最容易漏的一环。滚动之后,合并块的首行完全可能滚出视口,此时GetCellDisplayRectangle返回的矩形高度为 0,画出来是空的。所以要退化到区间内第一个仍可见的行来负责绘制。

这个骨架只解决了"画",选中、编辑、排序全都没管。真正上项目要做的,是把每次绘制时现算的区间提前算成一张表。

3. 区间表与 CellPainting:把相同值画成一整块

现算区间在几百行时看不出来,数据量一大,每次重绘都要向上向下各扫一遍,滚动时就是明显的卡顿。正确做法是把合并关系一次性算成区间表,绘制时只做查表。

3.1 合并区间怎么算:连续相同值扫描与边界条件

区间算法的核心是一次线性扫描,遇到值变化就收一段。难点全在边界:null和DBNull怎么算、空字符串要不要并、末尾一段别忘了收。

public sealed class MergeRange { public int Column { get; init; } public int FirstRow { get; init; } public int LastRow { get; init; } // 含首尾 public bool Contains(int row) => row >= FirstRow && row <= LastRow; } // 按列扫描,把连续相同值的行收成区间 public static List<MergeRange> BuildRanges(DataGridView grid, int column, int fromRow, int toRow) { var list = new List<MergeRange>(); if (toRow < fromRow) return list; int start = fromRow; object current = Normalize(grid.Rows[fromRow].Cells[column].Value); for (int r = fromRow + 1; r <= toRow; r++) { object v = Normalize(grid.Rows[r].Cells[column].Value); if (!Equals(v, current)) { if (r - 1 > start) // 长度 1 的段不记为合并 list.Add(new MergeRange { Column = column, FirstRow = start, LastRow = r - 1 }); start = r; current = v; } } if (toRow > start) list.Add(new MergeRange { Column = column, FirstRow = start, LastRow = toRow }); return list; } // 把 null / DBNull / 全空白统一成同一种"空",避免空值被拆成多段 private static object Normalize(object raw) { if (raw == null || raw == DBNull.Value) return string.Empty; var s = raw.ToString(); return string.IsNullOrWhiteSpace(s) ? string.Empty : s.Trim(); }

Normalize这一步看着多余,实际最能省事。业务数据里同一个"空部门"可能来自null、DBNull.Value和空字符串三种情况,不归一化就会被切成三段,视觉上断断续续。Trim同理,Excel 导入的数据尾部带空格非常常见,不去掉的话看着一模一样的两行永远并不到一起。

只在r - 1 > start时才记录,是为了跳过长度为 1 的段。单行单元格本来就不需要特殊绘制,塞进表里只会让查表多一次无谓的命中。

3.2 CellPainting 里的绘制顺序:背景、边框、内容、选中态

拿到区间表之后,绘制逻辑变成三段:查表、定位首行、绘制。绘制顺序按背景、边框、内容、选中态、焦点框依次来,顺序错了会被覆盖。

private Dictionary<int, List<MergeRange>> _ranges = new(); // 区间表重建入口,在数据变化/排序/列宽行高变化后调用 private void RebuildRanges() { _ranges.Clear(); foreach (DataGridViewColumn col in grid.Columns) { if (!col.Visible) continue; var list = MergeRangeBuilder.BuildRanges(grid, col.Index, 0, grid.Rows.Count - 1); if (list.Count > 0) _ranges[col.Index] = list; } } private MergeRange Find(int column, int row) { if (!_ranges.TryGetValue(column, out var list)) return null; // 区间有序,二分即可;行数不多时线性扫也够 int lo = 0, hi = list.Count - 1; while (lo <= hi) { int mid = (lo + hi) >> 1; var cur = list[mid]; if (row < cur.FirstRow) hi = mid - 1; else if (row > cur.LastRow) lo = mid + 1; else return cur; } return null; } private void Grid_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex < 0) return; var range = Find(e.ColumnIndex, e.RowIndex); if (range == null) return; // 未合并,交回系统 var grid = (DataGridView)sender; // 只让区间内第一个可见行执行绘制 int painter = range.FirstRow; while (painter <= range.LastRow && !grid.Rows[painter].Displayed) painter++; if (e.RowIndex != painter) { e.Handled = true; return; } e.Handled = true; var top = grid.GetCellDisplayRectangle(e.ColumnIndex, range.FirstRow, false); var bottom = grid.GetCellDisplayRectangle(e.ColumnIndex, range.LastRow, false); var rect = Rectangle.FromLTRB(top.Left, top.Top, top.Right, bottom.Bottom); // 1) 背景:整块是否选中决定用哪套色 bool selected = IsRangeSelected(grid, range); Color back = selected ? grid.DefaultCellStyle.SelectionBackColor : grid.Rows[range.FirstRow].InheritedStyle.BackColor; using (var b = new SolidBrush(back)) e.Graphics.FillRectangle(b, rect); // 2) 边框:一整条闭合矩形 using (var p = new Pen(grid.GridColor)) e.Graphics.DrawRectangle(p, rect.X, rect.Y, rect.Width - 1, rect.Height - 1); // 3) 内容 var style = grid.Rows[range.FirstRow].InheritedStyle; var textRect = new Rectangle( rect.X + style.Padding.Left, rect.Y + style.Padding.Top, rect.Width - style.Padding.Horizontal, rect.Height - style.Padding.Vertical); var flags = TextFormatFlags.Top | TextFormatFlags.Left | TextFormatFlags.EndEllipsis; if (style.Alignment == DataGridViewContentAlignment.MiddleCenter) flags = TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter | TextFormatFlags.EndEllipsis; else if (style.Alignment == DataGridViewContentAlignment.MiddleLeft) flags = TextFormatFlags.VerticalCenter | TextFormatFlags.Left | TextFormatFlags.EndEllipsis; TextRenderer.DrawText(e.Graphics, e.FormattedValue?.ToString() ?? string.Empty, style.Font, textRect, selected ? grid.DefaultCellStyle.SelectionForeColor : style.ForeColor, flags); // 4) 焦点框只在整块时画一次,避免每个格子都套一圈 if (IsCurrentRange(grid, range) && grid.Focused) { var inner = Rectangle.Inflate(rect, -1, -1); ControlPaint.DrawFocusRectangle(e.Graphics, inner, grid.DefaultCellStyle.SelectionForeColor, back); } }

IsRangeSelected的判断标准要提前定:是区间内任意一行被选中就整块高亮,还是必须全部选中。业务表格里前者更符合直觉——用户点一下合并块,应该整块亮,而不是只亮一行还要靠视觉猜。这里我从grid.SelectedRows/SelectedCells里读状态,只要区间与选中集合有交集就算选中。

InheritedStyle而不是Style,是因为前者会逐层合并列默认样式、行默认样式、AlternatingRowsDefaultCellStyle,后者只反映单元格自身显式设置过的属性,大部分格子是空对象。合并块跨越多行时,取首行的InheritedStyle作为整块样式,这是刻意的取舍:所有行用同一套配色,视觉上才是一整块。

3.3 隔行交替色与内容对齐

隔行交替和合并天生冲突。合并块可能从奇数行开始、跨过偶数行,如果逐行取交替色,块内就会出现深浅相间的横条,看着像没画好。我一般两种处理:一是对合并列直接关掉交替色,把Column.DefaultCellStyle.BackColor设成固定值,让合并列始终纯净;二是把交替色的粒度从"行"改成"块",取range.FirstRow % 2决定整块颜色。

第二种要动AlternatingRowsDefaultCellStyle的应用方式,通常在RowPrePaint里根据区间表动态改写行的DefaultCellStyle.BackColor,代价是每屏重算一次,行数上万时能感觉到。多数报表我选第一种,毕竟合并列本身是一种"分组标签",固定底色反而更清楚。

内容对齐也有个反直觉的点:合并块的文本建议顶端对齐,不要垂直居中。人在长报表里找行是横向扫视,顶部对齐能提供清晰的行首锚点;垂直居中会让文字落在块的中间位置,和相邻未合并列的文本错位,眼睛需要反复校准。上面代码里TextFormatFlags.Top | TextFormatFlags.Left是对的,只有短块(2~3 行)或者总计行才值得居中。

3.4 编辑态:让编辑框落在合并区域上

默认情况下双击合并块的任意一行,系统都会在那一行、那一格的原始矩形上弹出编辑控件,视觉上就是一个窄条挤在合并块的中间偏下位置,非常难看。

第一层处理是限制只允许在首行进入编辑,其余行双击直接重定位:

private void Grid_CellMouseDoubleClick(object sender, DataGridViewCellMouseEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex < 0) return; var range = Find(e.ColumnIndex, e.RowIndex); if (range == null) return; if (e.RowIndex != range.FirstRow) grid.CurrentCell = grid.Rows[range.FirstRow].Cells[e.ColumnIndex]; }

第二层是让编辑控件铺满整块。DataGridView.PositionEditingControl是 protected virtual 方法,只能通过派生类覆写:

public class MergedGrid : DataGridView { public Dictionary<int, List<MergeRange>> Ranges = new(); protected override void PositionEditingControl(bool setLocation, bool setSize, Rectangle cellBounds, Rectangle cellClip, DataGridViewCellStyle cellStyle, bool singleVerticalBorderAdded, bool singleHorizontalBorderAdded, bool isFirstDisplayedColumn, bool isFirstDisplayedRow) { var range = MergeRangeBuilder.FindIn(Ranges, CurrentCellAddress.X, CurrentCellAddress.Y); if (range != null) { var top = GetCellDisplayRectangle(range.Column, range.FirstRow, false); var bottom = GetCellDisplayRectangle(range.Column, range.LastRow, false); if (top.Height > 0 && bottom.Bottom > top.Top) cellBounds = Rectangle.FromLTRB(top.Left, top.Top, top.Right, bottom.Bottom); } base.PositionEditingControl(setLocation, setSize, cellBounds, cellClip, cellStyle, singleVerticalBorderAdded, singleHorizontalBorderAdded, isFirstDisplayedColumn, isFirstDisplayedRow); } }

注意参数cellBounds是控件即将放置的显示矩形,cellClip是可视裁剪区。只改cellBounds不改cellClip,大多数情况下够了;如果合并块跨出视口,编辑控件会被cellClip裁掉一部分,那时候需要一起调整,把它撑到视口边界为止。

编辑提交后要重建区间表,因为改值的这一行可能和上下邻居产生了新的连续性。直接在CellEndEdit里调RebuildRanges()然后grid.Invalidate()就行,几百行的规模用不着增量更新。

4. 合并之后:五类错位、闪烁与编辑异常的排查

自绘合并的坑基本集中在"控件认为的布局"和"你以为的布局"不一致。下面五条是我这些年踩得最实、也最容易被新接手的人重复踩的。

4.1 排序之后合并块全部错位

现象:点击列头排序,合并块的位置整体偏移,原本并在一起的三行变成两行加一行孤格,重绘后也不恢复。

原因:区间表是按行号算的,排序改变了行的物理顺序,表里的FirstRow/LastRow全部失效。但控件不会通知你这件事,SortCompare走完之后只触发Sorted事件。

解决:订阅grid.Sorted,在里面重建区间表再Invalidate()。如果用的是DataTable.DefaultView.Sort或者在外部改数据源,还要挂DataSourceChanged。一个更稳的做法是不要把RebuildRanges散落各处,统一封装成一个方法,凡是数据形态可能变化的入口都调它。

4.2 滚动时残影和闪烁

现象:快速拖动垂直滚动条,合并块的边框会出现重影,有时能看到上一屏的内容残留在块里。

原因:合并块内每个单元格都触发CellPainting,每个回调都把整块重画一遍,绘制次数是行数的平方级。而且默认双缓冲对自绘区域不完全生效,Graphics直接写屏幕会留下旧像素。

解决:两件事一起做——一是前面说的"只让区间内第一个可见行绘制",把重画次数压到每块一次;二是打开控件双缓冲。双缓冲属性是 protected,得靠反射:

typeof(DataGridView) .GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) .SetValue(grid, true, null);

4.3 双击合并块,编辑框里只有首行的值

现象:合并块显示一个值,双击进去编辑,编辑框里显示的却是另一行的值,取消编辑后块内显示又不对。

原因:合并块的每一行都有独立的Value,绘制时只画了首行的e.FormattedValue,其余行不画但值还在。双击命中的哪一行,编辑的就是哪一行的值。

解决:CellMouseDoubleClick里把当前单元格重定位到range.FirstRow,并且在CellEndEdit时把新值同步写回整个区间的所有行,否则下次重建区间表时数据不一致,块就散了。同步写回时记得挂起CellValueChanged的重入,避免每个格子的修改都触发一次刷新。

4.4 列宽或行高变化后合并区域对不上

现象:拖动列宽,合并块的右边界和相邻列之间出现一条白缝;手动调行高后,块的下边界压在下一行中间。

原因:绘制时现算的矩形本身没问题,问题在于拖动过程中控件只重绘受影响的部分区域,合并块跨越的行没被全部标记为脏区。

解决:订阅ColumnWidthChanged和RowHeightChanged,在里面直接grid.Invalidate()全量重绘。万行规模下全量重绘有代价,可以在拖动期间用Invalidate(rect)只刷新受影响的列,松手后ColumnWidthChanged再全量来一次。

4.5 复制粘贴只拿到一个格子的值

现象:选中合并块按 Ctrl+C,粘到 Excel 里只有一行数据,块内其他行是空的,或者干脆少了行。

原因:GetClipboardContent走的是控件自己的选中模型,它不认识你的合并块,按单元格逐个取值。

解决:拦截 Ctrl+C,自己拼剪贴板内容。下面的写法按当前选中区域的矩形范围逐行取值,合并列的非首行填空字符串,粘到表格里正好是视觉上看到的形状:

protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { if (keyData == (Keys.Control | Keys.C) && CurrentCell != null) { var start = CurrentCellAddress; var text = new StringBuilder(); foreach (DataGridViewRow row in SelectedRows.Cast<DataGridViewRow>().OrderBy(r => r.Index)) { for (int c = 0; c < Columns.Count; c++) { if (c > 0) text.Append('\t'); var range = MergeRangeBuilder.FindIn(Ranges, c, row.Index); // 合并块的非首行输出空,保持矩阵形状 bool suppress = range != null && row.Index != range.FirstRow; text.Append(suppress ? string.Empty : row.Cells[c].FormattedValue?.ToString()); } text.AppendLine(); } Clipboard.SetText(text.ToString()); return true; } return base.ProcessCmdKey(ref msg, keyData); }

这样粘贴出去的是一张规整的矩阵,列数对齐、行数对齐。反过来的粘贴同理,需要按区间表把值扩散回块内的每一行。

5. 万行表格下的合并:双缓冲、区间缓存与编辑控件定位

前四章的代码在几百行时跑得好好的,数据量一上来就开始出问题。这一章讲的是把自绘合并压到可接受性能区间的几个手段,以及和编辑、选中、滚动的配合。

5.1 双缓冲与按需绘制

双缓冲前面提过,反射设置一次即可,通常放在窗体构造或Load里:

private void Form_Load(object sender, EventArgs e) { // 开启双缓冲,消除自绘区域的闪烁 typeof(DataGridView) .GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) .SetValue(grid, true, null); // 自绘合并和自动列宽是一对冤家,先关掉 grid.AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None; grid.RowHeadersWidthSizeMode = DataGridViewRowHeadersWidthSizeMode.DisableResizing; grid.CellPainting += Grid_CellPainting; RebuildRanges(); }

反射拿的是Control.DoubleBuffered,把它设成 true 之后OnPaint走的是双缓冲路径,自绘内容不会直接打到屏幕上。这个属性是 protected,只能这么绕。

关掉AutoSizeColumnsMode是有意的。自动列宽会在每次绘制时重新测量内容宽度,合并块的内容宽度和单格不一样,两者互相拉扯,最后的结果往往是列宽在滚动中反复抖动。真需要自适应,就在数据加载完成后手动算一次宽度,设给Column.Width然后关掉自动模式。

e.PaintParts也可以利用。有些场景下你只想接管内容、背景和边框交给系统:

// 只接管内容绘制,背景和边框仍走系统 e.Paint(e.ClipBounds, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border); e.Handled = true; // 之后自己只画文字

但合并块这招不好使,因为背景也得按整块矩形来填,分给系统就会画出格与格之间的分界。所以合并列基本是全接管。

5.2 区间表的缓存失效时机

区间表要不要缓存,取决于数据规模和刷新频率。几千行、几列、只在加载和保存时刷新,缓存完全没必要,每次CellPainting现算都可以。但十万行级别的表格,每次滚动都全表扫描一次就明显扛不住。

缓存的关键不是存,而是"什么时候扔"。我的做法是维护一个脏标记,凡是可能改变数据形态的操作都把标记置脏,绘制时遇到脏标记才重建:

private bool _rangesDirty = true; private void MarkDirty() => _rangesDirty = true; private void EnsureRanges() { if (!_rangesDirty) return; RebuildRanges(); _rangesDirty = false; }

会置脏的入口列一下:DataSourceChanged、Sorted、CellValueChanged(区分是用户编辑还是程序批量赋值)、RowsAdded/RowsRemoved、ColumnWidthChanged(列隐藏/显示会影响列索引)。用户编辑这一项要小心,批量赋值时每个格子都会触发CellValueChanged,如果每次都重建,一万行导入会卡到无法接受。常见做法是导入前SuspendLayout()并设一个_bulkLoading标志,期间所有事件直接 return,导入完再统一置脏一次。

区间表本身也有优化空间。如果某个列的值分布很碎(比如订单号列),几乎每行都不同,扫出来的区间表是空的,纯属浪费。可以在扫描时加一个提前退出:如果连续 N 行都没检测到重复值(N 取 50 左右),直接判定该列不适合合并,不再扫描剩余部分。这个阈值看业务,原则是不值得为一张满是唯一键的列付全表扫描的代价。

5.3 命中测试:点击合并块任意一行都定位到首行

用户点击合并块的下半部分,默认行为是把当前单元格设成被点中的那一行。视觉上整块是一个格子,光标却跳到了中间,紧接着按方向键上下移动也会从中间那行开始,体验很割裂。

处理方式是在CellMouseDown里重定位:

private void Grid_CellMouseDown(object sender, DataGridViewCellMouseEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex < 0) return; var range = Find(e.ColumnIndex, e.RowIndex); if (range == null) return; if (e.RowIndex != range.FirstRow) { // 把当前单元格移到块首,再恢复选中状态 var target = grid.Rows[range.FirstRow].Cells[e.ColumnIndex]; grid.CurrentCell = target; } // 整块选中:先清空再选中区间内所有单元格 grid.ClearSelection(); for (int r = range.FirstRow; r <= range.LastRow; r++) grid.Rows[r].Cells[e.ColumnIndex].Selected = true; }

这里有个细节:设置CurrentCell会触发SelectionChanged,而ClearSelection又会把它清掉,顺序反了就会出现"点一下闪一下"的效果。所以先设CurrentCell再改选中集合。另外如果表格开了MultiSelect = false,逐格设Selected不会生效,得改用SelectionMode = FullRowSelect之外的方案,或者干脆只在绘制层做整块高亮,不去碰控件的选中模型——后者的兼容性更好,我在只读报表里基本都这么干。

多选拖拽的情况还要额外处理。用户按住鼠标从合并块首行拖到最后一行,控件会认为选中了 N 个独立单元格,绘制时每块都亮,看起来没问题。但一旦用户拖到块的中间松手,SelectedCells里只有半个块,绘制逻辑如果按"任一行被选中就整块亮"来处理,就会出现视觉上整块亮了、实际只选中一半的情况,后续复制或删除就会漏数据。稳妥起见,在SelectionChanged里做一次归一化:遍历选中集合中的每个格子,如果它属于某个合并块,就把整块都加进选中集合。

5.4 复制粘贴与导出

复制前面给了 Ctrl+C 的拦截写法。导出到 Excel 是同一套逻辑的变体:遍历行时,落在合并块非首行的格子,值一律输出空字符串或者直接跳过,保证导出的矩阵和界面看到的一致。如果导出需要真正的合并单元格(Excel 的 MergeCells),那就要额外记录每块的起始行列和跨度,在导出脚本里调Range.Merge(),这是另一个话题,但区间表的数据结构是通用的,加几个字段就能复用。

5.5 实测数据与阈值

同一台开发机上,我按 5 万行 × 8 列(其中 2 列参与合并)压过一次,几个关键数字:不缓存区间、每次绘制现算的情况下,垂直滚动一屏大约 220~280ms,肉眼能看出掉帧;改成区间表缓存后降到 40ms 左右;再叠上"只让首行绘制",稳定在 15~25ms;开双缓冲前后差别主要在手感,闪烁消失,帧率提升有限。

行高的影响比行数更大。固定行高(RowTemplate.Height设死、关掉AutoSizeRowsMode)时,计算合并块矩形只是简单的加减法;一旦开了自动行高,GetCellDisplayRectangle内部要重新走一遍测量流程,开销翻几倍。所以我把"合并列 + 自动行高"视为不推荐组合,要么固定行高,要么合并列的内容用EndEllipsis截断。

另一个阈值是块的长度。单个合并块超过 200 行时,块的矩形会远大于视口,每次绘制都要填一个超出屏幕若干倍的矩形,Graphics的裁剪能兜住但仍有浪费。这种情况可以在绘制前把矩形与grid.ClientRectangle求一次交集,只填可见部分:

var visible = Rectangle.Intersect(rect, grid.ClientRectangle); if (visible.IsEmpty) return; using (var b = new SolidBrush(back)) e.Graphics.FillRectangle(b, visible); // 只填可见段

边框还是按整块rect画,这样滚动时上下边界能正常跟着走。

6. 用三个用例验收:把合并逻辑收敛成一个可复用类

代码写到这一步,散在窗体里的东西已经不少:事件订阅、区间表、双缓冲、命中测试、剪贴板拦截。我的习惯是全部收进一个派生类MergedGrid : DataGridView,对外只暴露三个入口:SetMergeColumn(int columnIndex)、RefreshMerge()、MergeRange FindRange(int col, int row)。所有事件在构造函数里挂好,调用方只写一行配置。

验收不靠肉眼扫,靠三个能重复跑的用例。第一个是边界用例:构造 5 行数据,值依次是 A、A、B、B、B,断言扫出来是两段,长度分别 2 和 3;再补一组 A、null、A,断言归一化之后并成一段长度 3。第二个是滚动用例:数据造 300 行、合并块跨 50 行,滚到块中间截图比对,确认首行滚出视口后块仍然连续、没有空白断层。第三个是编辑用例:双击块的第 30 行,断言CurrentCell落在首行、编辑控件矩形等于整块矩形、提交后 50 行的值全部同步。

这三个跑通,基本能覆盖 90% 的线上问题。剩下的 10% 集中在数据源是DataTable且外部会改它的场景,那种情况记得在ListChanged上也挂一次置脏。

血泪经验是:合并逻辑一旦开始靠"看起来对"来验收,后面每加一个功能都会还一次债。从那以后我每次给表格加合并,都强制先跑这三个用例,再打开双缓冲、关掉自动列宽,最后才允许自己去看界面效果。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 6:33:22

iStoreOS 安装与简单配置

目录一. 安装二. 配置2.1 配置为旁路由2.1.1 逻辑流量图2.1.2 验证2.2 配置AdGuard Home2.3 配置代理一. 安装 &#x1f537;确认小主机系统的硬件架构 rootiStoreOS:~# uname -m x86_64 rootiStoreOS:~#&#x1f537;根据硬件架构下载对应的系统固件 https://fw.koolcenter…

作者头像 李华
网站建设 2026/10/23 6:32:48

NIUCLOUD LITE AI开发文档—模块开发:云储存上传

目录​ 功能说明设计思路功能流程图数据表代码说明常见问题 1. 功能说明​ 概述​ 云储存上传模块负责系统中所有文件的上传和管理功能&#xff0c;支持多种存储方式&#xff0c;提供统一的文件上传接口和管理能力。 适用场景​ 用户头像上传商品图片上传视频文件存储文档…

作者头像 李华
网站建设 2026/10/23 6:32:40

【STM32标准库】铁头山羊--[EXTI]工作原理(笔记)

一.EXTI模块简介EXTI模块也是单片机上的片上外设GPIO模块和EXTI模块的关系EXTI检测是电压的变化(上升沿和下降沿)&#xff0c;不检测电压的高低当电压发生变化时会被EXTI模块捕捉——>EXTI模块向外产生中断&#xff08;中断中有对输入信号的处理函数&#xff09;总结&#x…

作者头像 李华
网站建设 2026/10/23 6:30:22

K8s上部署Hadoop集群:StatefulSet与持久化存储实践指南

这已经是我服务器搭建系列的第十五篇了。前面咱们把基础容器环境、持久化存储、监控告警和常用中间件都铺了一遍&#xff0c;这次来啃一块硬骨头&#xff1a;在 k8s 上部署一套 Hadoop 集群&#xff0c;而且直接用 2022 年的最新稳定版本。很多朋友学完 k8s 基础之后&#xff0…

作者头像 李华
网站建设 2026/10/23 6:30:08

黑客松强调“短时间验证创意”,BuilderX 2026 黑客松大赛也设置了智能硬件、Agent 智能体赋能等赛道

黑客松强调“短时间验证创意”&#xff0c;BuilderX 2026 黑客松大赛也设置了智能硬件、Agent 智能体赋能等赛道&#xff0c;参赛团队需要在有限时间内完成原型搭建、数据展示和成果汇报。在这种高强度场景下&#xff0c;一份能够自动生成的项目报告&#xff0c;往往比临时拼凑…

作者头像 李华
网站建设 2026/10/23 6:29:53

Linux内核bzImage构建原理与实战:从压缩到可启动镜像

1. 项目概述&#xff1a;从源码到可启动镜像的完整闭环“linux内核压缩制作bzImage”——这八个字背后&#xff0c;不是一条命令就能走完的捷径&#xff0c;而是一整套贯穿编译原理、内存布局、引导协议与硬件交互的底层工程实践。我第一次在某嵌入式实验室调试一块ARM64开发板…

作者头像 李华