☰
C# DataGridView 合并单元格:CellPainting 跨行自绘
2026/11/6 2:55:09 网站建设 项目流程

简介:针对 C# Windows Forms 开发者的一份 DataGridView 单元格合并示例,重点解决表格标题行、汇总行及多列视觉合并等布局问题,适合已掌握基础控件操作、希望深入自定义绘制与事件处理的中级读者。压缩包共 22 个文件,约 44KB,含 6 个 cs 源码、3 个 exe 可执行程序及 resx、csproj、sln 等工程资源文件,另有 pdb、settings 调试配置,可直接运行对照。内容围绕逻辑隐藏与重写 OnPaint 视觉合并两条思路,涉及单元格对齐、AutoSizeColumnsMode 设置、行头主副表头合并及自定义 DataGridViewCell 放置控件等要点。已有 349 人学习,可帮助理解合并后的数据存储、绘制边界与界面适配,并据此搭建可维护的复杂表格布局。

1. 从一张总是错位的出库单明细说起:C# DataGridView 合并单元格到底难在哪

去年接手一个仓储出库模块,明细表格里同一个订单号会连着占三到五行商品,运营的要求很朴素:订单号那一列合并成一格,看起来像 Excel 里那样。我当时想当然地觉得DataGridView应该有Merge属性,翻完文档才发现它压根没有——C# DataGridView 合并单元格这个搜索词,就是那天下午我连着敲了三遍的东西。

第一次的做法很丢人:把同一个订单号下面几行的Value清空,再把边框设成None。静态截图能骗过人,鼠标一滚、表格一排序,竖线断成一截一截,选中时还露出半截高亮块,当场翻车。

这套东西真正要解决的问题,是把“视觉上的一格”跟“逻辑上的多行”对上账:数据还是那么多行,只是画的时候由第一行负责画文本、最后一行负责画下边框、中间行什么都不画。它适合做 ERP、进销存、报表预览这类 WinForms 桌面项目,一行代码都不想碰底层控件的同学可以先绕道。

2. 技术选型与类结构:为什么最后落在 CellPainting 自绘这条路

2.1 WinForms 里三种合并思路的取舍

在 WinForms 生态里想让表格出现跨行格子,基本上就三条路,没有第四条。

第一条是“假合并”:把下面几行的值置空,GridColor或者单元格BorderStyle关掉,靠视觉欺骗。代码量最小,十分钟能写完,问题是它经不起任何交互——排序后数据行顺序变了,被清空的那几行跟着乱跑;选中区间时,中间那几行的空单元格依然是独立的选中块,蓝色高亮一格一格跳;复制粘贴出去,订单号只剩一行有值,下游系统直接报数据缺失。我只在那种纯展示、行数固定、永远不排序的熔断看板上用过这一招。

第二条是CellPainting自绘,也是这篇的重点。思路是接管单元格的绘制过程,告诉DataGridView:“这个格子我自己画,你别管。”好处是数据模型完全不动,DataSource里该有几行还是几行,导出、复制、绑定逻辑全都不受影响;代价是绘制代码要自己写,边框、背景、选中态、文本居中这些原本框架帮你做的事,得一件件补回来。

第三条是彻底重写控件,用TableLayoutPanel或者自绘Control从零搭一个表格。这条路我在做打印预览排版的时候走过一次,能做出完全可控的效果,但排序、筛选、编辑、键盘导航、无障碍支持全都要自己实现,一个中型业务表格没有两三千行代码下不来。除非是要沉淀到公司控件库,否则不划算。

方案实现成本视觉效果排序/滚动稳定性编辑体验适用场景
清空相邻值 + 隐藏边框低一般,竖线易断差差只读、固定行序的小表格
CellPainting 自绘中好,接近 Excel需重算区间需拦截编辑常规业务表格
自定义控件重写高完全可控好好控件库、复杂排版

选第二条的关键判断是:数据要不要参与业务逻辑。只要这张表还要导出 Excel、还要做行选中联动、还要被下游接口消费,就不能动Value,只能动绘制。

