
简介面向 C# WinForms 开发者的 DataGridView 单元格合并实现资源适合在报表表头、统计汇总行等场景需要跨行跨列展示数据的开发者。压缩包以 Visual Studio 工程形式组织演示了两种主要的合并思路一种是通过调整列的 Visible 属性实现逻辑合并另一种是通过重写 OnPaint 事件、自定义绘制矩形覆盖多个单元格实现视觉合并后者兼顾显示效果与数据独立性且涉及行头合并、单元格内容对齐、AutoSizeColumnsMode 属性调整等实用细节动手前无需引入第三方控件。资源共 22 个文件以 6 个 cs 源文件、2 个 resx 资源文件、3 个 exe 可执行文件为主配套工程文件、配置与资源文件一应俱全压缩包仅 44KB结构紧凑方便直接加载调试。目前已有 345 人学习下载说明该需求在桌面表格开发中具有较高的普适性。通过研读这套源码可以快速掌握 DataGridView 单元格合并的完整实现路径了解自定义绘制中关于矩形覆盖、焦点框绘制及数据存储的关键处理为后续开发复杂表格界面打好基础。1. DataGridView合并单元格为什么这个需求总让报表开发卡到最后一天做WinForms报表的应该都有过这种经历客户拿来一张带合并单元格的Excel样板要求DataGridView还原同样的效果验收前一晚才发现这控件根本不支持原生合并。网上搜到的代码贴上去要么合并区域是一片空白要么点击时行选不中要么一滚动边框就错位。DataGridView的单元格合并本质上是拿绘制和命中测试去模拟“真合并”水比想象中深。这篇文章按我自己的落地顺序讲先讲清为什么控件不提供Merge方法、三条实现路线的取舍再给出一套能扛住排序、刷新和滚动的绘制级合并方案最后把5类高频翻车点逐个拆开。适合刚接手报表功能、被这个需求卡住以及想评估“到底值不值得自己写”的开发者。2. 先搞清底层逻辑DataGridView为什么不给你一个Merge方法2.1 控件的网格模型与行单元格的固有结构DataGridView的核心数据模型是“行×列”的二维网格每一行的Cells集合在内存里是独立对象行高、样式、绑定值都是按行维护的。这种结构决定了它无法像Excel那样把一个矩形区域的单元格合并成一个跨行跨列的大单元格——因为合并意味着“多个Cells对象要共享同一个显示区域”而这套模型里根本没有这样的存储单元。很多人第一次接触时都会问为什么微软不直接加个Merge属性原因其实不难理解合并单元格会带来一连串连锁问题——跨行合并后的排序怎么处理、编辑时触发的是哪个Cell的验证事件、合并区域的键盘导航怎么走、虚拟模式下的行索引怎么映射。这些如果靠原生属性支持复杂度远超收益所以官方一直没有正面支持。我早期做项目时也试图通过反射去调用内部接口翻车的概率极高。这个思路建议直接放弃因为它依赖特定版本的内部实现换一个补丁版本就可能失效。可靠的做法只有两条路自定义绘制模拟外观或者换第三方控件。2.2 三条实现路线绘制模拟、封装继承、第三方控件先看路线一基于CellPainting事件做绘制模拟。思路是让合并区域里的“从属单元格”不绘制背景和边框绘制成和主单元格完全一致的样子再自己画合并后的边框。这套方案不动控件内核只改外观所以排序、过滤、滚动等原生行为都不受影响是绝大多数自研方案的根基。路线二是继承DataGridView派生控件把合并逻辑封装成可复用的合并管理器。相比纯粹在事件里写绘制代码封装的好处是能把合并信息的计算、绘制、命中测试集中管理避免事件代码越写越长。它的缺点是需要对基类事件的作用机制有一定理解开发周期比第一种长但长期维护成本更低。路线三是直接引入第三方控件的合并功能。某些成熟的收费及开源表格控件把跨行跨列合并、导出打印都做好了开箱即用。代价也很直接引入依赖、学习成本、License边界。如果项目里已经引用了这类控件直接用它的合并属性最省事如果只是为了一个合并功能就引入整个控件库个人不建议。2.3 选型决策什么场景选什么方案给一个可判断的参考需求只是“报表展示时把相同值的列竖向合并”数据量在几百到几千行选路线一足够如果同一个项目里有多个页面都要用合并且后续可能加排序、虚拟模式选路线二如果对打印和导出的合并还原要求极高、且团队没有精力去维护绘图代码再考虑第三方。还有一个容易被忽略的判断点编辑场景。合并单元格的语义是“只读展示”一旦用户需要双击编辑这个区域绘制模拟方案会面临大面积返工因为它根本没在数据层合并单元格。所以需求评审时先确认清楚要的是“看起来合并了”还是“真的合并成一个单元格”。前者是绘制问题后者是数据模型问题两者难度差一个量级。3. 用绘制模拟实现跨行合并核心代码与命中测试3.1 基础绘制合并单元格的背景与边框开始写代码前先明确一个原则合并信息要被集中管理而不是散落在Paint事件里用if判断。我习惯先定义一个合并区域描述类每个区域记录它的起始行、起始列、跨行数、跨列数以及主单元格的值。public class MergedRegion { public int StartRow; // 起始行索引 public int StartCol; // 起始列索引 public int RowSpan; // 跨行数含起始行 public int ColSpan; // 跨列数含起始列 public string DisplayText; // 合并后显示的主文本 }这个类不做任何绘制工作只负责描述“哪里需要合并”。合并管理器里用一个List 保存全部区域每次数据刷新时重建。接下来是绘制核心挂在DataGridView的CellPainting事件里private void dgv_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { MergedRegion region mergedManager.FindRegion(e.RowIndex, e.ColumnIndex); if (region null) return; // 如果当前单元格不是合并区域的起始单元格 if (e.RowIndex ! region.StartRow || e.ColumnIndex ! region.StartCol) { // 属于内部区域用与主单元格一致的背景覆盖并隐藏边框 using (SolidBrush brush new SolidBrush(dgv.Rows[region.StartRow].DefaultCellStyle.BackColor)) { e.Graphics.FillRectangle(brush, e.CellBounds); } e.Handled true; return; } // 起始单元格绘制背景、文本、外框 Rectangle mergedRect mergedManager.GetMergedBounds(region, dgv); using (SolidBrush brush new SolidBrush(dgv.Rows[region.StartRow].DefaultCellStyle.BackColor)) { e.Graphics.FillRectangle(brush, mergedRect); } TextRenderer.DrawText(e.Graphics, region.DisplayText, dgv.DefaultCellStyle.Font, mergedRect, dgv.DefaultCellStyle.ForeColor, TextFormatFlags.VerticalCenter | TextFormatFlags.HorizontalCenter); ControlPaint.DrawBorder(e.Graphics, mergedRect, Color.Black, ButtonBorderStyle.Solid); e.Handled true; }这里的关键点是GetMergedBounds计算合并后的实际像素矩形从起始单元格的Bounds扩展把跨行和跨列的宽高累加上去。注意行高是变化的所以计算时不能用固定的RowHeight必须逐一累加内部行的实际高度。FindRegion负责判断当前单元格是否属于某个合并区域并且要在矩形包含判断时前后留1像素容差避免边框处点击时判定失败。3.2 命中测试解决点击时“选不中”的问题绘制模拟最坑的地方在这里外观上是合并的但点击合并区域的内部单元格时DataGridView仍然按原有逻辑选中了那个“从属单元格”。用户看到的反馈是——明明合并了一整列点击中间位置却只高亮了半行或者弹出事件拿到的行索引不对。解决方式是重新实现命中测试让整个合并区域的点击都落到起始单元格上protected override void OnCellMouseDown(DataGridViewCellMouseEventArgs e) { if (e.RowIndex 0 e.ColumnIndex 0) { MergedRegion region mergedManager.FindRegion(e.RowIndex, e.ColumnIndex); if (region ! null (e.RowIndex ! region.StartRow || e.ColumnIndex ! region.StartCol)) { // 将当前单元格切换为合并区域的起始单元格 dgv.CurrentCell dgv.Rows[region.StartRow].Cells[region.StartCol]; dgv.BeginEdit(false); return; } } base.OnCellMouseDown(e); }这段逻辑的本质是“事件重定向”检测到点击位置在合并区域的非起始单元格时手动把当前单元格切到起始格。注意这里要同时处理CellClick、CellDoubleClick否则双击编辑时拿到的还是错的行对象。SelectionChanged事件里也要判断如果程序代码通过索引直接选中了从属单元格要把它纠正回起始格。3.3 列方向的横向合并怎么处理横向合并跨列比跨行合并多一个问题列宽在用户拖动时变化合并区域的像素范围随之改变绘制时如果每次重算不彻底会出现边界重叠或文本截断。我处理横向合并的方式和跨行类似但GetMergedBounds要额外累加列宽而不是行高。另外一个容易被忽略的问题是ToolTip。合并区域的内部单元格悬停时DataGridView默认按每个单元格分别触发CellToolTipTextNeeded。如果你只给主单元格设置了提示文本其余格子悬停时提示会消失。处理方式是在这个事件里把区域内所有单元格的提示统一返回主单元格的文本。还要注意ColumnHeadersDefaultCellStyle和RowHeadersDefaultCellStyle。合并区域如果跨越了行头或列头绘制时这两个区域是特殊Phasee.PaintParts的取值需要做额外判断。最简单的避坑方式是只对DataGridView的DataGridViewElementStates.None状态下的单元格做合并绘制表头区域一律不处理。4. 动态合并管理器数据刷新、排序与滚动的重算机制4.1 合并信息的数据源设计从DataTable到Region的映射一个常见的错误是让合并管理器去遍历DataGridView里的每一行每一列实时判断相邻值是否相同。这在小数据量下还能勉强工作数据一多就成了性能灾难。我的做法是把合并计算放在数据层用DataTable的列值分组来生成Region。核心思路是先确定需要按哪一列做跨行合并比如日期列然后对DataTable按该列排序或保持原序顺序扫描每一行相邻行值相同的连续区间就是一个MergedRegion。计算过程只依赖数据值完全不碰UI对象速度极快。public ListMergedRegion BuildRegions(DataTable table, string mergeColumn, int startColIndex) { ListMergedRegion regions new ListMergedRegion(); if (table.Rows.Count 0) return regions; int startRow 0; object currentValue table.Rows[0][mergeColumn]; for (int i 1; i table.Rows.Count; i) { object nextValue table.Rows[i][mergeColumn]; if (!object.Equals(currentValue, nextValue)) { if (i - startRow 1) // 只有连续多行相同才合并 { regions.Add(new MergedRegion { StartRow startRow, StartCol startColIndex, RowSpan i - startRow, ColSpan 1, DisplayText currentValue?.ToString() ?? }); } currentValue nextValue; startRow i; } } // 处理最后一段 if (table.Rows.Count - startRow 1) { regions.Add(new MergedRegion { StartRow startRow, StartCol startColIndex, RowSpan table.Rows.Count - startRow, ColSpan 1, DisplayText currentValue?.ToString() ?? }); } return regions; }这段代码的价值在于它把“哪些行合并”这个决策完全从UI里剥离了。DisplayText直接来自第一行该列的值避免了重复查询。要注意object.Equals的比较逻辑对DBNull要多一层判断否则空值列会出现所有空行被错误合并的情况。4.2 排序与过滤后的索引漂移DataGridView的排序是“SortMode自动排序”排序后RowIndex会跟着数据重新排列。这时如果合并Region还是按DataTable里上一次的位置记录的就会错位——表现为合并区域跑到别的行上面去。我处理这个问题的方式是用“数据版本号重建触发器”。一旦DataGridView的Sorted事件、DataSource的ListChanged事件、或过滤条件发生变化就重新调用BuildRegions重建Region列表。重建要放在BeginUpdate/EndUpdate之间避免绘制时频繁重入。这里有个性能细节排序事件在用户点击列头后立刻触发同时用户的鼠标可能还在快速连点列头。每次重建Region虽然快但伴随的Invalidate会重绘整个网格区域在数据量上到几千行时会有可感知的闪烁。解决方式是用一个延迟计时器排序事件后10毫秒内重建一次期间重复触发不重复计算。4.3 滚动边界与Clip绘制滚动是绘制模拟方案最容易翻车的场景。DataGridView是局部重绘机制滚动时只重绘新暴露出来的区域。如果你的CellPainting代码每次都“相信自己拿到的是完整数据”在合并区域跨越可见区域边界时就会画出半个合并单元格的残影。我的落地方案是绘制MergedRegion时先用Graphics.SetClip把绘制区域裁剪到当前可见的ClientRectangle再绘制合并矩形。public Rectangle GetClippedMergedBounds(MergedRegion region, DataGridView dgv, Rectangle clipRect) { Rectangle fullBounds GetMergedBounds(region, dgv); return Rectangle.Intersect(fullBounds, clipRect); }这行代码能挡住大半滚动时的绘制错位。另一个滚动相关问题是垂直滚动条拖动到中间时Region的StartRow可能在可视区之外其内部单元格是可见的。此时Paint事件里FindRegion要正常工作依赖的是绝对行索引而不是可视行索引所以Region创建时必须存原始RowIndex而不是可视偏移量。还有个小细节滚动条出现在底部时DataGridView会调整行高的显示方式Cells的Bounds可能出现细微的1像素偏移。遇到边框线在滚动后错位最直接的办法是把CellPainting里的坐标全部基于e.CellBounds重新换算而不是缓存上一次的绝对坐标。5. 避坑DataGridView合并单元格最常见的五个翻车点5.1 翻车一合并区域出现黑色/灰色填充块现象合并区域内部不是预期的背景色而是一块块深浅不一的黑灰块像被橡皮擦蹭过。原因合并区域内的从属单元格没有关闭自身绘制它们根据各自样式绘制了背景和网格边框Paint事件又叠加了第二层填充两个颜色叠在一起形成花块。解决在CellPainting里对从属单元格e.Handled true并完整绘制统一背景同时设置这些单元格的DefaultCellStyle.BackColor与主单元格一致且Style.SelectionBackColor也保持一致否则选中状态下背景又会跳回默认蓝灰色。5.2 翻车二点击合并区域中间CurrentCell指向了不可见的从属行现象用户点击合并区域中部状态栏或代码里显示的当前行号是合并区间内靠后的某一行而不是起始行。原因命中测试逻辑没有覆盖所有入口事件或者只处理了CellMouseDown没有处理CurrentCell被代码直接赋值的情况。解决统一走一个RedirectToStartCell方法把CellMouseDown、CellClick、CellDoubleClick三个入口都接上。再在CurrentCellDirtyStateChanged里加一次验证若CurrentCell不属于起始格则强制纠正。注意BeginEdit调用要在纠正之后否则会弹出空白编辑框。5.3 翻车三滚动一半时合并边框断裂、出现多段边框现象向下滚动几行再向上滚回来合并单元格的边框出现了横向的断线像被切过一刀甚至出现双层边框。原因滚动时不同行的绘制时机不同顶部的行先重绘、底部的行后重绘如果GetMergedBounds计算依赖的行高数据在某两批绘制之间发生了变化比如滚动条引起行高重新计算那么两次画的矩形对不齐。解决在GetMergedBounds里不等分取值每次都累加当前行实际Height。并且绘制边框时不要用Rectangle的外框画而是逐线条画上边线取首行的Y下边线取末行的Bottom左右线取首尾列的外边界。5.4 翻车四排序/筛选后合并区域跟数据错位现象点列头排序后合并列的值还是连续的但合并区域画到了完全不相干的行上看起来“张冠李戴”。原因Region列表是在数据加载时生成的排序事件后DataGridView的行索引变化了但Region没有同步重建。解决把Region的重建挂在Sorted事件和数据源的ListChanged事件上且重建时基于排序后的DataView重新扫描值如果用了BindingSource请拿BindingSource的排序视图做值扫描而不是原始Table因为原始Table的顺序在排序后不会变。5.5 翻车五DataTable空值列把不相关的空行合并成一个巨块现象某一列大部分行都是DBNull结果这些行全部被合并成一个超大的单元格。原因object.Equals(DBNull.Value, DBNull.Value)始终为true连续空值被判定为“值相同”而合并。解决在BuildRegions的比较逻辑前把DBNull统一转换成一个哨兵字符串比如[NULL]只有显式指定“空值也合并”的列才放行。或者只对已经确认非空的列启用合并计算器。6. 进阶打印与导出时的合并还原以及一个值得养成的习惯一个普遍容易低估的问题是DataGridView画面上的合并解决了但打印预览和Excel导出时合并效果又打回原形。Print事件默认按行按列逐个Cell绘制绘制模拟的合并区域在打印输出时不会被保留因为打印走的是独立的绘制流程。处理方式有两种一是打印时改用DataGridView自身的一个隐藏副本把合并区域写成真实存在的合并单元格样式再绘制二是导出到Excel时直接操作导出对象来合并WorkSheet的Range区域。如果你只需要打印我的做法是PrintDocument的PrintPage事件里先遍历合并Region列表用Graphics.FillRectangle和DrawString直接绘制合并矩形文本再绘制普通单元格。这样绕开了DataGridView的行列绘制流程打印效果反而更稳定。注意打印里没有CellBounds概念所有坐标单位要乘以打印比例用e.Graphics.DpiX和PixelPerInch换算。说到性能预算这里给出一个我自己反复使用并验证过的底线当行数超过2000行时每次Invalidate都触发所有可见行的CellPainting绘制合并背景是重复劳动。把绘制成本降低90%的做法是维护一个“脏区域列表”数据变化时代码只标记受影响的Region为脏Paint时先检查当前e.RowIndex是否在脏区域范围内不在就跳过合并绘制逻辑直接走默认绘制。最后分享一个养成的习惯在任何正式的合并代码项目里第一件事先把DataGridView的DoubleBuffered属性用反射打开。如果不打开滚动时的闪烁会让用户觉得这功能是“半成品”而它本身只是两行代码的事。另外每次修改合并边界逻辑记得在排序、拖动列宽、滚动到最底部这三个动作上反复点几遍——这里出问题永远不会报错只会画错。希望帮到你。本文还有配套的精品资源点击获取