
简介这份资源面向使用 C# 开发 Windows Forms 应用的开发者聚焦 DataGridView 控件的单元格合并问题。当需要在表格中创建标题行、汇总行或复杂表头时普通单元格布局往往难以满足需求资源围绕逻辑合并与视觉合并两种思路展开重点讲解通过重写 OnPaint 事件自定义绘制来实现视觉合并并涉及对齐方式设置、AutoSizeColumnsMode 调整、行头主副表头处理以及自定义 DataGridViewCell 等关键细节。压缩包为 rar 格式共 22 个文件约 44KB包含 6 个 cs 源码文件、3 个 exe 可执行程序、2 个 resx 与 2 个 resources 资源文件、2 个 pdb 调试符号以及 csproj、sln、suo、settings、manifest 等工程配置与缓存文件构成一套可直接运行的完整示例工程。目前已有 344 人学习下载适合希望快速掌握合并单元格实现方式、对照源码理解绘制流程与排错思路的读者参考。1. 从一次报表翻车说起DataGridView 合并单元格到底能解决什么去年帮一家做仓储管理的客户改 WinForms 老系统出库单打印预览里同一个订单号在明细表里重复出现了十几行业务方拍着桌子说“这表格看着像没做完”。当时我第一反应是去改 SQL 做分组结果发现数据本身没问题是展示层需要把相同订单号的单元格视觉上合并成一块。这就是 DataGridView 合并单元格最典型的落地场景数据行必须保留但视觉上要按某个或某几个字段归并。它解决的不是数据聚合问题而是展示层的可读性问题。适合谁维护 .NET Framework WinForms 老项目的工程师、做内部管理系统的独立开发者、以及被业务方要求“表格看起来专业一点”的同行。这篇笔记不讲虚的直接拆我实际用过的合并方案、参数怎么设、哪些坑会让你在演示现场翻车。2. 合并的底层逻辑为什么 DataGridView 没有现成的 Merge 属性2.1 单元格绘制与数据分离的机制DataGridView 从设计上就是一个“平铺”的网格控件每个单元格DataGridViewCell对应一行一列的交叉点它没有像 Excel 那样原生的 Merge 属性。你看到的“合并”效果本质上是通过CellPainting 事件或单元格重绘把相邻相同值的单元格区域画成一个整体同时把被“吃掉”的单元格内容清空、边框抹掉。换句话说合并是画出来的不是数据结构层面的合并。理解这一点非常关键因为后面所有坑都源于此排序、选中、编辑、复制粘贴这些行为仍然按原始单元格粒度走不会因为你视觉上合并了就自动变成“一个单元格”。常见做法有两种一是用DataGridViewCellPaintingEventArgs在绘制阶段动态合并适合数据量大、合并规则会变的场景二是预先计算合并区域用DataGridViewAdvancedBorderStyle和背景色覆盖来“伪装”合并适合规则固定、追求性能的场景。我一般会优先选第一种因为它的侵入性小数据源不用动排序后重新触发绘制即可。2.2 合并区域的计算从行遍历到区间标记不管用哪种绘制方式核心都是先算出哪些单元格要合并。以最常见的“同一列中连续相同值合并”为例算法逻辑是遍历该列每一行维护一个起始行 startRow当发现当前行值与上一行不同时就把 [startRow, 当前行-1] 这个区间标记为一个合并块。这里有个容易被忽略的边界只合并连续相同的行如果中间隔了一行不同的值即使后面又出现相同值也不能跨过去合并否则视觉上会误导用户以为它们是一组。这个规则在订单号、批次号场景里尤其重要。下面是我常用的一个合并区间计算函数返回一个字典key 是列索引value 是合并区间的列表// 计算指定列中连续相同值的合并区间 // 参数dgv-目标表格, columnIndex-要合并的列索引 // 返回List(int startRow, int endRow) 每个元素是一个合并块 private Dictionaryint, List(int start, int end) CalcMergeRanges( DataGridView dgv, int[] columnIndexes) { var result new Dictionaryint, List(int, int)(); foreach (var col in columnIndexes) { var ranges new List(int, int)(); if (dgv.Rows.Count 0) { result[col] ranges; continue; } int start 0; for (int i 1; i dgv.Rows.Count; i) { // 到达末尾或值发生变化时结算一个区间 bool isEnd i dgv.Rows.Count; bool isDiff !isEnd !Equals( dgv.Rows[i].Cells[col].Value, dgv.Rows[start].Cells[col].Value); if (isEnd || isDiff) { if (i - 1 start) // 至少两行才需要合并 ranges.Add((start, i - 1)); start i; } } result[col] ranges; } return result; }这段代码里columnIndexes是你要合并的列数组比如订单号在第 0 列、客户名称在第 1 列就传new[]{0,1}。注意Equals比较的是单元格的 Value如果数据源里是 DBNull 或者空字符串要提前统一否则null和会被判定为不同值导致该合并的没合并。我一般会在绑定数据后先做一次空值归一化。2.3 CellPainting 事件里做合并绘制的完整流程算出区间后就在CellPainting事件里动手。思路是对于每个合并区间的起始单元格正常绘制它的内容但把背景和边框画成覆盖整个区间的大小对于区间内的其他单元格直接跳过内容绘制只画背景或者什么都不画让起始单元格的绘制盖住。这里必须设置e.Handled true否则默认绘制会把内容又画一遍出现重影。// 在窗体加载时绑定事件 dgv.CellPainting Dgv_CellPainting; private Dictionaryint, List(int start, int end) _mergeRanges; private void Dgv_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; if (_mergeRanges null || !_mergeRanges.ContainsKey(e.ColumnIndex)) return; var ranges _mergeRanges[e.ColumnIndex]; var range ranges.FirstOrDefault(r e.RowIndex r.start e.RowIndex r.end); if (range default) return; // 不在任何合并区间内走默认绘制 // 只由区间起始行负责绘制整个合并块 if (e.RowIndex range.start) { // 计算合并块的总高度 int totalHeight 0; for (int i range.start; i range.end; i) totalHeight dgv.Rows[i].Height; Rectangle rect new Rectangle( e.CellBounds.Left, e.CellBounds.Top, e.CellBounds.Width, totalHeight); using (Brush backBrush new SolidBrush(e.CellStyle.BackColor)) e.Graphics.FillRectangle(backBrush, rect); // 画边框 using (Pen pen new Pen(dgv.GridColor)) e.Graphics.DrawRectangle(pen, rect); // 画内容垂直居中 TextRenderer.DrawText(e.Graphics, e.Value?.ToString() ?? , e.CellStyle.Font, rect, e.CellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter); e.Handled true; } else { // 区间内非起始行只填充背景不画内容 using (Brush backBrush new SolidBrush(e.CellStyle.BackColor)) e.Graphics.FillRectangle(backBrush, e.CellBounds); e.Handled true; } }参数说明e.CellBounds是当前单元格的矩形区域totalHeight是把区间内所有行高累加这样合并块才能跨越多行。TextFormatFlags.VerticalCenter让文字在合并块里垂直居中否则会贴在顶部很难看。这段代码在数据绑定后、排序后、行高变化后都需要重新计算_mergeRanges并调用dgv.Invalidate()触发重绘。3. 填充数据合并单元格从 DataTable 到界面的完整链路3.1 数据准备阶段的三个归一化动作热词里提到的“填充数据合并单元格”我理解成两个层面一是数据填充到 DataGridView 之后再做合并二是合并后的单元格如何正确显示填充的数据。不管哪种数据准备阶段有三件事必须做否则后面必出玄学问题。第一空值归一化把 DBNull、null、空字符串统一成或某个占位符避免Equals比较时把本该合并的行拆开。第二排序稳定性如果业务要求按订单号合并那数据源必须按订单号排序且排序要稳定否则相同订单号被其他行隔开合并区间就断了。第三列顺序确认合并列的索引在绑定后不能变如果用户能拖动列顺序你的columnIndexes就失效了得改用列名来定位。// 数据绑定后立即做归一化和排序 DataTable dt GetOrderDetails(); // 按订单号排序保证相同订单号连续 DataView dv dt.DefaultView; dv.Sort OrderNo ASC, LineNo ASC; dt dv.ToTable(); // 空值归一化 foreach (DataRow row in dt.Rows) { foreach (DataColumn col in dt.Columns) { if (row.IsNull(col)) row[col] ; } } dgv.DataSource dt; // 绑定后重新计算合并区间 _mergeRanges CalcMergeRanges(dgv, new[] { 0, 1 }); dgv.Invalidate();这里dv.Sort的排序字段要根据实际业务定OrderNo ASC保证相同订单号聚在一起LineNo ASC保证明细行顺序不乱。归一化那层循环看起来笨但比在每个比较处写?? 要可靠得多尤其是数据源来自多个不同数据库的时候。3.2 合并后选中与复制行为的修正合并绘制完成后你会发现一个很别扭的现象鼠标点在合并块的中间行选中的是那一行的单元格而不是整个合并块。复制的时候也只复制了那一行的值用户会以为复制了整个合并内容。这是 CellPainting 方案的天然缺陷因为选中逻辑还是按原始单元格走的。我的处理办法是在SelectionChanged或CellClick事件里做补偿当用户点击的单元格属于某个合并区间时手动把整个区间的行都选中。private void Dgv_CellClick(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; if (_mergeRanges null || !_mergeRanges.ContainsKey(e.ColumnIndex)) return; var range _mergeRanges[e.ColumnIndex] .FirstOrDefault(r e.RowIndex r.start e.RowIndex r.end); if (range default) return; dgv.ClearSelection(); for (int i range.start; i range.end; i) dgv.Rows[i].Cells[e.ColumnIndex].Selected true; }注意ClearSelection要先调否则之前选中的行会残留。这个补偿逻辑只对合并列生效其他列还是正常单选。如果业务方要求复制合并块的内容还得在KeyDown里拦截 CtrlC自己拼剪贴板文本这个后面进阶部分再展开。3.3 行高变化与滚动时的重绘时机合并块的高度依赖行高如果用户手动拖拽调整了某一行的高度或者程序里根据内容动态设置了AutoSizeRowsMode合并块的绘制就会错位。我踩过的坑是设置了AutoSizeRowsMode AllCells之后行高在绘制过程中还在变导致totalHeight算出来和实际不一致合并块要么盖住下一行要么留一条白缝。解决办法是在RowHeightChanged或AutoSizeRowsModeChanged事件里重新计算合并区间并Invalidate而且要把AutoSizeRowsMode设成None之后手动调AutoResizeRows避免绘制和自动调整同时发生。// 关闭自动行高改为手动触发 dgv.AutoSizeRowsMode DataGridViewAutoSizeRowsMode.None; dgv.AutoResizeRows(DataGridViewAutoSizeRowsMode.AllCells); // 行高变化后重算合并 dgv.RowHeightChanged (s, e) { _mergeRanges CalcMergeRanges(dgv, new[] { 0, 1 }); dgv.Invalidate(); };滚动条滚动时DataGridView 会重绘可见区域CellPainting会被反复触发所以_mergeRanges必须缓存好不能在事件里现算否则大数据量下滚动会卡成幻灯片。我一般把计算放在数据绑定后和排序后滚动时只读缓存。4. 避坑与排查合并单元格最常见的五类翻车现场4.1 现象合并后单元格内容重影或错位原因通常是e.Handled没有正确设置或者区间内非起始行也走了默认绘制。默认绘制会把单元格的值再画一遍和你的合并绘制叠在一起。解决确保起始行绘制后e.Handled true非起始行只填充背景并同样设置e.Handled true。另外检查e.Value是否在绘制前被清空过如果数据源里起始行的值本身是空的画出来就是空白。4.2 现象排序后合并块错乱相同值没有合并原因是排序后没有重新计算_mergeRanges缓存还是旧的区间。解决在ColumnHeaderMouseClick或数据源ListChanged事件里重新调用CalcMergeRanges并Invalidate。如果用的是 BindingSource监听BindingSource.ListChanged更稳。4.3 现象合并列无法编辑双击后内容消失CellPainting 方案下合并块的非起始行单元格其实还在双击进入编辑时编辑的是那一行的值而绘制时又被跳过看起来就像内容消失了。解决如果合并列需要编辑建议只允许编辑起始行在CellBeginEdit里判断当前行是否为区间起始行不是就e.Cancel true。或者干脆把合并列设成ReadOnly编辑走弹窗。4.4 现象大数据量下滚动卡顿CPU 飙升原因是CellPainting里做了 LINQ 查询或重复计算。ranges.FirstOrDefault在每次绘制时都遍历列表行数一多就慢。解决把区间查找改成字典或二分查找或者提前为每一行缓存它所属的区间。我一般会建一个int[] rowToRangeStart数组绘制时 O(1) 拿到起始行。4.5 现象合并块边框和网格线对不齐出现双线原因是默认网格线还在画你的合并绘制又画了一遍边框。解决把dgv.CellBorderStyle设成None完全由你的绘制逻辑控制边框或者只画合并块的外框内部不画。注意设成 None 后非合并列也没有网格线了需要自己在CellPainting里补画。5. 进阶技巧把合并单元格做成可复用的扩展方法5.1 封装成 DataGridView 扩展类的思路每次新建窗体都复制一遍CellPainting代码太蠢我后来把它封装成一个扩展类DataGridViewMergeExtension核心接口就两个EnableMerge(int[] columnIndexes)和RefreshMerge()。内部持有_mergeRanges缓存和_mergedColumns数组事件绑定在EnableMerge时完成。这样业务窗体里只需要一行dgv.EnableMerge(new[]{0,1})数据变化后调dgv.RefreshMerge()即可。封装时注意事件解绑避免窗体关闭后事件还挂着导致内存泄漏。public static class DataGridViewMergeExtension { private static readonly DictionaryDataGridView, MergeContext _contexts new DictionaryDataGridView, MergeContext(); public static void EnableMerge(this DataGridView dgv, int[] columns) { var ctx new MergeContext { Columns columns }; _contexts[dgv] ctx; dgv.CellPainting (s, e) OnCellPainting(dgv, e); dgv.CellClick (s, e) OnCellClick(dgv, e); dgv.RowHeightChanged (s, e) RefreshMerge(dgv); RefreshMerge(dgv); } public static void RefreshMerge(this DataGridView dgv) { if (!_contexts.TryGetValue(dgv, out var ctx)) return; ctx.Ranges CalcMergeRanges(dgv, ctx.Columns); dgv.Invalidate(); } // OnCellPainting / OnCellClick / CalcMergeRanges 实现略 }这个封装里MergeContext存列数组和区间缓存_contexts用 DataGridView 实例做 key。注意静态字典会导致控件无法被 GC 回收窗体关闭时要调DisableMerge移除。如果嫌麻烦可以把扩展状态挂在dgv.Tag上但类型安全差一些。5.2 验证合并是否正确三个自检动作写完合并逻辑后我习惯做三个验证。第一边界验证构造只有一行、两行相同、两行不同、首尾相同中间不同这几种数据看合并块是否只在连续相同处出现。第二交互验证点击合并块中间行看选中范围是否覆盖整个块滚动到列表底部再滚回来看合并块是否错位。第三性能验证灌 5000 行数据快速拖动滚动条看是否有明显卡顿。这三个动作走完基本能覆盖 90% 的翻车场景。验证项操作预期结果单行数据只绑定一行不产生合并块正常显示首尾相同第一行和最后一行值相同中间不同不合并因为不连续全列相同所有行值一致合并成一个大块快速滚动拖动滚动条到底再回顶合并块位置和内容不变点击中间行点击合并块非起始行整个块被选中5.3 一个我常备的调试习惯从那以后我每次做合并单元格都强制先在CalcMergeRanges里加一行日志把每个区间的 start 和 end 打到Debug.WriteLine绑定数据后先看日志确认区间对不对再看界面。因为合并的 bug 十有八九出在区间计算上界面绘制反而是最不容易错的。这个习惯帮我省了至少三次在演示前夜抓瞎的时间。希望帮到你。本文还有配套的精品资源点击获取