2.2 一个可复用的合并辅助类怎么分层

既然是绘制层的事,那就应该把它从窗体的Form1.cs里剥出来,做成一个跟业务无关的辅助类。我一般的分层是三块:数据模型、区间扫描器、渲染器,最后包在一个GridCellMerger里,对外只暴露“绑定哪一列”和“什么时候重算”两个入口。

先看数据模型,一个合并区间本质上就是“某列从第几行到第几行”:

// MergeRange.cs // 描述某一列上的一段连续合并区间 public sealed class MergeRange { public int ColumnIndex { get; init; } public int FirstRow { get; init; } // 区间首行,负责画文本 public int LastRow { get; init; } // 区间末行,负责画下边框 public int RowSpan => LastRow - FirstRow + 1; // 判断某行是否落在本区间内 public bool Contains(int rowIndex) => rowIndex >= FirstRow && rowIndex <= LastRow; // 首行:唯一负责绘制文本的那一行 public bool IsHeader(int rowIndex) => rowIndex == FirstRow; // 末行:唯一负责绘制底边线的那一行 public bool IsTail(int rowIndex) => rowIndex == LastRow; }

FirstRow和LastRow这两个字段是整个方案的核心契约。绘制的时候,CellPainting是逐单元格触发的,框架不会告诉你“这一格属于哪一段合并”,所以必须靠外部记录反查。契约一旦定死——文本只由首行画、底线只由末行画——后面所有绘制逻辑就都变成 if 判断,不会互相打架。

第二块是扫描器。它遍历整列,把值相同的相邻行合并成一个MergeRange。这里有个必须提前想清楚的点:“相同”的判定标准是什么。字符串直接string.Equals,数字要注意100和100.00在显示上一样但Equals不一样,日期要注意时分秒。我的做法是统一走一个Normalize方法,把值转成“最终要显示的那串字符”再比,这样判定标准和用户眼睛看到的一致。

第三块是渲染器,挂CellPainting事件,按区间信息决定怎么画。这一块最费笔墨,下一章单独拆。

2.3 目录结构与初始化方式

工程里我就放两个文件,别搞太复杂:

文件职责
Controls/MergeRange.cs区间模型,纯 POCO
Controls/GridCellMerger.cs扫描 + 索引 + 绘制 + 事件订阅
Forms/OutboundDetailForm.cs业务窗体,只负责 new 一个 merger

在窗体里的接法是这个样子的:

// OutboundDetailForm.cs private GridCellMerger _orderNoMerger; private void BindDetail() { // 先关自动生成列,避免绑定后又多出一批列 gridDetail.AutoGenerateColumns = false; gridDetail.DataSource = _detailList; // List<OutboundDetailDto> // 绑定完成之后再挂合并器,此时列索引已经确定 _orderNoMerger = new GridCellMerger(gridDetail, "colOrderNo"); _orderNoMerger.Rebuild(); // 主动扫一次,别等事件 }

注意第二个参数我传的是列名而不是索引。业务窗体里列的顺序经常被产品经理调来调去,用索引写死,哪天有人插一列进去,合并就跑到别的列上去了,这种坑很难靠肉眼发现。构造里用grid.Columns[name].Index换算一次,把索引缓存起来。

另外Rebuild()必须在DataSource赋值之后调。DataGridView的绑定是延迟的,如果你在设置数据源之前就扫描,Rows.Count还是 0,扫出来一个空列表,界面上一格都不合并,而你又不会收到任何报错——这是最容易让人怀疑人生的第一处。

3. 核心链路拆解:从扫描相同值到画出跨行边框

3.1 扫描相邻行,切出合并区间

扫描逻辑看着简单,但有几个边界必须处理:隐藏行、新行占位符、以及列值为null或DBNull的情况。

