资讯动态

C# DataGridView 合并单元格:重绘、交互与刷新避坑指南

发布时间:2026/11/6 6:06:33 来源:尧图企业网站定制
简介面向 WinForms 桌面开发者的 DataGridView 单元格合并示例工程主要解决表格中标题行、汇总行等跨行跨列布局的需求。压缩包共 22 个文件以 .cs 源码、.resx 与 .resources 资源文件、.csproj 工程和 .sln 解决方案为主体另含 .exe、.pdb 等编译产物及 .txt 说明整体约 44KB可直接用 Visual Studio 打开调试。内容围绕自定义绘制实现视觉合并通过处理 Paint 相关事件重绘单元格矩形配合单元格对齐方式、列宽自适应模式与行头样式设置呈现表头与数据区的合并效果并对比隐藏列的逻辑合并思路及其数据残留问题。已有 349 人学习下载适合希望快速跑通合并效果、对照源码理解绘制流程的中初级开发者参考。1. 从一张出库单说起DataGridView 合并单元格真正难的是配套改造去年帮一个做仓储的朋友改界面需求只有一句话用 C# DataGridView 合并单元格把同一张出库单的多行明细在「单号」「客户」两列上压成一格。当时估了半小时结果真正花时间的是后面三件事——排序一刷新合并就散架、鼠标点在被合并掉的格子上选不中整块、滚动条一拖那片区域直接闪成白条。DataGridView 从头到尾就没有提供过真正的合并能力能做的只是把中间那些格子的文字和边框不画让左上角那一格把整块区域画满。所以这件事的本质是「接管绘制 维护一张映射表 把交互补回来」缺一样都会在某个操作路径上露馅。这套东西适合谁做过 WinForms 业务表单、被客户要求过「像 Excel 那样合并」的开发者以及正在维护一个跑了好几年、还在往里加列的老表格系统的人。2. 结构先行合并描述表、绘制器与扩展方法的三段式拆分2.1 DataGridView 为什么天生没有 Merge单元格与屏幕矩形的强绑定先把模型讲清楚后面很多坑就知道为什么必然出现。DataGridView 的三层结构是 Rows、Columns、Cells每个 cell 在屏幕上对应一个由行高和列宽推出来的矩形GetCellDisplayRectangle(columnIndex, rowIndex, false)返回的就是这个矩形永远一对一。它没有 HTML table 里rowspan/colspan那种属性也没有 Spread 控件里的 Merge 方法内部连「这个格子占几行」的字段都不存在。行高由Rows[i].Height决定列宽由Columns[j].Width决定布局计算里没有任何跨行的分支。明白了这一点方案就只剩一条路数据还是那么多格格还是那么大只是让某些格「闭嘴」。锚点格合并区左上角那一个负责把整块的背景、边框、文字画出来其余的成员格什么都不画。用户看到的是合并程序里其实一格没少。想通这层你才不会去翻文档找Merge属性也不会被搜索引擎里那些「DataGridView 合并单元格」标题下贴的第三方控件广告带偏。顺带说一句网上不少所谓合并代码实际做的是把重复值置空看起来干净了但跨行边框还是断的鼠标点上去还是单格。那不算合并只能算「视觉留白」适合不需要框线的场景别混为一谈。2.2 三条技术路线对比CellPainting 重绘、自定义 Cell、数据层留白方案实现成本滚动性能选中与编辑适用场景CellPainting 重绘低挂一个事件就能跑中需要自己做可视区裁剪要手动映射当前单元格合并列固定、列数不多绝大多数业务表自定义 Column / Cell 重写 Paint高布局和命中测试都要自己管高可以做到只算可视区完全可控大面积合并、需要封装成控件复用数据层留白重复值置空极低绑定时处理一下高天然正常只要视觉清爽、不要跨行框线我一般从第一种起步。理由很实际业务系统的表格列是会长出来的今天两列要合并下个月可能变五列用事件方式改起来就是往集合里加个列索引用自定义 Cell 的方式得连列类型一起换。等哪天发现滚动确实卡了再整体迁到第二种那时代码结构没变只是把绘制逻辑从事件搬到DataGridViewCell.Paint里迁移成本可控。2.3 我习惯的类结构MergeRange、MergeMap、MergePainter 各管一段别把所有逻辑糊在 Form 的CellPainting里那是半年后自己都看不懂的写法。我习惯拆成三块MergeRange只描述一块区域不知道 DataGridView 的存在MergeMap负责在数据变化时重建所有区域并提供Find(column, row)的查询MergePainter持有 grid 引用只负责画和交互。三者关系是单向的Painter 依赖 MapMap 依赖 Range反过来不成立。// 描述一块视觉合并区以左上角单元格为锚点向右下扩散 public sealed class MergeRange { public int FirstRow { get; set; } public int FirstColumn { get; set; } public int RowSpan { get; set; } 1; public int ColumnSpan { get; set; } 1; public int LastRow FirstRow RowSpan - 1; public int LastColumn FirstColumn ColumnSpan - 1; public bool Contains(int columnIndex, int rowIndex) columnIndex FirstColumn columnIndex LastColumn rowIndex FirstRow rowIndex LastRow; public bool IsAnchor(int columnIndex, int rowIndex) columnIndex FirstColumn rowIndex FirstRow; }RowSpan默认给 1 是有意的横向合并可以先不做但字段得留着否则以后要支持「同一单号跨两列」就得改一圈调用方。Contains用在绘制时反查IsAnchor用在绘制和编辑时判责两个方法加起来不到十行却能省掉后面一堆散落的if。MergeMap用Dictionarylong, MergeRange存索引键用(long)column 32 | (uint)row拼出来查询是 O(1)。别用List.Find那玩意儿在滚动重绘时是每帧都在调用的行数上千就会明显掉帧。这个细节属于血泪经验一开始图省事用列表滚动起来像幻灯片。3. 核心实现拆解一次 CellPainting 里的背景、边框与文字3.1 合并描述表怎么建按列扫描连续相同值合并的前提是数据连续。同一单号的行必须挨在一起跨行的相同值中间夹了别的单号视觉上合并就是把两张单据揉成一格属于业务错误。所以扫描算法只认「相邻相等」不认「全局相等」。// 按某一列扫描把连续相同的行压成一段调用前需保证数据已按该列排好序 private static ListMergeRange BuildVerticalRanges(DataGridView grid, int columnIndex) { var result new ListMergeRange(); int startRow -1; string lastKey null; // 循环多跑一次哨兵行保证最后一段能被收口 for (int row 0; row grid.Rows.Count; row) { string key row grid.Rows.Count ? null : Convert.ToString(grid.Rows[row].Cells[columnIndex].Value)?.Trim(); // 空值不参与合并否则表格中间的空行会被压成一大块 bool same row grid.Rows.Count startRow 0 !string.IsNullOrEmpty(key) key lastKey; if (!same) { // 只有跨两行以上才值得记一条单行合并没有意义还拖慢绘制 if (startRow 0 row - startRow 1 !string.IsNullOrEmpty(lastKey)) { result.Add(new MergeRange { FirstRow startRow, FirstColumn columnIndex, RowSpan row - startRow }); } startRow row grid.Rows.Count ? row : -1; } lastKey key; } return result; }startRow记录当前这一段的起点lastKey记录这一段的值。什么时候断段遇到不同的值、遇到空值、或者走到表尾三种情况都走!same分支。row - startRow 1这个判断负责过滤掉单行!string.IsNullOrEmpty(lastKey)负责过滤掉整段空值——这两个条件少一个表格里就会出现莫名其妙的空白合并块。Trim()不能省。数据库里存的SO2024001和SO2024001 在Equals下不相等但人眼看着一样用户会说「这两行明明一样为什么不合并」。这类问题排查起来很费时间提前处理掉。3.2 锚点判定与整块矩形GetCellDisplayRectangle 逐格取并集合并区的屏幕矩形绝对不能自己用「行高 × 行数」算。原因有三行可能被用户拖动改高、可能存在隐藏行、表格可能设了冻结列导致坐标偏移。唯一可靠的做法是逐格取矩形再求并集。private Rectangle GetMergedBounds(MergeRange range) { // GetCellDisplayRectangle 返回的是当前屏幕上的真实矩形 // 隐藏行、冻结列、用户拖过的行高都已经被算进去了 var rect _grid.GetCellDisplayRectangle(range.FirstColumn, range.FirstRow, false); for (int r range.FirstRow; r range.LastRow; r) { for (int c range.FirstColumn; c range.LastColumn; c) { var cellRect _grid.GetCellDisplayRectangle(c, r, false); if (cellRect.Width 0 || cellRect.Height 0) continue; // 已滚出可视区 rect Rectangle.Union(rect, cellRect); } } return rect; }Width 0的判断是必须的。滚出可视区的行GetCellDisplayRectangle会返回空矩形或负坐标直接参与Union会把合并区拉到屏幕外面画出来的东西就跑到别的控件上去了。这个现象很吓人看起来像是「表格把下面的按钮盖住了」。逐格循环看着笨但只有合并列上那几个格会走到这里行数一般也就几十实际开销可以忽略。真到了几千行就得在 3.2 前面加一层可视区过滤——只遍历FirstDisplayedScrollingRowIndex到FirstDisplayedScrollingRowIndex DisplayedRowCount(false)之间的行超出的部分记下偏移量再补。3.3 背景、边框、文字的绘制顺序与像素级细节绘制顺序是背景、边框、文字一层盖一层。这里有个容易忽略的性能问题如果每画一个成员格都把整块区域刷一遍背景一个 30 行的合并区就要刷 30 次复杂度是 O(n²)。正确做法是每个成员格只刷自己的e.CellBounds反正整块区域被所有成员瓜分完了不会漏。private void OnCellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; // 表头、行号列不处理 var range _map.Find(e.ColumnIndex, e.RowIndex); if (range null) return; // 不在任何合并区交回默认绘制 e.Handled true; // 完全接管这一格默认绘制一律不执行 bool selected _grid[range.FirstColumn, range.FirstRow].Selected; // 1. 背景每格只刷自己的矩形避免大方块的 O(n²) 重绘 using (var back new SolidBrush(selected ? _grid.DefaultCellStyle.SelectionBackColor : e.CellStyle.BackColor)) { e.Graphics.FillRectangle(back, e.CellBounds); } // 2. 文字和整块边框只由「责任人」画一次 if (!IsTextOwner(e.ColumnIndex, e.RowIndex, range)) return; DrawMergedBorder(e.Graphics, range); DrawMergedText(e.Graphics, e, range); }e.Handled true是最关键的一行。不设它默认绘制会在你画完之后再补一遍文字和网格线结果就是文字重影、框线交错看起来像显卡坏了。设了它这一格的每一像素都归你负责选中背景、隔行变色、网格线全得自己补——这也是为什么上面要显式判断selected。IsTextOwner这层抽象是防一个很隐蔽的坑锚点行滚出可视区后CellPainting不会为它触发成员格又被完全接管且不画文字整段合并区的文字就凭空消失了。所以「谁画文字」不能写死成锚点要动态算// 谁负责画文字正常是锚点锚点滚出可视区后交给最上面的可见成员 private bool IsTextOwner(int columnIndex, int rowIndex, MergeRange range) { if (columnIndex ! range.FirstColumn) return false; int firstVisible _grid.FirstDisplayedScrollingRowIndex; int ownerRow range.FirstRow firstVisible ? range.FirstRow : firstVisible; return rowIndex ownerRow; } private void DrawMergedBorder(Graphics g, MergeRange range) { var bounds GetMergedBounds(range); using (var pen new Pen(_grid.GridColor)) { // 宽高各减 1否则最右和最下那条线会被相邻格的后绘制覆盖掉 g.DrawRectangle(pen, bounds.Left, bounds.Top, bounds.Width - 1, bounds.Height - 1); } } private void DrawMergedText(Graphics g, DataGridViewCellPaintingEventArgs e, MergeRange range) { var bounds GetMergedBounds(range); var style e.CellStyle; var flags TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis; // 对齐方式跟着列样式走业务代码改了列样式合并区要跟着变 switch (style.Alignment) { case DataGridViewContentAlignment.MiddleCenter: case DataGridViewContentAlignment.TopCenter: case DataGridViewContentAlignment.BottomCenter: flags | TextFormatFlags.HorizontalCenter; break; case DataGridViewContentAlignment.MiddleRight: case DataGridViewContentAlignment.TopRight: case DataGridViewContentAlignment.BottomRight: flags | TextFormatFlags.Right; break; default: flags | TextFormatFlags.Left; break; } var text Convert.ToString(e.FormattedValue) ?? string.Empty; TextRenderer.DrawText(g, text, style.Font, bounds, style.ForeColor, flags); }边框那个Width - 1/Height - 1是像素级的血泪经验。DrawRectangle的宽高是「线段长度」不是「包含的像素数」不减 1 的话右边和下边会多出一个像素压在下一格的地盘上滚动时能看见一条抖动的细线。文字用TextRenderer而不是Graphics.DrawString是因为前者走 GDI字重和网格对齐跟系统原生控件一致后者走 GDI在 125% 缩放下会糊。另外注意e.FormattedValue而不是e.Value。列上如果挂了CellFormatting做金额千分位、日期格式化Value拿到的是原始值FormattedValue才是用户看到的那串合并区里显示原始值就露馅了。3.4 选中态、点击落点与当前单元格的映射绘制搞定只是能看交互才是能不能用的分水岭。合并区里除了锚点其他格在数据层依然存在用户点第二行那个「看不见内容」的格子CurrentCell就跑到那儿去了选中框只框住一个格键盘上下移动也会在合并区内部乱跳。private void OnCellMouseDown(object sender, DataGridViewCellMouseEventArgs e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; var range _map.Find(e.ColumnIndex, e.RowIndex); if (range null) return; // 点在合并区的任意落点当前单元格都归到锚点 // 这样键盘导航、复制、编辑才有确定的目标 _grid.CurrentCell _grid[range.FirstColumn, range.FirstRow]; }在CellMouseDown里改CurrentCell是安全的但如果你还在SelectionChanged里做了联动比如根据当前行刷新右侧详情面板这个赋值会再触发一次事件可能造成递归。稳妥的写法是加个_mappingSelection布尔做重入保护赋值前后置位。我见过有人在SelectionChanged里改CurrentCell的那是真的会栈溢出。还有个体验点合并区被选中时SelectionMode如果是CellSelect默认只会给锚点画选中背景。上面 3.3 的代码里已经用锚点的Selected状态给全块刷了底色视觉上就连成一片了。但如果业务要求「点一下选中整张单据的所有行」那得在CellMouseDown里顺手把range.FirstRow到range.LastRow的整行都标上Selected true并且在绘制时改成判断rowIndex range.FirstRow rowIndex range.LastRow的任意一格是否选中。4. 交互与刷新排序、滚动、编辑、导出四处的配套改造4.1 排序与数据绑定后重算合并表合并表是数据的派生结果数据一变就得重算否则就是一堆指向错误行的陈旧索引。需要挂的事件至少有四个Sorted列头点击排序、DataBindingComplete重新绑数据源、RowsRemoved删行、以及业务侧主动调用RefreshMerge()的地方。private void RebuildMergeMap() { _map.Clear(); // 关掉重绘避免每加一条区域就触发一次全表刷新 _grid.SuspendLayout(); try { foreach (int columnIndex in _mergeColumns) { foreach (var range in BuildVerticalRanges(_grid, columnIndex)) _map.Add(range); } ApplyReadOnlyRules(); // 非锚点设只读见 4.3 } finally { _grid.ResumeLayout(); _grid.Invalidate(); // 一次性重画比逐条 Invalidate 快一个数量级 } }SuspendLayout/ResumeLayout这一对很多人只在改控件布局时用其实批量改单元格属性时也管用。不加的话ReadOnly每设一次就可能触发一次内部刷新两千行数据能卡出半秒白屏。排序这块还有个前置条件必须校验数据源本身得是按合并列排好序的。用户点别的列头排序同一单号的行就散了BuildVerticalRanges会认为它们不连续合并自然消失——这是对的行为但产品经理会当成 bug 提过来。我的处理方式是在合并列的HeaderCell上禁用排序SortMode NotSortable或者在SortCompare里把合并列作为次要排序键保证同单号永远挨着。4.2 滚动卡顿与残影双缓冲加可视区裁剪DataGridView 的DoubleBuffered是 protected 的外面改不了得靠反射或者派生一个控件。我倾向派生反射那套在不同运行时版本上不一定稳。// 派生一个开了双缓冲的 DataGridView替换设计器里的类型即可 public class BufferedGrid : DataGridView { public BufferedGrid() { // AllPaintingInWmPaint 让绘制全部走 WM_PAINT配合双缓冲消除闪烁 SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint, true); UpdateStyles(); } }只有双缓冲还不够。滚动时CellPainting的调用次数跟可视行数成正比一个 400 行的表如果每次滚动都把全部合并区算一遍矩形照样卡。裁剪的思路很简单e.RowIndex落在[FirstDisplayedScrollingRowIndex, FirstDisplayedScrollingRowIndex DisplayedRowCount(false))之外就直接返回让默认绘制兜底——反正看不见。再进一步GetMergedBounds里的双重循环可以改成只遍历可见行。合并区的矩形在锚点滚出屏幕后本来就只有一部分可见逐格Union时跳过不可见行结果是一样的但循环次数从「合并行数」降到「可见合并行数」。滚动条快速拖动时这个差别很明显。4.3 编辑态只让锚点可写改完回写整块合并区里数据其实还是每行一份。用户在锚点上把单号从SO2024001改成SO2024002如果只改了锚点那一个格子另外几行还是旧值——眼睛看着是一格实际上数据裂成了两半下一次RebuildMergeMap合并就散了导出也错。private bool _syncing; // 重入保护防止回写时再触发 CellValueChanged private void ApplyReadOnlyRules() { foreach (var range in _map.Ranges) { for (int r range.FirstRow; r range.LastRow; r) { for (int c range.FirstColumn; c range.LastColumn; c) { // 只有锚点可编辑成员格只读避免用户点到看不见的格子里改数据 _grid[c, r].ReadOnly !range.IsAnchor(c, r); } } } } private void OnCellValueChanged(object sender, DataGridViewCellEventArgs e) { if (_syncing) return; var range _map.Find(e.ColumnIndex, e.RowIndex); if (range null || !range.IsAnchor(e.ColumnIndex, e.RowIndex)) return; _syncing true; try { var value _grid[e.ColumnIndex, e.RowIndex].Value; // 锚点改完整块同步否则数据就裂了 for (int r range.FirstRow; r range.LastRow; r) { if (r e.RowIndex) continue; _grid[e.ColumnIndex, r].Value value; } } finally { _syncing false; } RebuildMergeMap(); // 值变了合并区可能变长或变短 }_syncing这个标志必须加。给成员格赋值会再次触发CellValueChanged虽然成员格的ReadOnly true但代码赋值不受只读限制照样进事件不加保护就是无限递归。还有一种情况合并列绑的是数据源的属性赋值之后BindingSource那边可能抛DataError比如类型不匹配、唯一约束冲突。DataError事件里不处理控件会弹一个默认的红色错误对话框用户体验很糟。我一般在DataError里e.ThrowException false把异常信息记到日志然后e.Cancel true让这次编辑回滚。4.4 导出时的行列偏移与合并还原导出到表格文件是这类界面绕不开的配套。表格软件支持真正的合并所以这里不需要重绘直接照搬MergeRange就行但有三个偏移量要对齐。// 伪代码sheet 为第三方表格库的写入对象接口各有差异思路一致 const int headerRowCount 1; // 导出时的表头行数 int columnOffset 0; // 只读列、行号列不导出时的偏移 foreach (var range in _map.Ranges) { // 行偏移网格的第 0 行在导出文件里是 headerRowCount int firstRow range.FirstRow headerRowCount; int firstCol range.FirstColumn columnOffset; // 先确保单元格数据写全成员格也要写否则合并后取到空值 for (int r 0; r range.RowSpan - 1; r) { for (int c 0; c range.ColumnSpan - 1; c) { sheet.SetValue(firstRow r, firstCol c, _grid[range.FirstColumn c, range.FirstRow r].Value); } } sheet.Merge(firstRow, firstCol, range.RowSpan, range.ColumnSpan); }行偏移是最容易翻车的地方。网格里第 0 行对应导出文件的第 1 行因为文件里有表头忘了加这个偏移合并区整体上移一行把表头给并进去了。列偏移同理隐藏列、勾选列、行号列不导出的话列索引必须减去前置列数否则合并错位一列。还有一点成员格的数据要先写进去再合并。有些库的Merge会丢弃非左上角单元格的值但另一些库会把它们保留在底层读出来格式乱。统一先写全、再合并行为最稳。5. 避坑排查五个把合并做翻车的现场5.1 现象往下滚一屏合并区的文字整段消失原因只在锚点那一格画文字。锚点行滚出可视区后CellPainting根本不会为它触发而成员格又被e.Handled true接管且不画文字于是整块区域只剩背景色。解决把「谁画文字」的判断从IsAnchor换成「锚点可见则锚点画锚点不可见则由当前最上面的可见成员画」就是 3.3 里的IsTextOwner。改完之后上下快速拖动滚动条文字始终有且只有一个不会重复也不会消失。5.2 现象拖动滚动条时合并区闪白条松手后又正常原因没开双缓冲或者开了双缓冲但背景是逐个成员格刷的成员格的绘制顺序在滚动时被打乱中间出现了未被填充的瞬间。也有一类情况是给整个合并区刷背景FillRectangle(bounds)渲染量随合并行数平方增长滚动时直接跟不上帧。解决两步。第一步换成 4.2 里那个派生的BufferedGrid第二步把背景填充改成每格刷自己的e.CellBounds别刷整块矩形。改完再看白条基本消失。如果还有残余闪烁检查是不是在Scroll事件里同步做了重算把重算挪到Scroll结束后用定时器延迟触发。5.3 现象点列头排序一次合并区串到别的单据上或者干脆整体错位原因Sorted事件触发时合并表没重建_map里存的还是旧行号。排序只改了行的显示顺序行号本身没动所以旧索引全部指向错误的行画出来的合并区自然对不上。解决在Sorted、DataBindingComplete、RowsRemoved里统一调RebuildMergeMap()。另外加一道校验RebuildMergeMap之后遍历所有MergeRange检查LastRow Rows.Count越界的直接丢掉。这个检查在调试期很有价值能第一时间发现谁在别处改了行集合却没通知。5.4 现象点合并区的第二行选中框只框住一个格键盘上下键在里面来回跳原因没做命中映射。CurrentCell落到了成员格上而成员格在视觉上「被合并」了感觉就像选中框缩小了。解决CellMouseDown里把CurrentCell归位到锚点3.4 的代码。同时注意SelectionMode的选择FullRowSelect模式下还要处理整行高亮别只改当前格。5.5 现象导出到表格软件后合并区整体错位一行或一列原因网格的RowIndex/ColumnIndex是控件坐标系导出文件的坐标系从表头之后、第一列开始算两者之间差着表头行数和被跳过的前置列数。解决把偏移量做成显式常量headerRowCount和columnOffset各一个写导出循环时强制加上。别想着「反正现在是 1 行表头先写死」等哪天需求变成两级表头这个常量能救命。6. 二次开发把合并逻辑收成可复用扩展的三步验证这套东西写完之后我发现真正需要长期维护的只有两个变量哪些列要合并以及重算时机。剩下的绘制和交互逻辑是稳定的。所以二次开发时我一般不会再动MergePainter只暴露三个扩展点做成一张表贴在团队文档里扩展点位置改什么典型诉求_mergeColumns窗体初始化要合并的列索引集合从两列扩到五列BuildVerticalRangesMergeMap段落的判定规则按「单号 客户」两列组合成键ApplyReadOnlyRulesMergePainter可编辑范围允许成员格编辑并反向拆分组合键那个改动最常见单号相同的行里客户不同就不该合并。把key从单列值改成多列拼接即可string.Join(|, 各列Trim后的值)扫描逻辑一行不用改。验证方法我固定三步缺一步都不敢上线。第一步构造三组数据全相同、全不同、交替相同各两百行滚动看有没有闪烁和文字丢失。第二步点列头排序、删行、重新绑数据源每次操作后肉眼核对合并区边界重点看第一行和最后一行有没有多并或少并。第三步导出后用表格软件打开跟界面并排对比逐块看合并范围。这三步跑下来基本能覆盖 90% 的回归问题。最后说个自己的教训。早期我图省事把重算逻辑只挂在DataBindingComplete上本地测试一直没问题因为测试数据是绑一次就不动的。上了现场用户点了列头排序整个表格的合并直接乱掉客户当场演示给我看那种感觉不太想回忆。从那以后我每次接这类绘制相关的活都强制在Sorted、RowsRemoved、CellValueChanged三个事件里打一遍日志再跑一遍排序确认合并表跟着重建了才继续往下写。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