简介面向使用 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 自绘小需要补 34 处钩子报表展示、只读或少量编辑隐藏边框伪合并极小基本不可用只读、固定行高的静态展示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 ListMergeRange BuildRanges(DataGridView grid, int column, int fromRow, int toRow) { var list new ListMergeRange(); 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 Dictionaryint, ListMergeRange _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是对的只有短块23 行或者总计行才值得居中。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 Dictionaryint, ListMergeRange 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 复制粘贴只拿到一个格子的值现象选中合并块按 CtrlC粘到 Excel 里只有一行数据块内其他行是空的或者干脆少了行。原因GetClipboardContent走的是控件自己的选中模型它不认识你的合并块按单元格逐个取值。解决拦截 CtrlC自己拼剪贴板内容。下面的写法按当前选中区域的矩形范围逐行取值合并列的非首行填空字符串粘到表格里正好是视觉上看到的形状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.CastDataGridViewRow().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 复制粘贴与导出复制前面给了 CtrlC 的拦截写法。导出到 Excel 是同一套逻辑的变体遍历行时落在合并块非首行的格子值一律输出空字符串或者直接跳过保证导出的矩阵和界面看到的一致。如果导出需要真正的合并单元格Excel 的 MergeCells那就要额外记录每块的起始行列和跨度在导出脚本里调Range.Merge()这是另一个话题但区间表的数据结构是通用的加几个字段就能复用。5.5 实测数据与阈值同一台开发机上我按 5 万行 × 8 列其中 2 列参与合并压过一次几个关键数字不缓存区间、每次绘制现算的情况下垂直滚动一屏大约 220280ms肉眼能看出掉帧改成区间表缓存后降到 40ms 左右再叠上只让首行绘制稳定在 1525ms开双缓冲前后差别主要在手感闪烁消失帧率提升有限。行高的影响比行数更大。固定行高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上也挂一次置脏。血泪经验是合并逻辑一旦开始靠看起来对来验收后面每加一个功能都会还一次债。从那以后我每次给表格加合并都强制先跑这三个用例再打开双缓冲、关掉自动列宽最后才允许自己去看界面效果。希望帮到你。本文还有配套的精品资源点击获取