// GridCellMerger.cs private void Rebuild() { _ranges.Clear(); _rowMap.Clear(); var rows = _grid.Rows; if (rows.Count == 0) return; int start = -1; string prevKey = null; for (int i = 0; i < rows.Count; i++) { var row = rows[i]; // 隐藏行和新行占位符一律视为“断点”,不参与合并 if (!row.Visible || row.IsNewRow) { Flush(start, i - 1); start = -1; prevKey = null; continue; } string key = Normalize(row.Cells[_columnIndex].Value); if (start < 0) { start = i; prevKey = key; continue; } if (!string.Equals(key, prevKey, StringComparison.Ordinal)) { Flush(start, i - 1); // 上一段结束 start = i; // 新的一段开始 prevKey = key; } } Flush(start, rows.Count - 1); // 收尾,别漏了最后一段 _grid.InvalidateColumn(_columnIndex); } // 把一段连续行登记进区间列表,长度不足 2 的直接丢弃 private void Flush(int first, int last) { if (first < 0 || last < first) return; // 回退掉尾部可能存在的隐藏行 while (last > first && (!_grid.Rows[last].Visible || _grid.Rows[last].IsNewRow)) last--; if (last <= first) return; // 单行不构成合并 var range = new MergeRange { ColumnIndex = _columnIndex, FirstRow = first, LastRow = last }; _ranges.Add(range); for (int r = first; r <= last; r++) _rowMap[r] = range; }

Normalize的实现决定了合并的“口径”,我是这么写的:

private static string Normalize(object value) { if (value == null || value == DBNull.Value) return string.Empty; if (value is DateTime dt) return dt.ToString("yyyy-MM-dd HH:mm"); if (value is decimal or double or float) return Convert.ToDecimal(value).ToString("0.####"); return value.ToString()?.Trim() ?? string.Empty; }

这里参数怎么改:如果你的订单号列带前后空格(从 Excel 导进来的数据很常见),Trim()保证"SO001"和"SO001 "被当成同一个;如果业务上要求忽略大小写,把StringComparison.Ordinal换成OrdinalIgnoreCase。日期格式是最容易出事的一处,ToString()不带格式会带上秒,两条本来同一天的记录就被判成不同值了。

Flush里那段回退隐藏行的while循环不能省。DataGridView允许业务侧隐藏某些行(比如作废明细),如果不回退,合并区间的LastRow会落在一条看不见的行上,下边框就画到屏幕外面去了,表现出来是“这一段的底边莫名消失”。

3.2 用字典把区间摊平,绘制时 O(1) 命中

扫描完得到的是一个List<MergeRange>,但绘制阶段是逐单元格回调的,如果每次都去列表里线性找“我在哪个区间”,两千行表格滚一下就卡成幻灯片了。所以扫描的同时要建一张行索引到区间的映射:

private readonly Dictionary<int, MergeRange> _rowMap = new(); private readonly List<MergeRange> _ranges = new(); public MergeRange FindByRow(int rowIndex) => _rowMap.TryGetValue(rowIndex, out var range) ? range : null;

Dictionary<int, MergeRange>在这里是最合适的结构:键是行索引,天然有序但不要求连续,查找是 O(1)。两千行数据大概占用几十 KB,可以忽略。用数组的话要处理空档,用SortedList查找是 O(log n),在CellPainting这种一帧要调用几十上百次的地方,差别是能感觉出来的。

有一点必须写进注释:_rowMap和_ranges必须同生共死,任何一处Clear都要成对出现。我在一个项目里就是因为新增了行插入逻辑,只清了_ranges忘了清_rowMap,导致删掉的区间还留在字典里,画面上出现幽灵边框,查了半天以为是显卡驱动的问题。

3.3 CellPainting:背景、文本、边框三段式

这是整套方案里唯一需要跟 GDI+ 打交道的地方,也是最容易画错的地方。核心矛盾在于:e.Graphics的裁剪区被限制在当前单元格内部,而合并后的文本要垂直居中于整个合并块——首行画的时候,块的下半部分在裁剪区外面,文字会被切掉一半。

解决办法是临时把裁剪区放大到整个合并块,画完再恢复:

