简介针对 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区间模型纯 POCOControls/GridCellMerger.cs扫描 索引 绘制 事件订阅Forms/OutboundDetailForm.cs业务窗体只负责 new 一个 merger在窗体里的接法是这个样子的// OutboundDetailForm.cs private GridCellMerger _orderNoMerger; private void BindDetail() { // 先关自动生成列避免绑定后又多出一批列 gridDetail.AutoGenerateColumns false; gridDetail.DataSource _detailList; // ListOutboundDetailDto // 绑定完成之后再挂合并器此时列索引已经确定 _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) 命中扫描完得到的是一个ListMergeRange但绘制阶段是逐单元格回调的如果每次都去列表里线性找“我在哪个区间”两千行表格滚一下就卡成幻灯片了。所以扫描的同时要建一张行索引到区间的映射private readonly Dictionaryint, MergeRange _rowMap new(); private readonly ListMergeRange _ranges new(); public MergeRange FindByRow(int rowIndex) _rowMap.TryGetValue(rowIndex, out var range) ? range : null;Dictionaryint, 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设成了AllCellsDataGridView会在绘制过程中反过来调整行高形成“绘制触发测量、测量触发重绘”的循环表现出来是界面卡住不动。我的做法是合并列所在表格一律关掉自动行高改成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是ListTDataGridView会退化成它自己维护的Sort排序后CellValue和DataSource就脱钩了导出的数据和界面看到的不一致。我一般的做法是DataSource绑BindingListT或者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有了内容但它所属的区间是别人画的界面上看不到任何变化——用户会以为软件坏了。拦截方式是CellBeginEditprivate 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()更稳因为有些项目会继承一层自定义DataGridViewGetType()拿到的是子类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.EmptyRectangle.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 现象CtrlC 复制出去合并列中间几行是空的原因这是自绘方案的固有特点——数据层没动但复制走的是GetClipboardContent它对每个单元格取FormattedValue中间行本来就是空的复制出来自然是空的。下游系统拿到一堆空订单号直接判定数据非法。解决拦截复制自己组织剪贴板内容。最简单的做法是重写ProcessCmdKey拦截CtrlC遍历SelectedCells时把空值替换成所属区间的首行值protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { if (keyData (Keys.Control | Keys.C)) { var sb new StringBuilder(); var selected gridDetail.SelectedCells .CastDataGridViewCell() .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的缓存重建几千行下来开销很可观。更要命的是如果同时开了AutoSizeRowsModeValue访问会触发测量测量又触发重绘恶性循环。解决一是扫描阶段改用row.Cells[_columnIndex].FormattedValue或者直接从数据源取值避免反复穿过DataGridView的绑定层二是关掉自动行高三是把Rebuild包在SuspendLayout/ResumeLayout里。public void Rebuild() { _grid.SuspendLayout(); try { DoRebuild(); } finally { _grid.ResumeLayout(); _grid.InvalidateColumn(_columnIndex); } }这三招叠起来两千行表格的重算时间能从两秒多降到一百毫秒以内。6. 二次开发把合并规则抽成可配置策略并验证把一套能跑的合并代码交给同事最大的问题不是代码看不懂而是“下次遇到另一张表还得再复制一遍”。所以第 6 章我想说说怎么把它从“一个类的自嗨”变成“能复用的规则”。第一个改造点是把“哪些行能合并”的判断抽成委托。现在的Normalize是写死的字符串比较但真实业务里合并条件经常带业务规则比如“只有同一个客户 同一个仓库的订单号才合并”。加一个FuncDataGridViewRow, DataGridViewRow, bool类型的CanMerge参数默认实现是比较首列字符串业务方可以覆盖public sealed class MergeOptions { // 返回 true 表示这两行可以并进同一段 public FuncDataGridViewRow, 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; }第二个改造点是绘制策略。现在文本只支持左对齐单行遇到需要换行、需要右对齐金额、或者需要画图标的情况就歇菜了。加一个ActionDataGridViewCellPaintingEventArgs, Rectangle的TextRenderer回调默认实现走前面那套TextRenderer.DrawText业务换列时传自己的实现进去绘制逻辑和扫描逻辑彻底解耦。第三个改造点是验证。这类视觉方案的自动化测试很难写我的办法是留一个调试开关在Rebuild之后把区间信息打到Debug.WriteLine里格式是[col2] rows 5-8 span4然后针对几个典型数据集全相同、全不同、交替相同、含隐藏行、含空值跑一遍人工对一遍行号。听起来原始但比肉眼看界面靠谱得多——界面上一段合并错位你可能只看出“这里不对”日志里能直接定位到是哪一段区间的边界算错了。还有一个我自己常用的小技巧在CellPainting开头加一个if (_debugDraw)分支用半透明红色填充合并块矩形跑起来一眼就能看出每段的实际范围跟预期对不对。这个开关在生产包里记得关掉我有一次忘了客户截图里全是红框解释了半天。说个血泪教训。最开始我把Rebuild挂在了CellFormatting里想着“格式化的时候顺便重算一下”结果CellFormatting在滚动时会被疯狂触发每次滚动都全表重扫表格滚起来像在放幻灯片而且因为是在绘制过程中改了_rowMap偶尔还会抛集合修改异常。从那以后我每次给这类控件挂事件都强制走一遍“这个事件一帧触发几次、有没有可能在绘制周期里被调用”的检查宁可多加一层延迟也不在绘制路径上做数据计算。这套代码我现在放在项目里当基础设施用新表格接入基本就是三行new 一个 merger、设一下列名、绑定完成后调一次Rebuild。希望帮到你。本文还有配套的精品资源点击获取
企业数字化 ERP 产品动态
相关推荐
MySQL root密码热重置实战指南 1. 问题背景与紧急处理思路上周五凌晨两点,我接到运维同事的紧急电话——生产环境的MySQL数据库root密码突然失效,导致所有依赖数据库的业务系统瘫痪。这种突发情况在数据库运维中并不罕见,但处理不当可能引发数据丢失或服务长时间中断。经过… · 2026/11/8 14:17:28
PostgreSQL角色与权限管理实战指南 1. PostgreSQL 角色与权限体系概述PostgreSQL 作为企业级开源关系型数据库,其权限管理系统采用了基于角色的访问控制(RBAC)模型。这套系统不同于传统数据库将"用户"和"用户组"分离的设计,而是通过统一的"… · 2026/11/7 13:00:31
通信方式全解析:单工、半双工与双工技术对比 1. 通信基础概念与分类体系1.1 什么是通信通信的本质是信息的传递过程,就像两个人之间的对话一样,只不过发生在设备之间。想象一下你给朋友发短信:你的手机(发送端)把文字信息转换成电信号,通过移动网络&am… · 2026/11/6 2:55:12
中山电工证打印实体卡保姆级教程:证书过期了不知道怎么复审延期怎么办 中山电工证打印实体卡保姆级教程:证书过期了不知道怎么复审延期怎么办 证书过期了不知道怎么复审延期?这可能是很多电工从业者最头疼的问题之一。尤其是在中山地区,很多电工朋友在拿到【中山电工证打印实体卡】后,常常会遇到复审流程不清、操作不熟悉、甚… · 2026/11/8 14:17:16
南山安监局办电工证含金量到底如何?看完这篇不再纠结 南山安监局办电工证含金量到底如何?看完这篇不再纠结 急需特种作业证才能上岗找工作,这是很多从业者的真实写照,尤其是在建筑、电力、制造业等行业, 南山安监局办电工证… · 2026/11/8 14:17:16
朔州在哪考电工证最快 电工证值不值得考 朔州在哪考电工证最快 电工证值不值得考 你是不是也在找“朔州在哪考电工证最快”,但又担心被中介坑?现在市面上各种培训机构鱼龙混杂,打着“包过”“快速拿证”的旗号,其实背后藏着各种套路,稍有不慎,钱花了证没拿到,甚至可能影响到你的职业发展。… · 2026/11/8 14:17:15
容桂高压电工证复审流程及费用详解:多少钱?怎么操作? 容桂高压电工证复审流程及费用详解:多少钱?怎么操作? 你有没有遇到过这样的情况:明明有电工技术,但因为没有高压电工证,被用人单位直接拒绝?别急,这篇文章就来帮你解决【容桂高压电工证复审】的问题,重点告诉你 多少钱… · 2026/11/8 14:17:15
乐山低压电工证哪儿考真实经历告诉你怎么避坑 乐山低压电工证哪儿考真实经历告诉你怎么避坑 你是不是也这样,拿着手机刷了大半天,结果还是不知道乐山低压电工证哪儿考?别急,我跟你说,我就是之前在商丘读工程类专业,毕业后也正愁怎么考低压电工证,踩了不少坑,今天我就把我的真实经历和你分享一下,… · 2026/11/8 14:17:15
新兴哪里考电工证好点怎么选电子证书更靠谱 新兴哪里考电工证好点怎么选电子证书更靠谱 急需特种作业证才能上岗找工作,你是不是也在为“新兴哪里考电工证好点”发愁?尤其是在电工证考试流程、培训机构选择、电子证书发放等方面,信息繁杂、真假难辨,稍有不慎就可能耽误入职机会。今天我们就从实际出… · 2026/11/8 14:16:15
电工证丢失还能补吗?千万别踩坑,丢了证等于失业 电工证丢失还能补吗?千万别踩坑,丢了证等于失业 你正在找工作,但手里的电工证突然丢了,这下怎么办?别急,先别慌, 电工证丢失还能补吗 ?答案是肯定的,但 千万别踩坑 ,补办流程如果不了解,可能会耽误你上岗、甚至影响到你的收入。… · 2026/11/8 0:00:20
电力电缆作业证直播报名入口与办理流程良心建议 电力电缆作业证直播报名入口与办理流程良心建议 你是不是也担心学历不高报不了名?别急,今天就给你讲讲 电力电缆作业证直播 的报名流程和那些你必须知道的干货, 良心建议 在这里,别错过。 电力电缆作业证直播报名需要什么条件?… · 2026/11/8 0:00:20
英德电工证报考费多少钱,怕考不过白交培训费?看完这篇别再踩坑 英德电工证报考费多少钱,怕考不过白交培训费?看完这篇别再踩坑 怕考不过白交培训费?英德电工证报考费多少钱,你真的了解吗?现在市面上培训机构五花八门,价格参差不齐,很多人一不小心就花了冤枉钱,结果还考不过。别急,今天就来给你说清楚英德电工证的… · 2026/11/8 0:00:20
电工证丢失还能补吗?千万别踩坑,丢了证等于失业 电工证丢失还能补吗?千万别踩坑,丢了证等于失业 你正在找工作,但手里的电工证突然丢了,这下怎么办?别急,先别慌, 电工证丢失还能补吗 ?答案是肯定的,但 千万别踩坑 ,补办流程如果不了解,可能会耽误你上岗、甚至影响到你的收入。… · 2026/11/8 0:00:20
电力电缆作业证直播报名入口与办理流程良心建议 电力电缆作业证直播报名入口与办理流程良心建议 你是不是也担心学历不高报不了名?别急,今天就给你讲讲 电力电缆作业证直播 的报名流程和那些你必须知道的干货, 良心建议 在这里,别错过。 电力电缆作业证直播报名需要什么条件?… · 2026/11/8 0:00:20
英德电工证报考费多少钱,怕考不过白交培训费?看完这篇别再踩坑 英德电工证报考费多少钱,怕考不过白交培训费?看完这篇别再踩坑 怕考不过白交培训费?英德电工证报考费多少钱,你真的了解吗?现在市面上培训机构五花八门,价格参差不齐,很多人一不小心就花了冤枉钱,结果还考不过。别急,今天就来给你说清楚英德电工证的… · 2026/11/8 0:00:20
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/7 11:25:08
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/7 12:02:18
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/8 1:18:25