
简介这份资源面向使用 C# 开发 Windows Forms 应用的开发者聚焦 DataGridView 控件的单元格合并问题适合已掌握基础控件操作、希望实现复杂表格布局的中级学习者。内容围绕逻辑合并与视觉合并两种思路展开重点讲解通过重写 OnPaint 事件自定义绘制单元格、设置对齐方式、禁用自动调整列宽以及行头主副表头合并等关键实现细节帮助读者理解合并后数据仍需独立存储的原因。资源包为 rar 格式共 22 个文件约 44KB包含 6 个 cs 源码文件、3 个 exe 可执行程序、2 个 resx 与 2 个 resources 资源文件以及 csproj、sln、suo 等工程配置另附 pdb 调试文件与 txt 说明构成一个可直接运行的完整示例工程。目前已有 344 人学习下载读者可借助源码对照调试快速掌握合并单元格的绘制流程与常见排错思路并将其迁移到标题行、汇总行等实际业务场景中。1. DataGridView合并单元格为什么你的表格看起来总像半成品做过 WinForms 的人都有过这种体验数据从数据库里查出来DataGridView 一绑行是行、列是列功能没问题但界面就是透着一股“半成品”的味道。尤其是当同一张订单号出现五次、同一个部门重复十几行的时候用户第一眼看到的不是数据而是一堆冗余的重复文字。DataGridView合并单元格要解决的正是这个视觉噪音问题——把纵向或横向上内容相同的相邻单元格合并成一个让表格从“能看”变成“好读”。这个技术点适合谁适合正在用 WinForms 做报表、做台账、做审批流界面的开发者。它不复杂但坑不少合并之后选中行为会变、排序会乱、重绘会闪、编辑会错位。这篇笔记就按“先讲清原理和选型再给可复现的代码最后把踩过的坑摊开”的顺序走一遍目标是让你看完就能在自己的项目里落地而不是停留在“知道有这么回事”。2. 合并单元格的底层逻辑不是视觉把戏是绘制与命中测试的重写2.1 为什么 DataGridView 原生不支持合并DataGridView 从 .NET 2.0 开始就是“单元格矩阵”模型每个单元格独立存在有自己的边界、样式、值和编辑状态。它没有提供类似 Excel 那种Merge属性根本原因是它的设计目标偏向“数据绑定 虚拟化”而不是“报表排版”。你绑一个 DataTable它就老老实实一行一行画哪怕相邻行的值完全一样。所以合并单元格这件事在 DataGridView 里不是“设置一个属性”而是要在两个层面动手绘制层和行为层。绘制层决定合并后长什么样行为层决定合并后选中、编辑、排序怎么响应。只做绘制不做行为就会得到一个“看起来合并了但点上去还是散”的假合并。常见做法是继承DataGridView写一个自定义控件重写OnCellPainting和OnCellFormatting再配合CellMouseDown做命中测试。也有更轻量的方案用CellPainting事件在外部挂逻辑不继承控件。两种都能用区别在于复用性和维护成本。2.2 合并的两种方向与判定规则合并分纵向合并和横向合并。纵向合并是把同一列里连续相同的值合成一个单元格横向合并是把同一行里连续相同的值合成一个。实际项目里纵向合并用得最多因为报表通常按“分组字段”纵向展开。判定规则听起来简单——“相邻且相等就合并”——但真正写的时候要明确三件事比较范围只比较当前列还是多列联合比较比如“部门 小组”都相同才合并那就得把两列的值拼起来做 key。比较方式字符串比较用还是Equals大小写敏感吗空值DBNull和空字符串算不算相等这些不统一合并结果就会时对时错。边界处理第一行和最后一行没有“上一行”或“下一行”循环里要单独处理否则容易数组越界。我一般会先写一个GetMergeKey(rowIndex, columnIndex)方法把需要参与比较的列值拼成一个字符串再在绘制时用这个 key 判断上下行是否一致。这样逻辑集中改规则只改一个地方。2.3 绘制合并单元格的最小代码骨架下面这段代码是继承DataGridView后的核心绘制逻辑只做纵向合并横向合并思路相同改一下循环方向即可。protected override void OnCellPainting(DataGridViewCellPaintingEventArgs e) { // 只处理数据行表头行不合并 if (e.RowIndex 0 || e.ColumnIndex 0) return; // 判断当前单元格是否属于某个合并区间的“起始行” bool isFirst IsFirstCellInMerge(e.RowIndex, e.ColumnIndex); bool isMerged IsMergedCell(e.RowIndex, e.ColumnIndex); if (!isMerged) { base.OnCellPainting(e); return; } if (isFirst) { // 起始行计算合并后的矩形区域并一次性绘制 int span GetMergeSpan(e.RowIndex, e.ColumnIndex); Rectangle rect GetMergedRect(e.RowIndex, e.ColumnIndex, span); using (Brush backBrush new SolidBrush(e.CellStyle.BackColor)) using (Brush foreBrush new SolidBrush(e.CellStyle.ForeColor)) { e.Graphics.FillRectangle(backBrush, rect); e.Graphics.DrawString( e.Value?.ToString() ?? string.Empty, e.CellStyle.Font, foreBrush, rect, new StringFormat { Alignment StringAlignment.Center, LineAlignment StringAlignment.Center }); } e.Handled true; } else { // 非起始行不绘制内容只填充背景避免文字重复 using (Brush backBrush new SolidBrush(e.CellStyle.BackColor)) { e.Graphics.FillRectangle(backBrush, e.CellBounds); } e.Handled true; } }逻辑说明IsMergedCell判断当前格是否在合并区间内IsFirstCellInMerge判断它是不是区间的第一行。只有第一行负责画文字其余行只填背景。GetMergedRect把起始行到结束行的CellBounds合并成一个矩形。e.Handled true告诉控件“这个格我自己画了你别再画一遍”否则会出现文字重影。参数说明e.CellStyle.BackColor和ForeColor直接沿用当前样式保证合并后颜色和未合并时一致。StringFormat里设了居中对齐如果你的报表需要左对齐改Alignment即可。GetMergeSpan返回的是向下连续相同的行数实现时用一个 while 循环往下比遇到不同就停。这段代码跑起来界面上的重复值就消失了。但别急着收工——选中行为还没处理点一下合并区域你会发现只有第一行响应下面几行像“死”了一样。3. 让合并后的单元格“活”起来选中、编辑与数据绑定的配合3.1 命中测试点击合并区域任意位置都选中整块默认情况下DataGridView 的选中是按单元格走的。合并之后用户点第二行控件仍然认为他点的是第二行那个“空壳”而不是合并后的整体。要解决这个问题得重写OnCellMouseDown把点击坐标映射到合并区间的起始行。protected override void OnCellMouseDown(DataGridViewCellMouseEventArgs e) { if (e.RowIndex 0 e.ColumnIndex 0 IsMergedCell(e.RowIndex, e.ColumnIndex)) { int firstRow GetFirstRowOfMerge(e.RowIndex, e.ColumnIndex); // 把选中焦点转移到合并区间的起始行 this.CurrentCell this[e.ColumnIndex, firstRow]; // 清除原有选中选中整个区间 this.ClearSelection(); int span GetMergeSpan(firstRow, e.ColumnIndex); for (int i 0; i span; i) { this[e.ColumnIndex, firstRow i].Selected true; } return; } base.OnCellMouseDown(e); }逻辑说明GetFirstRowOfMerge向上回溯找到这个合并区间的第一行。然后把CurrentCell设过去再循环把区间内所有单元格标记为选中。这样用户点哪里整块都亮起来视觉反馈才一致。参数说明ClearSelection()不能省否则之前选中的其他区域会残留。CurrentCell设成第一行是为了让键盘上下键从合并块顶部开始移动符合直觉。3.2 编辑状态下的合并冲突与规避合并单元格和编辑功能天生有冲突你合并了五行用户双击第二行想改值改的是哪个如果允许编辑改完第二行的值合并条件可能就不成立了界面立刻“裂开”。我的做法是合并列一律设为只读。在OnCellBeginEdit里判断如果当前格属于合并区间直接e.Cancel true。如果业务上确实需要编辑那就不要合并改用“分组标题行”的样式来呈现而不是真合并。protected override void OnCellBeginEdit(DataGridViewCellCancelEventArgs e) { if (IsMergedCell(e.RowIndex, e.ColumnIndex)) { e.Cancel true; // 合并单元格禁止直接编辑 return; } base.OnCellBeginEdit(e); }逻辑说明CellBeginEdit是编辑开始前的最后一道闸在这里取消最干净不会留下半编辑状态。参数上不需要额外配置e.Cancel设为 true 即可。3.3 排序与数据刷新后的重算时机DataGridView 支持点击列头排序排序一发生行的顺序变了原来的合并区间全部失效。如果你不在排序后重新计算合并关系界面就会错乱——该合并的没合并不该合并的粘在一起。正确的时机是挂在Sorted事件和DataSourceChanged事件上在这两个节点重新跑一遍合并判定。如果你用的是自定义控件可以在OnSorted里调用Invalidate()触发重绘绘制逻辑会重新计算。protected override void OnSorted(EventArgs e) { base.OnSorted(e); // 排序后清除缓存的合并区间强制重算 _mergeCache.Clear(); this.Invalidate(); }逻辑说明_mergeCache是我用来存合并区间的一个字典key 是“列号 起始行”value 是跨度。排序后清空下次绘制时重新算。Invalidate()触发整个控件重绘保证界面和最新数据一致。参数说明如果你的数据量很大全量Invalidate()可能造成闪烁可以改用InvalidateColumn只刷新合并列。但大多数业务表格行数在几千以内全量刷新足够。4. 避坑与排查合并单元格最常见的五类翻车现场4.1 现象合并后文字垂直不居中偏上或偏下原因GetMergedRect计算出的矩形高度是多个CellBounds相加但DrawString的StringFormat没有设LineAlignment默认是顶部对齐。行数一多文字就贴在合并块顶部。解决在StringFormat里同时设Alignment StringAlignment.Center和LineAlignment StringAlignment.Center。如果还有偏差检查rect是否包含了行与行之间的网格线高度必要时把rect.Height减去 1 像素。4.2 现象滚动表格时合并块闪烁文字忽隐忽现原因CellPainting里每次都用new SolidBrush创建画刷GDI 对象频繁分配回收加上滚动时重绘频率高就会闪。另外e.Handled true如果漏设控件会在你画完之后再画一遍造成重影和闪烁。解决把画刷提到控件级别做缓存或者用SystemBrushes里的系统画刷。确保每个分支都设了e.Handled true。如果还闪开双缓冲在构造函数里设this.DoubleBuffered true。4.3 现象合并列参与排序后合并关系错乱原因排序改变了行的物理顺序但合并缓存没有失效绘制时还在用旧的区间数据。解决在OnSorted和DataSourceChanged里清空缓存并Invalidate()。如果你用的是外部CellPainting事件而不是继承控件就在ColumnHeaderMouseClick里手动触发重算。4.4 现象合并单元格导出 Excel 后合并丢失原因DataGridView 的合并只是绘制层面的视觉合并数据层仍然是多行独立。导出时如果直接遍历单元格写 Excel写出来还是散开的。解决导出逻辑里复用同一套合并判定在写 Excel 时调用 Excel 的Range.Merge()。也就是说合并规则要抽成一个公共方法界面和导出共用避免两处逻辑不一致。4.5 现象合并后键盘上下键跳转异常原因CurrentCell停在合并块的第一行按向下键时控件按单元格矩阵移动会跳到合并块内部的“空壳”行视觉上像卡住了。解决重写OnKeyDown在方向键按下时判断下一个单元格是否属于当前合并块如果是就跳过整个块直接落到块外的下一行。这个逻辑和命中测试类似都是把“视觉块”映射到“逻辑行”。5. 进阶技巧用条件格式让合并单元格自动适应业务变化合并单元格做完之后真正让它好用的是一个容易被忽略的点合并条件要能跟着业务数据动态变。比如你今天按“部门”合并明天业务说要看“部门 项目”联合合并如果判定逻辑写死在绘制代码里改起来就痛苦。我的习惯是把合并规则抽成一个委托或者接口让调用方注入。下面是一个简化版的规则注入示例public interface IMergeRule { // 返回 true 表示这两行在指定列上应该合并 bool ShouldMerge(DataGridViewRow rowA, DataGridViewRow rowB, int columnIndex); } public class DepartmentMergeRule : IMergeRule { public bool ShouldMerge(DataGridViewRow rowA, DataGridViewRow rowB, int columnIndex) { // 只对“部门”列做合并其他列不合并 if (columnIndex ! 0) return false; string a rowA.Cells[0].Value?.ToString() ?? string.Empty; string b rowB.Cells[0].Value?.ToString() ?? string.Empty; return string.Equals(a, b, StringComparison.OrdinalIgnoreCase); } }逻辑说明ShouldMerge接收相邻两行和列号返回是否合并。绘制逻辑里不再写死比较规则而是调用这个接口。参数上StringComparison.OrdinalIgnoreCase表示忽略大小写如果你的业务要求区分大小写改成Ordinal。有了这层抽象切换合并策略只需要换一个实现类。更进一步你可以在运行时根据用户选择的“分组维度”动态切换规则界面立刻重绘不用改一行绘制代码。验证合并是否正确我一般用两个手段一是拿一组边界数据——第一行相同、最后一行相同、中间断开、全相同、全不同——跑一遍看合并块数量对不对二是导出 Excel 后和界面比对因为 Excel 的合并是数据层真合并能反推出你的判定逻辑有没有漏。最后说个我自己的教训早期做合并单元格我总想一步到位把绘制、选中、编辑、导出全写完再测结果一个命中测试的坐标偏移查了一下午。后来改成“先只做绘制确认视觉对了再加选中再加导出”每加一层就用手点一遍反而快得多。合并单元格这事视觉和逻辑是两条线分开推进别搅在一起。希望帮到你。本文还有配套的精品资源点击获取