private void OnCellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.ColumnIndex != _columnIndex || e.RowIndex < 0) return; // 不在任何合并区间内,交还给框架默认绘制 if (!_rowMap.TryGetValue(e.RowIndex, out var range)) return; // 第一步:背景和选中态交给框架,省得自己判断 Selected / AlternatingRows e.PaintBackground(e.CellBounds, true); // 第二步:只有首行负责画文本 if (range.IsHeader(e.RowIndex)) { var block = GetMergedBounds(range); var state = e.Graphics.Save(); e.Graphics.SetClip(block, System.Drawing.Drawing2D.CombineMode.Replace); string text = Convert.ToString(e.FormattedValue) ?? string.Empty; var flags = TextFormatFlags.VerticalCenter | TextFormatFlags.Left | TextFormatFlags.SingleLine | TextFormatFlags.EndEllipsis; var textRect = Rectangle.Inflate(block, -6, 0); // 左右各留 6px 内边距 TextRenderer.DrawText(e.Graphics, text, e.CellStyle.Font, textRect, e.CellStyle.ForeColor, flags); e.Graphics.Restore(state); } // 第三步:补边框,只画右边;末行额外画底边 DrawMergeBorder(e.Graphics, e.CellBounds, range.IsTail(e.RowIndex)); e.Handled = true; // 告诉框架:这一格我接手了,你别再画 }

三个步骤对应三个必须做对的地方。

第一步用e.PaintBackground而不是自己FillRectangle。因为它内部会处理选中高亮、交替行背景色、CellStyle.BackColor这些细节,自己填色等于把这些全放弃,选中行会和周围格格不入。第二个参数传true表示“按选中状态画背景”,这是保留选中态的关键。

第二步的Save/Restore不能省。SetClip改的是Graphics对象的全局状态,而e.Graphics在整个绘制周期里是复用的,不恢复的话,后面所有单元格都会被画到这块大区域里去,画面会糊成一片。这是我在第一次写自绘时踩过的坑,现象是“只有第一屏正常,滚动之后全乱”,查了两天才定位到。

第三步边框只画右边不画左边。因为每一格的左边就是前一格的右边,两边都画会叠出两条线,颜色深一倍,肉眼看着像加了粗。交给当前格画右边、由左邻格负责自己的右边,天然就不重复。底边只在IsTail为真时画,中间行的底边被跳过,跨行的视觉连续性就是这么来的。

配套的两个小方法:

// 把区间内所有可见单元格的矩形并起来,得到合并块的实际显示区域 private Rectangle GetMergedBounds(MergeRange range) { Rectangle block = Rectangle.Empty; for (int r = range.FirstRow; r <= range.LastRow; r++) { var rect = _grid.GetCellDisplayRectangle(range.ColumnIndex, r, false); block = block.IsEmpty ? rect : Rectangle.Union(block, rect); } return block; } private void DrawMergeBorder(Graphics g, Rectangle bounds, bool drawBottom) { using var pen = new Pen(_grid.GridColor); g.DrawLine(pen, bounds.Right - 1, bounds.Top, bounds.Right - 1, bounds.Bottom - 1); if (drawBottom) g.DrawLine(pen, bounds.Left, bounds.Bottom - 1, bounds.Right - 1, bounds.Bottom - 1); }

GetCellDisplayRectangle的第三个参数传false,表示取“单元格实际显示矩形”而不是“包含列头的区域”。这个参数写成true的话,第一行的矩形会带上列头高度,文本会偏下一大截,这是个非常隐蔽的错误。

3.4 行高变化与 DPI 缩放的处理

合并块的文本是垂直居中于整块的,所以只要块内任意一行的高度变了,整块的矩形就变了,必须重绘。DataGridView提供了RowHeightChanged事件,但它在批量调整行高时会触发几十上百次,每次都整列Invalidate会闪得厉害。稳妥的写法是接事件后做一次节流:

