简介:一份面向C# WinForms开发者的DataGridView单元格合并实现资料,专门解决表格标题行、汇总行需要跨行跨列显示的表头布局问题,也适用于报表统计中的多级表头与动态合并需求。压缩包仅44KB,共22个文件,核心为6个C#源文件,另附可直接运行的exe演示程序、Visual Studio工程文件(.sln/.csproj)、resx界面资源及调试符号(pdb)等,在VS中打开工程即可运行并对照源码定位实现细节。已有345人浏览学习,作者arbinghappy在说明文档中对比了逻辑合并(隐藏列)与视觉合并(重写Paint事件)两种思路,并完整演示了视觉合并过程:先遍历单元格判断合并条件,再用大矩形覆盖目标区域,同时借助DefaultCellStyle.Alignment设置内容居中或左对齐;还提示禁用AutoSizeColumnsMode避免自动调整宽度破坏合并效果,处理表头嵌套以及合并区内嵌入按钮、文本框等控件的方法,并强调合并后每个单元格数据仍独立存储。读者既能获得可直接套用的合并代码,也能理解DataGridView事件驱动与自定义绘制的底层逻辑,遇到绘制偏移、列宽变化等问题也有明确的排查方向,特别适合报表表头需要多级合并或定时刷新数据的场景,可直接集成到现有WinForms项目中。
1. DataGridView 合并单元格:没有 Merge 方法,就用绘制欺骗眼睛
做 WinForms 报表界面的人,迟早都会遇到一个需求:同一个分类下的多行数据,在某一列不要重复显示,而要像 Excel 那样合并成一个单元格。DataGridView 合并单元格并没有现成的 API,你去翻属性面板找不到 Merge,翻事件列表也没有 Merge 事件。先给结论:正确做法是用 CellPainting 在绘制阶段自己掌控内容与边框,把同一个分组里非首行的格子全部隐藏,再由首行把文本和边框一路画到组底部。只要事件选对、分组算准、边界处理好,这个方案可以做到绑定数据、分页滚动、排序后都不翻车。这套逻辑对刚接手 WinForms 维护的人最实用,也是做台账、单据列表、频道汇总时最省心的合并方案。
2. 渲染原理与事件选型:为什么只在 CellPainting 里做手脚
2.1 为什么不是重写 OnPaint:事件驱动和自绘的分工边界
刚接触这个需求时,我第一反应是继承 DataGridView,重写 OnPaint 自己画。试了一轮就放弃了:OnPaint 是控件整体绘制层,单元格的绘制、滚动区域裁剪、列头行头的刷新顺序全在内部状态里,强行在 OnPaint 里画单元格内容,既要处理剪裁、又要处理双击命中,根本划不来。
DataGridView 的绘制模型本来就为这种场景留了口子:每个单元格在绘制前都会触发 CellPainting 事件,事件参数里有 CellBounds、RowIndex、ColumnIndex、CellStyle 和 State。你在这个事件里把 Handled 设为 true,网格就不再走内部默认绘制;设为 false,网格继续画默认样式。这正是我们需要的分层——默认逻辑负责背景、边框、文本的基础渲染,我们只覆盖合并那一列,其余列完全不受影响。
事件驱动的另一个好处是不强占类继承。公司里很多老项目用了第三方网格封装,或者在 DataGridView 外面包了自定义控件,不可能为了合并功能重构继承链。用事件订阅的方式,三五十行就能把合并逻辑挂到任意 DataGridView 实例上,想给哪一列加合并效果,就在初始化时往哪个列上挂一个合并器。
需要重申一个边界:CellPainting 只负责“画”,不负责“布局”。单元格的宽度高度、行索引、列索引都由网格管理,我们在事件里拿到的 CellBounds 永远是正确的当前格子位置,滚动、列宽拖动之后网格会自动修正。这种拆分的好处是合并绘制不需要关心滚动条位置,坏处是不能像布局系统那样改变真正的高度。
2.2 CellPainting 触发时机与三个初始化入口
CellPainting 的触发频率比想象中高:单元格获得焦点、滚动显示区、鼠标悬停、数据重绑、调用 Invalidate,都会触发重绘。这意味着合并逻辑必须保证一个前提——绘制阶段绝不能做耗时操作,更不能在绘制事件里修改数据源。
分组数据在什么时候算?我的习惯是维护一个合并器类,构造函数里订阅四个事件:
- DataBindingComplete:数据源绑完、行数稳定,群体组重建;
- RowsAdded / RowsRemoved:增删行后重建;
- CellValueChanged:单元格内容变化后重建;
- CellPainting:只负责用现成的分组结构来画。
这种把“计算”和“绘制”分开的方式确实能避免滚动卡顿。分组数据量不管几千行还是几万行,重建次数也只在数据变动时发生,而不是每个单元格都重新算一遍。
public class CellMerger { private readonly DataGridView _grid; private readonly int _colIndex; private List<MergeGroup> _groups = new List<MergeGroup>(); public CellMerger(DataGridView grid, int colIndex) { _grid = grid; _colIndex = colIndex; _grid.DataBindingComplete += OnBindingComplete; _grid.RowsAdded += OnRowsAdded; _grid.RowsRemoved += OnRowsRemoved; _grid.CellValueChanged += OnCellValueChanged; _grid.CellPainting += OnCellPainting; } private void OnBindingComplete(object sender, DataGridViewBindingCompleteEventArgs e) { RebuildGroups(); } private void OnRowsAdded(object sender, DataGridViewRowsAddedEventArgs e) { RebuildGroups(); } private void OnRowsRemoved(object sender, DataGridViewRowsRemovedEventArgs e) { RebuildGroups(); } private void OnCellValueChanged(object sender, DataGridViewCellEventArgs e) { if (e.ColumnIndex == _colIndex) RebuildGroups(); } }OnBindingComplete 和 OnRowsAdded 的事件参数类型不同,所以不能共用同一个方法签名,这是 C# 事件机制决定的写法,别想着偷懒合并。
参数说明:colIndex 是合并列的索引,从 grid.Columns 里取;如果 DataGridView 开了 AllowUserToAddRows,RowsRemoved 也会在删除新行时触发,这正好是需要的。CellValueChanged 只在用户提交编辑或代码赋值时触发,逐列判断 e.ColumnIndex 可以避免其它列的值变动引发无谓重建。
这里有个细节:DataBindingComplete 触发时,DataGridView 的行数可能还没完全稳定,某些虚拟模式或延迟加载场景下需要判断 grid.Rows.Count 是否为 0。RebuildGroups 内部需要做空保护,不要假设绑定后一定有数据。
3. 垂直合并实现:分组计算、跨行绘制与选中高亮的一次完成
3.1 分组算法:值相同、行连续的单元格才算一组
合并的前提是分组。什么样的行算一组?两个条件同时满足:某一列的值相同,并且在行顺序上是连续的。比如一个客户 ID 出现了三次,中间隔了另一个客户的单子,那这两个同名客户不能合并,必须各自成组。
实现分组算法时,我先讲一个常见误区:很多人拿到 DataTable 直接用dataTable.DefaultView.ToTable(true, "列名")做 Distinct,然后把合并行的索引记下来。这在数据不需要按序显示时没问题,但报表场景里行序往往是业务顺序,Distinct 会把连续关系打散。我这里只用一次遍历完成连续分组。
private void RebuildGroups() { _groups.Clear(); if (_grid.Rows.Count == 0) return; int startRow = 0; object previousValue = GetValue(0); for (int i = 1; i < _grid.Rows.Count; i++) { object currentValue = GetValue(i); if (!IsSameValue(previousValue, currentValue)) { _groups.Add(new MergeGroup(startRow, i - 1, previousValue)); startRow = i; previousValue = currentValue; } } _groups.Add(new MergeGroup(startRow, _grid.Rows.Count - 1, previousValue)); } private object GetValue(int rowIndex) { return _grid.Rows[rowIndex].Cells[_colIndex].Value; } private static bool IsSameValue(object a, object b) { if (a == null && b == null) return true; if (a == null || b == null) return false; return string.Equals( Convert.ToString(a), Convert.ToString(b), StringComparison.Ordinal); }MergeGroup 是一个简单的结构体,里面放 StartRow、EndRow、Value 三个字段。算法逻辑是:从第 0 行开始,拿上一行的值和当前行比较,如果不相同,就把 startRow 到 i-1 这一段收成一个组,再让 startRow 指向当前行。循环结束后把最后一组也收进去。这个过程的复杂度是 O(n),一万行数据遍历一次也就是几毫秒的事。
GetValue 我单独抽出来了,因为后面选中断言、调试画框都要用。如果一个单元格值是 DBNull,Convert.ToString 会得到空字符串,这和 null 会被区分出来,下一章会专门讲 null 的边界坑。
分组完成后的数据结构是连续区间列表。假设有四行数据,客户 ID 分别是 C001、C001、C002、C002,分组结果就是两个 MergeGroup:[0,1] C001、[2,3] C002。如果四行全是 C001,那只有一个组[0,3]。
3.2 跨行绘制:顶行画整块、中间行全跳过
分组结构有了,剩下就是画。绘制的原则只有一个:组内非首行完全隐藏,组内首行把文本和边框一路画到组底。听起来简单,实际操作时要处理行高不一致的问题。
DataGridView 的每行高度可以不同,有些行因为内容换行自动增高,有些行手动调整过 Resizable。所以合并区域的高度不是简单乘行数,而是要把组内每行的高度都累计一次。
private void OnCellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex < 0 || e.ColumnIndex != _colIndex) return; MergeGroup group = FindGroup(e.RowIndex); if (group == null || group.StartRow == group.EndRow) return; if (e.RowIndex != group.StartRow) { using (SolidBrush bgBrush = new SolidBrush(e.CellStyle.BackColor)) { e.Graphics.FillRectangle(bgBrush, e.CellBounds); } e.Handled = true; return; } DrawMergedCell(e, group); } private void DrawMergedCell(DataGridViewCellPaintingEventArgs e, MergeGroup group) { Rectangle bounds = e.CellBounds; int totalHeight = 0; for (int r = group.StartRow; r <= group.EndRow; r++) totalHeight += _grid.Rows[r].Height; bool selected = IsGroupSelected(group); Color backColor = selected ? e.CellStyle.SelectionBackColor : e.CellStyle.BackColor; Color foreColor = selected ? e.CellStyle.SelectionForeColor : e.CellStyle.ForeColor; using (SolidBrush bgBrush = new SolidBrush(backColor)) { e.Graphics.FillRectangle(bgBrush, bounds.X, bounds.Y, bounds.Width, totalHeight); } string text = Convert.ToString(group.Value); if (!string.IsNullOrEmpty(text)) { Font font = e.CellStyle.Font ?? _grid.Font; SizeF size = e.Graphics.MeasureString(text, font); float x = bounds.X + (bounds.Width - size.Width) / 2f; float y = bounds.Y + (totalHeight - size.Height) / 2f; e.Graphics.DrawString(text, font, new SolidBrush(foreColor), x, y); } using (Pen pen = new Pen(_grid.GridColor)) { e.Graphics.DrawLine(pen, bounds.Left, bounds.Top, bounds.Left, bounds.Top + totalHeight); e.Graphics.DrawLine(pen, bounds.Right - 1, bounds.Top, bounds.Right - 1, bounds.Top + totalHeight); e.Graphics.DrawLine(pen, bounds.Left, bounds.Top, bounds.Right, bounds.Top); e.Graphics.DrawLine(pen, bounds.Left, bounds.Top + totalHeight - 1, bounds.Right, bounds.Top + totalHeight - 1); } e.Handled = true; }DrawMergedCell 的绘制合规有三件事:先用 SolidBrush 整体填充背景,把默认格子背景盖掉;再画文本,文本垂直居中用 totalHeight 计算,而不是当前行高度;最后画四条边框线,左边线和右边线要从组顶画到组底。这样就不会出现“合并格子中间有横线”的效果。可能有人会在中间画一条水平线,那是 bug。
这里还藏着一个 GDI+ 的细节:Color 传入 new SolidBrush(foreColor) 时,SolidBrush 必须释放,但 Font 不能释放。e.CellStyle.Font 可能是 null,落到 _grid.Font 上,这两个字体对象都是网格或样式持有的共享对象,一旦 using 释放会引发后续绘制异常。写自绘代码时,Pen 和 Brush 用 using 包住,Font 只引用不释放。
FindGroup 的查找我用顺序遍历,因为合并列的分组数量通常远小于总行数;几万行的报表也就几十个组。
private MergeGroup FindGroup(int rowIndex) { foreach (MergeGroup g in _groups) { if (rowIndex >= g.StartRow && rowIndex <= g.EndRow) return g; } return null; }3.3 选中与编辑状态:整块高亮,不许光标落在中间行
合并单元格之后,最容易被忽略的是选中状态。用户点合并区域中间的某一行时,整行默认高亮,视觉上像是只高亮了合并块里的几行,而其它列的高亮又是整行选,会显得断断续续。把合并块作为整体处理会更好。
private bool IsGroupSelected(MergeGroup group) { for (int r = group.StartRow; r <= group.EndRow; r++) { if (_grid.Rows[r].Selected) return true; } return false; }这方法配合 SelectionMode = FullRowSelect 很合适:组内任何一行被选中,整个合并块就用 SelectionBackColor 填充,上下几行的背景连成一片。如果业务允许中间行选中,可以让用户点击后在别的地方看到选中了一个组,语义更接近报表的“当前分类”。
编辑也要拦一下。合并列中间行的值本来就是重复的,允许编辑会造成“改了中间行,顶行还是旧值”的混乱。常见做法是只允许编辑组首行。
_grid.CellBeginEdit += (s, e) => { if (e.ColumnIndex != _colIndex) return; MergeGroup group = FindGroup(e.RowIndex); if (group != null && e.RowIndex != group.StartRow) e.Cancel = true; };这段逻辑挂在 CellMerger 构造函数里即可。它解决的不只是编辑问题,还有键盘 Tab 导航问题——如果用户光标可以停在中间行又不允许编辑,网格会出现“当前格高亮但无法输入”的怪异状态,还要额外处理。主动取消编辑,Tab 顺序会自然跳过这些中间行,体验正常。
4. 合并单元格避坑清单:五类高频翻车和对应修复代码
4.1 事件重复订阅,分组错位到整列都乱了
现象:同一个 DataGridView 上初始化两次 CellMerger,或者窗口重新打开时构造函数又执行了一遍,合并效果就彻底乱了,有些地方画出了重复边框,有些地方分组索引对不上。
原因:CellMerger 在构造函数里订阅的事件没有对应的退订逻辑,Windows 窗体设计器每次 new 都会挂一套新的事件处理器。旧分组、新分组同时存在于事件链表里,RebuildGroups 被触发多次,顺序还不可控。
解决:给 CellMerger 加 Dispose 方法,把所有事件退订掉。调用方在窗体 Dispose 时调用一次。
public void Dispose() { _grid.DataBindingComplete -= OnBindingComplete; _grid.RowsAdded -= OnRowsAdded; _grid.RowsRemoved -= OnRowsRemoved; _grid.CellValueChanged -= OnCellValueChanged; _grid.CellPainting -= OnCellPainting; _grid.CellBeginEdit -= OnCellBeginEdit; }我在构造函数订阅事件的同时保存 EventHandler 字段也可以,但 C# 里通过方法名直接退订最省事。关键在于 CellMerger 不能 “new 完就忘”,要留存引用,窗体关闭时成对释放。
4.2 空值和 Null 被当成同一组,合并成一个巨大格子
现象:某一列数据大部分是空字符串,只有少数行有值,合并结果显示整列空单元格全部连成了一片,中间有值的行也被吞掉。
原因:IsSameValue 里用了 Convert.ToString 比较,null 转成空字符串,""也转成空字符串,业务上这两者可能代表完全不同的含义。比如“未分配客户”是空字符串,“这条单据没有客户字段”是 null,合并时应区分。
解决:比较前先区分 null、DBNull、空字符串三态。
private static bool IsSameValue(object a, object b) { if (a == null && b == null) return true; if (a is DBNull && b is DBNull) return true; if (a == null || b == null) return false; if (a is DBNull || b is DBNull) return false; string sa = Convert.ToString(a); string sb = Convert.ToString(b); return string.Equals(sa, sb, StringComparison.Ordinal); }DBNull 和 null 在 DataGridView 的单元格 Value 里都可能出现,尤其是绑定 DataTable 时,空列默认是 DBNull.Value。不处理的话,DBNull 与 null 会被归成一组,只有几个有值的行,小问题也会变成大长条格子。
4.3 排序之后 merge 没重建,行号全对不上
现象:用户点击列头排序,某一列顺序变了,但合并区域还停留在旧行号上,文本和数据错位,像是把字贴到了不正确的格子里。
原因:DataGridView 自动排序时行位置变化,但 CellValueChanged 不触发,DataBindingComplete 默认情况下也不会在排序时触发。分组数据还是排序前的行号区间。排序后数据被移动到新行号,绘制时 FindGroup 用旧区间找新行,当然错位。
解决:订阅 DataGridView 的 Sorted 事件,排序完成后重建分组。
_grid.Sorted += (s, e) => RebuildGroups();另外要注意:如果 DataSource 是 DataView 且允许排序,DataGridView 的 SortCompare 和 DataView.Sort 是两套机制。最稳妥的做法是在代码里给 DataView.RowFilter 赋值后手动调用 RebuildGroups,不要依赖网格内部通知。排序完成后整个网格必须 Invalidate,光重建分组不刷新画面,肉眼看到的还是旧痕迹。
4.4 编辑触发后 CellPainting 半途返回,文字直接消失
现象:合并列的首行进入编辑状态后,格子背景还在,但文字没了,或者文字位置飘到左上角。
原因:编辑状态下该单元格处于 EditMode,默认绘制逻辑只画背景和编辑框,不画文本。我们的事件在绘制阶段覆盖了默认绘制,却没有对 EditMode 分支做处理。只要 e.State 里带 DataGridViewElementStates.Edited 或 Displayed 状态异常,我们沿用默认绘制,而默认绘制在编辑状态不重画文本,于是文字消失。
解决:在 DrawMergedCell 里判断当前格子是否处于编辑状态,是就直接调用默认绘制,等编辑提交后再走合并绘制。
if ((e.State & DataGridViewElementStates.Edited) == 0) { // 输出:与你上文的绘画类型 // 不支持 DataGridViewCellPaintingEventArgs.AdvancedBorderStyle }精确写法:
if ((e.State & DataGridViewElementStates.Edited) != 0) { e.Paint(e.CellBounds, DataGridViewPaintParts.All); e.Handled = true; return; }这样编辑中显示的是普通格样式,编辑结束后 CellValueChanged 触发 RebuildGroups,下一次绘制恢复合并样式。注意判断要用位运算,不能直接比较 e.State,因为 State 是标志枚举,同时可能带多个标志。
4.5 滚动后残留残影,旧格子盖住新内容
现象:鼠标快速拖动滚动条时,合并列的某些区域出现上一屏的文本重叠,白色背景上糊了一层黑字,或者边框线断了又接上。
原因:DataGridView 滚动时按需绘制可视区域,合并列的组顶行被画出,非顶行隐藏;但滚动条快速拖动时部分区域的 Invalidate 范围没有覆盖到所有自绘内容。我们把内容画到了 e.CellBounds 之外,等于画到了当前格子边界外,滚动时网格还按原单元格矩形计算失效区域,就产生了残影。
解决:滚动时进行一次强制全量刷新,并开启双缓冲。
_grid.Scroll += (s, e) => _grid.Invalidate(); typeof(DataGridView) .GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) ?.SetValue(_grid, true);双缓冲属性是受保护的,用反射开一次就行。在实际项目里我用反射设置这个属性后,滚动撕裂基本消失了。如果不想用反射,可以在窗体里继承 DataGridView 再暴露 DoubleBuffered,但和第一阶段的“不加继承”理念冲突,看你的工程约束。
另外还有一个隐藏坑:当组内行的高度总和较大,比如一个组包含七八行时,FillRectangle 会画到当前可视区下方,但 GDI+ 的绘制会被网格裁剪到客户区,所以不会溢出到窗体其它控件上,只是底色会在滚动时被下一行覆盖。加 Invalidate 能保证每次滚动都重算一次裁剪区。
5. 水平合并与列宽方案:当客户要的是“整块格子”而不是列拼接
5.1 别指望 ColumnSpan,先想清楚业务是合并还是拼接
垂直合并做顺手了,业务方会追加需求:两列能不能像 Excel 那样合并成一列显示?比如“日期”和“时间”合并成“日期时间”,“单据前缀”和“单据号”合并成一个完整编号。很多人在网上搜 DataGridView 水平合并,能找到一堆自绘方案,但我的经验是:除非你真的很喜欢处理命中测试和选择区域,否则别去实现真水平合并。
DataGridView 没有 ColumnSpan 属性,这是它的底层设计决定的。默认绘制、命中测试、键盘导航、列宽调整全都以“一个单元格=一个矩形”为前提。如果你在 CellPainting 里画跨列文本,用户点击合并区域时,网格不知道应该高亮左边列还是右边列,获得焦点时也绘制混乱,还会和列宽拖动冲突。
更合理的做法是区分“数据合并”和“显示拼接”。数据合并是指把两个字段拼成一个字段写入 DataTable 的计算列;显示拼接是指第一列显示拼接后的完整内容,第二列隐藏或不显示。这个思路对十几种报表都适用,没有绘制副作用。
5.2 字段拼接 + 隐藏列:最简单且不出错的做法
拼接字段最常见的场景是“日期 + 时间”。原表里日期列和时间列分开,是因为数据库规范要求;界面显示时合在一起更便于阅读。
DataTable table = new DataTable(); table.Columns.Add("WorkDate", typeof(string)); table.Columns.Add("WorkTime", typeof(string)); table.Columns.Add("WorkDateTime", typeof(string)); foreach (DataRow row in table.Rows) { row["WorkDateTime"] = string.Format("{0} {1}", Convert.ToString(row["WorkDate"]), Convert.ToString(row["WorkTime"])); }然后配置文件里把 WorkDate、WorkTime 两列的 Visible 设为 false,只显示 WorkDateTime 列。注意拼接要做在 DataTable 填充阶段,不要放在 CellFormatting 里,否则每次滚动都要重算字符串,数据量一大就卡。
如果不想新建计算列,也可以在 GridView 里新建一列 DataGridViewTextBoxColumn,在 CellFormatting 事件里赋值:
private void OnGridCellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (e.ColumnIndex == _mergedColumnIndex) { object part1 = _grid.Rows[e.RowIndex].Cells[_dateColIndex].Value; object part2 = _grid.Rows[e.RowIndex].Cells[_timeColIndex].Value; e.Value = string.Format("{0} {1}", part1, part2); e.FormattingApplied = true; } }这个方案的边界很清晰:不能对合并列排序时与源列联动;如果业务还要求按完整拼接串过滤,需要把拼接逻辑写到 BindingSource 的 Filter 表达式里,而不是只做显示层。
5.3 用列头合并模拟分组表头:跨列标题的轻量方案
还有一种“水平合并”其实是列头合并:报表把列分成几组,每组上面有一个相同的表头标题。DataGridView 默认列头只有一行,ColumnHeadersHeight 调整后,可以在 CellPainting 里对 RowIndex == -1 的单元格做特殊绘制,把几个列头合并成一个带分组标题的大格子。
_grid.ColumnHeadersHeight = 48; private void OnCellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex != -1 || e.ColumnIndex == -1) return; if (e.ColumnIndex >= 0 && e.ColumnIndex <= 2) { Rectangle rect = GetColumnHeaderSpanBounds(0, 2); TextRenderer.DrawText(e.Graphics, "基础信息", _grid.ColumnHeadersDefaultCellStyle.Font, rect, _grid.ColumnHeadersDefaultCellStyle.ForeColor, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); using (Pen pen = new Pen(_grid.GridColor)) { e.Graphics.DrawRectangle(pen, rect); } e.Handled = true; } } private Rectangle GetColumnHeaderSpanBounds(int startColumn, int endColumn) { int left = _grid.GetCellDisplayRectangle(startColumn, -1, false).Left; int right = _grid.GetCellDisplayRectangle(endColumn, -1, false).Right; return new Rectangle(left, 0, right - left, _grid.ColumnHeadersHeight / 2); }这个方案把两行表头的上半部分画成“基础信息”标题,下半部分让各列自己画列名。ColumnHeadersHeight 必须设成偶数,通常 48 或 52。需要注意列被冻结时 GetCellDisplayRectangle 返回的位置和普通列不同,冻结列合并要单独处理。这个方案不能满足全部水平合并场景,但在“分组表头”需求里已经能直接上线。
6. 验证合并结果:画红色边框、跑断言、再出门给领导看
6.1 把合并外框画成红色,让眼睛快速找茬
合并做完最怕的是肉眼看不出来。我给自己的检查习惯是:在 CellMerger 里加一个调试开关,开启后在 Paint 事件里把每个合并区域的外框用红色画出来。这样所有合并区域会变成一个个红色大矩形,哪里多画、哪里漏画,一眼就能发现。
public bool ShowDebugBounds { get; set; } private void DrawDebugBounds() { if (!ShowDebugBounds) return; using (Pen redPen = new Pen(Color.Red, 2f)) { foreach (MergeGroup group in _groups) { Rectangle firstRect = _grid.GetCellDisplayRectangle( _colIndex, group.StartRow, false); int totalHeight = 0; for (int r = group.StartRow; r <= group.EndRow; r++) totalHeight += _grid.Rows[r].Height; Rectangle bounds = new Rectangle( firstRect.X, firstRect.Y, firstRect.Width, totalHeight); _grid.CreateGraphics().DrawRectangle(redPen, bounds); } } }这个方法的局限是它画在客户区表面,滚动或遮挡后需要重新调用。我通常在调试按钮单击和 Scroll 事件里都调一次。验证完成后把 ShowDebugBounds 设为 false,不影响线上。
6.2 一组不变量断言:连续、同键、行序不变
肉眼检查之外,我会用断言跑一遍分组数据。三个不变量必须成立:组号连续,组内键值相同,组与组之间按行序严格分区。
private void ValidateGroups() { if (_groups.Count == 0) return; for (int i = 0; i < _groups.Count; i++) { MergeGroup group = _groups[i]; if (group.StartRow > group.EndRow) throw new InvalidOperationException($"分组 {i} 区间非法"); for (int r = group.StartRow; r <= group.EndRow; r++) { object value = GetValue(r); if (!IsSameValue(group.Value, value)) { throw new InvalidOperationException( $"第 {r} 行和第 {group.StartRow} 行值不一致"); } } if (i > 0) { MergeGroup prev = _groups[i - 1]; if (prev.EndRow + 1 != group.StartRow) { throw new InvalidOperationException( $"分组 {i - 1} 到 {i} 不连续"); } } } }这个断言在 RebuildGroups 完成后调一次。数据量一万行时也就跑几十毫秒,放在开发版里完全没负担。跑过断言并不能证明画面绘制正确,但至少保证分组结构没有错,剩下绘制层的错误交给红框模式排查。
那次之后,我把“合并”类需求都定成同一条交付路径:先跑断言确认数据分组,再开红框看视觉,最后用一段一万行的假数据反复滚动和排序,确认没有残影才交给业务验收。经过这套流程的合并效果,基本没在验收阶段出过幺蛾子。希望帮到你。
本文还有配套的精品资源,点击获取