_grid.RowHeightChanged += (_, __) => ScheduleRepaint(); private bool _repaintPending; private void ScheduleRepaint() { if (_repaintPending) return; _repaintPending = true; _grid.BeginInvoke(new Action(() => { _repaintPending = false; _grid.InvalidateColumn(_columnIndex); })); }

用BeginInvoke把重绘推迟到消息队列空闲时执行,一批行高变更最终只合并成一次InvalidateColumn。这个技巧在高 DPI 环境下尤其有用,因为 125% 或 150% 缩放时,GetCellDisplayRectangle返回的矩形经常带小数,行高变化比 100% 时频繁得多。

还有一个细节:如果AutoSizeRowsMode设成了AllCells,DataGridView会在绘制过程中反过来调整行高,形成“绘制触发测量、测量触发重绘”的循环,表现出来是界面卡住不动。我的做法是合并列所在表格一律关掉自动行高,改成RowTemplate.Height = 28固定值,需要换行显示的列单独设WrapMode。

4. 接进真实业务表格:绑定、排序、编辑态与刷新

4.1 绑定完成后触发合并的时机

GridCellMerger的构造函数里我挂了几个事件,RowsAdded、RowsRemoved、Sorted、CellValueChanged,但都没在里面直接调Rebuild,而是统一走一个延迟入口。原因很实际:DataSource赋值会触发一连串RowsAdded,如果每个都扫一遍全表,两千行的表格绑定时间会从几十毫秒涨到一秒多。

public GridCellMerger(DataGridView grid, string columnName) { _grid = grid; _columnIndex = grid.Columns[columnName]?.Index ?? throw new ArgumentException($"找不到列 {columnName}"); _grid.CellPainting += OnCellPainting; _grid.CellBeginEdit += OnCellBeginEdit; _grid.CellValueChanged += (_, e) => { if (e.ColumnIndex == _columnIndex) ScheduleRebuild(); }; _grid.RowsRemoved += (_, __) => ScheduleRebuild(); _grid.Sorted += (_, __) => ScheduleRebuild(); _grid.RowHeightChanged += (_, __) => ScheduleRepaint(); }

ScheduleRebuild跟前面的ScheduleRepaint是同一个套路,只是它做的是Rebuild()加InvalidateColumn。参数说明一下:CellValueChanged只需要监听合并列自己,监听整张表没意义还费性能;RowsRemoved必须接,因为删除行之后旧的FirstRow/LastRow全部失效,索引会整体错位。

4.2 排序、筛选、增删行之后的重算

排序是这套方案最容易翻车的场景,没有之一。用户点了列头,DataGridView内部把行顺序全打乱,_rowMap里记的还是旧索引,结果就是合并框跑到不相干的行上去,或者几个不相邻的订单号被强行连成一块。

Sorted事件在排序完成之后触发,此时Rows已经是新顺序,直接Rebuild()就行。但有个前提:排序必须发生在数据源层,而不是DataGridView自己排。如果DataSource是List<T>,DataGridView会退化成它自己维护的Sort,排序后CellValue和DataSource就脱钩了,导出的数据和界面看到的不一致。

我一般的做法是DataSource绑BindingList<T>或者DataTable,排序交给BindingSource.Sort处理,表格只负责显示:

private void OnColumnHeaderClick(object sender, DataGridViewCellMouseEventArgs e) { if (e.Button != MouseButtons.Left || e.ColumnIndex < 0) return; string prop = gridDetail.Columns[e.ColumnIndex].DataPropertyName; if (string.IsNullOrEmpty(prop)) return; // 走数据源排序,而不是 grid.Sort _bindingSource.Sort = prop + (_desc ? " DESC" : " ASC"); _desc = !_desc; }

筛选同理,BindingSource.Filter变化后行数变了,也要重算一次。这里有个隐蔽的坑:Filter之后被过滤掉的行Visible是false吗?不完全是——BindingSource过滤掉的行在DataGridView里其实是被移除了,Rows.Count直接变小。所以Rebuild里的隐藏行判断主要针对业务侧手动调用row.Visible = false的情形(比如“折叠作废明细”),两者并存时都要照顾到。

4.3 编辑态与选中态的坑

合并区的中间行是空的,用户双击它会进入一个空白单元格的编辑状态,输入完值之后那一行的Value有了内容,但它所属的区间是别人画的,界面上看不到任何变化——用户会以为软件坏了。

拦截方式是CellBeginEdit:

private void OnCellBeginEdit(object sender, DataGridViewCellCancelEventArgs e) { if (e.ColumnIndex != _columnIndex) return; if (!_rowMap.TryGetValue(e.RowIndex, out var range)) return; if (range.IsHeader(e.RowIndex)) return; // 首行正常编辑 e.Cancel = true; // 中间行不允许单独编辑 _grid.CurrentCell = _grid[_columnIndex, range.FirstRow]; _grid.BeginEdit(true); // 焦点拉回首行,用户体验更连贯 }

e.Cancel = true之后再主动把CurrentCell设成首行并调用BeginEdit(true),用户双击中间行会直接跳到首行进入编辑,比“点了没反应”友好得多。注意BeginEdit(true)的参数表示“选中全部文本”,对订单号这种整串修改的场景很顺手。

选中态还有一层:用户框选一片区域时,合并区的中间行依然是独立单元格,选中高亮会一格一格跳。彻底解决要重写SetSelectedCellCore,代价太大,我的妥协方案是在SelectionChanged里把选区归一化,选中任意一行就整段选中:

private void OnSelectionChanged(object sender, EventArgs e) { if (_syncingSelection) return; _syncingSelection = true; try { foreach (DataGridViewCell cell in _grid.SelectedCells) { if (cell.ColumnIndex != _columnIndex) continue; if (!_rowMap.TryGetValue(cell.RowIndex, out var range)) continue; for (int r = range.FirstRow; r <= range.LastRow; r++) _grid[_columnIndex, r].Selected = true; } } finally { _syncingSelection = false; // 防止 SelectionChanged 递归 } }

_syncingSelection这个标志位是必须的,因为Selected = true又会触发SelectionChanged,不加保护直接栈溢出。

4.4 大数据量下的双缓冲与局部刷新

DataGridView默认是单缓冲渲染,CellPainting里做自绘之后滚动会明显发虚。开双缓冲是老生常谈但确实有效:

// 在窗体构造函数里调用一次 private static void EnableDoubleBuffer(DataGridView grid) { var prop = typeof(DataGridView).GetProperty( "DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); prop?.SetValue(grid, true, null); }

DoubleBuffered在DataGridView上是protected的,只能靠反射拿。用typeof(DataGridView)而不是grid.GetType()更稳,因为有些项目会继承一层自定义DataGridView,GetType()拿到的是子类,GetProperty可能返回null。

刷新范围也要控制住。_grid.Refresh()会重画整个控件,InvalidateColumn只重画一列,InvalidateCell(row, col)只重画一格。合并场景下,一行变化会影响整段,所以最小粒度是“一个区间”而不是“一个单元格”:

private void InvalidateRange(MergeRange range) { for (int r = range.FirstRow; r <= range.LastRow; r++) { if (r >= 0 && r < _grid.Rows.Count) _grid.InvalidateCell(_columnIndex, r); } }

合并列之外的列变化不需要动,InvalidateCell只针对这一列,代价可控。

5. 常见问题排查:花屏、错位、闪烁、复制为空的 5 个坑

5.1 现象:滚动之后文字糊成一片,或者跑到相邻列上

原因:CellPainting里调了e.Graphics.SetClip扩大裁剪区,但画完没有恢复。Graphics对象在整个绘制周期里是复用的,一旦裁剪区被放大,后面所有单元格的绘制内容都会溢出自己的边界。这个问题的迷惑性在于第一屏看起来完全正常,因为首屏是从上到下依次画的,溢出部分被后面的绘制覆盖掉了,一滚动就露馅。

解决:SetClip前后必须Save()/Restore(state)成对出现,绝对不要用ResetClip(),那会把框架外层的裁剪也一起清掉。

var state = e.Graphics.Save(); try { e.Graphics.SetClip(block, CombineMode.Replace); // ... 绘制 } finally { e.Graphics.Restore(state); }

5.2 现象:合并列的下边框时有时无,滚动一半才出现

原因:GetCellDisplayRectangle对屏幕外的行返回的是Rectangle.Empty,Rectangle.Union跟空矩形合并会把结果带偏,合并块的矩形算小了,底边就画到了裁剪区外面。另外行被隐藏时同样返回空矩形,也会触发同一类错误。

解决:绘制前先判断行是否真的可见,别用屏幕外矩形的数据。同时把LastRow的回退逻辑放在扫描阶段,而不是绘制阶段——绘制阶段每一帧都在跑,不该做数据修正。

private Rectangle GetMergedBounds(MergeRange range) { Rectangle block = Rectangle.Empty; for (int r = range.FirstRow; r <= range.LastRow; r++) { var rect = _grid.GetCellDisplayRectangle(range.ColumnIndex, r, false); if (rect.IsEmpty) continue; // 屏幕外的行跳过,不要参与 Union block = block.IsEmpty ? rect : Rectangle.Union(block, rect); } return block; }

如果整块都在屏幕外,返回空矩形,后面的绘制逻辑会被IsEmpty判断拦住,不要硬画。

5.3 现象:切换 DPI 或者缩放窗口后,合并文本偏上偏下、竖线错位

原因:TextRenderer.DrawText用的是 GDI 而非 GDI+,在高 DPI 下字体度量跟Graphics.DrawString有细微差别,垂直居中的结果是按整块高度算的,但实际字形高度可能比Font.Height大一点。另一个原因是Rectangle.Inflate(block, -6, 0)里的 6 是写死的像素值,125% 缩放下视觉上就变成 4.8,内边距看起来不一致。

解决:内边距按 DPI 缩放系数换算,不要写死。

private int ScaledPadding(int basePadding) { using var g = _grid.CreateGraphics(); return (int)Math.Round(basePadding * g.DpiX / 96f); }

更省事的做法是在窗体Load里读一次DeviceDpi缓存起来,别每次绘制都CreateGraphics,那个调用开销不小。

5.4 现象:Ctrl+C 复制出去,合并列中间几行是空的

原因:这是自绘方案的固有特点——数据层没动,但复制走的是GetClipboardContent,它对每个单元格取FormattedValue,中间行本来就是空的,复制出来自然是空的。下游系统拿到一堆空订单号,直接判定数据非法。

解决:拦截复制,自己组织剪贴板内容。最简单的做法是重写ProcessCmdKey拦截Ctrl+C,遍历SelectedCells时把空值替换成所属区间的首行值:

protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { if (keyData == (Keys.Control | Keys.C)) { var sb = new StringBuilder(); var selected = gridDetail.SelectedCells .Cast<DataGridViewCell>() .OrderBy(c => c.RowIndex).ThenBy(c => c.ColumnIndex); foreach (var row in selected.GroupBy(c => c.RowIndex)) { var cells = row.OrderBy(c => c.ColumnIndex).Select(c => { object v = c.Value; if (c.ColumnIndex == _orderNoMerger.ColumnIndex && string.IsNullOrEmpty(v?.ToString())) v = _orderNoMerger.FindByRow(c.RowIndex)?.FirstRowValue; // 回填首行的值 return v?.ToString() ?? string.Empty; }); sb.AppendLine(string.Join("\t", cells)); } Clipboard.SetText(sb.ToString()); return true; } return base.ProcessCmdKey(ref msg, keyData); }

FirstRowValue就是_grid[_columnIndex, range.FirstRow].FormattedValue,取格式化后的值,跟界面上看到的一致。

5.5 现象:表格加载完后界面卡住三五秒,鼠标转圈

原因:Rebuild里对每一行调row.Cells[_columnIndex].Value,这个属性访问在数据绑定模式下会触发DataGridView的缓存重建,几千行下来开销很可观。更要命的是如果同时开了AutoSizeRowsMode,Value访问会触发测量,测量又触发重绘,恶性循环。

解决:一是扫描阶段改用row.Cells[_columnIndex].FormattedValue或者直接从数据源取值,避免反复穿过DataGridView的绑定层;二是关掉自动行高;三是把Rebuild包在SuspendLayout/ResumeLayout里。

public void Rebuild() { _grid.SuspendLayout(); try { DoRebuild(); } finally { _grid.ResumeLayout(); _grid.InvalidateColumn(_columnIndex); } }

这三招叠起来,两千行表格的重算时间能从两秒多降到一百毫秒以内。

6. 二次开发:把合并规则抽成可配置策略并验证

把一套能跑的合并代码交给同事,最大的问题不是代码看不懂,而是“下次遇到另一张表,还得再复制一遍”。所以第 6 章我想说说怎么把它从“一个类的自嗨”变成“能复用的规则”。

第一个改造点是把“哪些行能合并”的判断抽成委托。现在的Normalize是写死的字符串比较,但真实业务里合并条件经常带业务规则,比如“只有同一个客户 + 同一个仓库的订单号才合并”。加一个Func<DataGridViewRow, DataGridViewRow, bool>类型的CanMerge参数,默认实现是比较首列字符串,业务方可以覆盖:

public sealed class MergeOptions { // 返回 true 表示这两行可以并进同一段 public Func<DataGridViewRow, DataGridViewRow, bool> CanMerge { get; set; } = (a, b) => string.Equals( a.Cells[0].FormattedValue?.ToString()?.Trim(), b.Cells[0].FormattedValue?.ToString()?.Trim(), StringComparison.Ordinal); // 是否把隐藏行当作断点,折叠场景需要 public bool BreakOnHiddenRow { get; set; } = true; // 文本内边距基数,实际值会按 DPI 缩放 public int Padding { get; set; } = 6; }

第二个改造点是绘制策略。现在文本只支持左对齐单行,遇到需要换行、需要右对齐金额、或者需要画图标的情况就歇菜了。加一个Action<DataGridViewCellPaintingEventArgs, Rectangle>的TextRenderer回调,默认实现走前面那套TextRenderer.DrawText,业务换列时传自己的实现进去,绘制逻辑和扫描逻辑彻底解耦。

第三个改造点是验证。这类视觉方案的自动化测试很难写,我的办法是留一个调试开关,在Rebuild之后把区间信息打到Debug.WriteLine里,格式是[col=2] rows 5-8 span=4,然后针对几个典型数据集(全相同、全不同、交替相同、含隐藏行、含空值)跑一遍,人工对一遍行号。听起来原始,但比肉眼看界面靠谱得多——界面上一段合并错位,你可能只看出“这里不对”,日志里能直接定位到是哪一段区间的边界算错了。

还有一个我自己常用的小技巧:在CellPainting开头加一个if (_debugDraw)分支,用半透明红色填充合并块矩形,跑起来一眼就能看出每段的实际范围跟预期对不对。这个开关在生产包里记得关掉,我有一次忘了,客户截图里全是红框,解释了半天。

说个血泪教训。最开始我把Rebuild挂在了CellFormatting里,想着“格式化的时候顺便重算一下”,结果CellFormatting在滚动时会被疯狂触发,每次滚动都全表重扫,表格滚起来像在放幻灯片,而且因为是在绘制过程中改了_rowMap,偶尔还会抛集合修改异常。从那以后我每次给这类控件挂事件,都强制走一遍“这个事件一帧触发几次、有没有可能在绘制周期里被调用”的检查,宁可多加一层延迟,也不在绘制路径上做数据计算。

这套代码我现在放在项目里当基础设施用,新表格接入基本就是三行:new 一个 merger、设一下列名、绑定完成后调一次Rebuild。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询