
C# WinForms开发里DataGridView是我用得最多的控件之一。做上位机数据采集要展示仪表读数做进销存要维护物料清单做内部工具要做批量录入最后几乎都会落在一张可以编辑、查看的表格上而DataGridView就是这张表格最常见的载体。很多新手一开始只把它当豪华版ListView用拖一个控件、设个DataSource、能显示就完事了。等做到项目中期才发现绑定的方式、列的配法、事件的触发时机、大数据量下的刷新策略每一项都直接影响界面是否卡顿、数据是否一致、操作是否顺手。这篇文章我会从绑定逻辑、列与样式、增删改查套路、性能优化以及这些年踩过的坑这几个方面把DataGridView完整捋一遍给正在写WinForms或上位机界面的朋友一个可以直接参考的清单。1. 为什么WinForms项目里到处是DataGridView1.1 它是什么自带“数据意识”的网格控件DataGridView本质上是一个网格容器由行、列、单元格三层结构组成。和早期常用的ListView相比它的核心优势在于能直接感知数据源的变化。ListView需要你手动拼Column、手动往Items里塞SubItems数据一变就得自己刷新DataGridView绑定了DataTable之后数据源里的行列发生变化表格会自动重绘省掉大量同步代码。我用一个比较土但很贴切的类比DataGridView就是WinForms里内置的微型Excel。它有行号、有列头、单元格可以编辑、可以校验、可以排序、可以过滤、可以冻结列用户对表格的绝大多数操作习惯它都能直接满足。这个控件之所以在WinForms项目里出现频率那么高根本原因是桌面开发里“表格”这个交互形态实在太常用了。做上位机要把PLC、串口、仪器采集到的数据按时间一排排展示出来做管理系统要把数据库里的记录呈现给用户编辑做内部工具要支持把Excel导进来的数据批量核对。这些场景本质上都是二维表格数据的展示和操作DataGridView恰好全部覆盖。1.2 三种最常用的上手指法我第一次接触DataGridView的时候是先拖控件再在属性面板里找DataSource后来才发现实操中数据来源有几种不同玩法方式操作套路适用场景设计器直接拖拽拖控件到窗体属性面板选择数据源快速原型、绑定简单DataTable代码赋值DataSourcedataGridView1.DataSource dt;最常见运行时动态取数手动建列设DataPropertyName关闭AutoGenerateColumns在列设计器里定义列结构需要精确控制列显示、格式、按钮列等场景很多新手喜欢用第一种方式图省事但我建议项目里至少从第二种开始。因为实际开发中数据源几乎都是运行中从数据库、Excel、第三方接口里拿到的设计器绑定反而绑死了一个固定的数据集结构后期改起来很麻烦。第二种方式有个天然好处DataGridView是WinForms里少数“开箱即用”的表格控件。你只需要给它一个DataTable列会自动生成数据会自动填进去甚至排序都是默认支持的。我做第一个上位机界面时从串口收到一组模拟量数据拼成DataTable后一行代码绑上去整个表格立刻就有了那感觉是ListView给不了的。第三种方式是我现在最常用的。AutoGenerateColumns设为false后表格列完全由你控制可以自由指定每一列的显示文本、宽度、格式、是否只读还能插入按钮列、下拉列、图片列。这个模式稍微增加一点配置成本但换来的可控性非常值后面我会专门讲列的配置细节。2. 数据绑定DataSource、BindingSource和DataTable的三者关系2.1 DataSource到底能绑什么很多人对DataSource的理解就是“给它一个东西就能显示”但这个“东西”的类型不同后续的行为差异非常大。我列一下我实际用过的几种DataTable这是最顺滑的选择。DataTable内部有完整的行集合、列集合、行状态管理DataGridView绑定之后双向同步是默认支持的。你在界面上改一个单元格DataTable里对应的DataRow会被直接改掉不需要写任何同步代码。查数据库、读Excel、组装采集数据最终拼成一个DataTable再绑定这条路我走了好几年没有踩过大坑。DataView它本质是DataTable的一个视图用来做排序和过滤。你完全可以绑定dt.DefaultView然后通过view.Sort CreateTime DESC控制显示顺序。唯一要注意的是当你用DataGridView自带的列头点击排序时动的其实是DataView的Sort属性底层DataTable的顺序并不变。ListT和BindingListT绑定实体对象列表最直观。比如有个DeviceInfo类里面有Id、Name、Value三个属性直接把ListDeviceInfo赋值给DataSource表格会自动按属性名生成列。但这里有个很隐蔽的坑普通ListT只读取初始值你在界面上改了单元格数据不会写回对象列表新增行也不会被跟踪。想让前端修改和后端数据真正联动必须用BindingListT并且实体类要实现INotifyPropertyChanged否则改了对象属性界面也不会刷新。所以我能用DataTable的时候一般不折腾List绑定除非是那种纯展示、不编辑的场景。2.2 BindingSource缓冲层的意义很多人不明白为什么要多套一层BindingSource直接dataGridView1.DataSource dt不也一样能显示吗确实能显示但BindingSource解决的是“界面和数据源之间频繁交互”的问题。BindingSource bs new BindingSource(); bs.DataSource dt; dataGridView1.DataSource bs;加这一层之后三个常用功能变得非常顺手过滤bs.Filter Status 启用本质是走了DataView.RowFilter一行代码完成行筛选不用自己遍历DataTable加行删行。排序bs.Sort CreateTime DESC代码排序比点击列头排序更可控。定位通过bs.Current拿到当前行对应的DataRowView再取字段值比通过dataGridView1.CurrentRow绕一层更稳定。还有一个藏在细节里的好处当多个控件要联动同一个数据源时BindingSource可以作为公共纽带。比如主表选中一行从表显示该行关联的明细两个DataGridView绑到同一个BindingSource的不同视图上联动逻辑会简化很多。我做物料管理界面时就是主表一个DataGridView、明细一个DataGridView中间靠一个BindingSource串起来切换主表行时明细自动跟随代码量很少。2.3 绑定时最容易忽略的三件事第一件是AutoGenerateColumns。手工建列之后一定要把dataGridView1.AutoGenerateColumns false;关掉否则绑定时系统会自动生成一遍和DataTable列对应的列和手工列重复叠加表格立刻乱掉。这个错误我见过不止一次排查起来还特别容易懵因为列的来源不是一个地方。第二件是切换DataSource前要Columns.Clear()。如果你在一个窗口里反复换绑不同的DataTable比如切换查询条件后重新取数再绑定上一张表生成的列不会自动消失。正确顺序是先清列、再设DataSource否则列头越叠越多。dataGridView1.AutoGenerateColumns false; dataGridView1.Columns.Clear(); // 手工添加列... dataGridView1.Columns.Add(colId); dataGridView1.Columns.Add(colName); dataGridView1.DataSource dt;第三件是DataPropertyName的映射。手工建列时列的显示文本和字段名是两回事HeaderText决定表头显示什么DataPropertyName决定这一列绑定DataTable里的哪个字段。DataPropertyName拼错一个字母那一列就是整列空白而且不报错非常坑。我习惯把DataPropertyName写得和DataColumn名完全一致并用字段名的字符串常量来引用避免手滑。3. 把表格调成“能见人”的样子列类型、样式与单元格细节3.1 列类型怎么选不同列承载不同交互DataGridView的列不只是展示文本它有一套完整的列类型体系不同列类型对应不同的用户交互方式。我整理一个表方便对照列类型交互效果常见用途DataGridViewTextBoxColumn文本显示和编辑默认主力列DataGridViewCheckBoxColumn复选框是否启用、是否完成等布尔字段DataGridViewButtonColumn单元格内按钮编辑、删除、导入弹窗入口DataGridViewComboBoxColumn下拉选择状态枚举、外键关联DataGridViewImageColumn图片显示缩略图、签名字迹DataGridViewLinkColumn链接样式的文本点击跳转、查看详情ComboBox列有个配置要点它需要单独设置DataSource、DisplayMember和ValueMember。比如要显示“启用/停用”可以绑一个小DataTableDisplayMember是显示文本列ValueMember是实际存储值列单元格里显示的是文本存到数据源里的是值。这个逻辑和普通WinForms的ComboBox是一致的理解了就不难。按钮列值得多说一句。按钮的点击事件不是Button的Click而是DataGridView的CellClick或CellContentClick。我一般用CellClick在里面判断点击的列是不是按钮列private void dataGridView1_CellClick(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex 0) return; if (e.ColumnIndex btnEditColumn.Index) { DataRowView rowView dataGridView1.Rows[e.RowIndex].DataBoundItem as DataRowView; if (rowView ! null) { // 打开编辑窗口把当前行字段传过去 } } }有个容易踩的细节点击按钮列时如果只是点到按钮文字区域用CellContentClick没问题但用户如果点到按钮边缘的空白处CellContentClick不触发而CellClick会触发。为了体验一致我都用CellClick。3.2 行号、冻结列、只读与格式化行号DataGridView左侧的RowHeaders区域默认是空白没有数字。要显示行号处理RowPostPaint事件自己把行号画上去private void dataGridView1_RowPostPaint(object sender, DataGridViewRowPostPaintEventArgs e) { Rectangle rect new Rectangle( e.RowBounds.Location.X, e.RowBounds.Location.Y, dataGridView1.RowHeadersWidth - 4, e.RowBounds.Height); TextRenderer.DrawText(e.Graphics, (e.RowIndex 1).ToString(), dataGridView1.RowHeadersDefaultCellStyle.Font, rect, dataGridView1.RowHeadersDefaultCellStyle.ForeColor, TextFormatFlags.Right | TextFormatFlags.VerticalCenter); }这段代码我几乎每个项目都复用。注意前提是RowHeadersVisible为true否则左侧区域都不存在画也没地方画。冻结列当表格列很多、需要横向滚动时把关键列钉在左侧。做法极简单dataGridView1.Columns[Id].Frozen true;设置之后这一列及其左侧所有列冻结滚动时固定不动。我处理几十列的采集表格时通常把“设备编号”和“时间”两列冻结用户滚动看其他数据时始终知道当前行是哪个设备哪个时刻。只读三级控制。整表只读设置ReadOnly true适合纯报表展示单独列只读设置列的ReadOnly特殊单元格只读可以单独设置某个单元格。三个层级各自独立比较灵活。注意如果只想禁止用户新增行不要用只读而是设AllowUserToAddRows false两者的语义完全不同。格式化这个很多人忽略。日期列和数字列如果不设格式显示的就是DataTable里的原始值可能是“2024/5/6 0:00:00”这种很难看的格式。在列上设置DefaultCellStyle.Format yyyy-MM-dd HH:mm:ss和Format 0.00就能解决。但有个前提DataTable里这一列的类型必须是DateTime和decimal如果你图方便全用string类型存数据格式化是不生效的只能是原始字符串。所以建DataTable时列类型一定要定义准确这一步的收益会体现在后续所有环节。3.3 隔行变色、表头样式、ToolTip与合并单元格隔行变色是让表格“专业感”提升最明显的一个设置在属性面板或代码里设置AlternatingRowsDefaultCellStyle即可。我一般把奇数行背景设为浅灰Color.FromArgb(245, 245, 245)阅读长表格时不容易串行。表头样式同样可以批量设置。ColumnHeadersDefaultCellStyle里可以控制背景色、字体、对齐方式。做上位机界面时我习惯把表头背景设为深色、文字白色一整排深色表头比默认灰白表头显得整洁很多。ToolTip在单元格内容被截断时非常有用。默认情况下单元格内容超过列宽会直接显示不全用户不知道完整内容。处理CellToolTipTextNeeded事件返回该单元格完整值鼠标悬停就能看到全部内容private void dataGridView1_CellToolTipTextNeeded(object sender, DataGridViewCellToolTipTextNeededEventArgs e) { if (e.RowIndex 0 e.ColumnIndex 0) { object val dataGridView1.Rows[e.RowIndex].Cells[e.ColumnIndex].Value; if (val ! null) { e.ToolTipText val.ToString(); } } }合并单元格是DataGridView的痛点它没有原生合并能力。我处理“跨行相同值合并显示”这类需求时用的是CellPainting事件自己画先绘制单元格内容和边框再绘制覆盖分隔线把多行同值区域画成一体。这个方案细节多如果项目里确实需要我的建议是单独封装一个合并单元格的工具类不要在每个窗口里重复写绘制逻辑。4. 增删改查的实操套路取值、校验、联动与合计4.1 拿到当前正在编辑的这一行DataGridView里的“当前行”有两个表达方式CurrentRow表示当前选中行CurrentCell.RowIndex表示当前焦点单元格所在行。绝大多数场景两者指向同一行但焦点在单元格里时CurrentRow可能已经是另一行了稳妥做法是通过dataGridView1.CurrentCell.RowIndex获取再校验行索引大于等于0。这里有个细节表头点击、空白区域点击都有可能让RowIndex变成-1取值前一定要判断。绑定了DataTable之后row.DataBoundItem是一个DataRowView通过它可以安全地取字段DataGridViewRow row dataGridView1.Rows[e.RowIndex]; DataRowView rowView row.DataBoundItem as DataRowView; if (rowView ! null) { int id Convert.ToInt32(rowView[Id]); string name rowView[Name].ToString(); }这套写法在上位机里很常用从表格选中一行采集记录点击“趋势图”按钮把时间范围、设备ID传给图表窗口就是从DataRowView里取字段开始的。编辑完成时的取值我用CellEndEdit事件。它触发时单元格的新值已经提交可以放心读取。如果是在CellValueChanged里取值要注意它可能在编辑还没完全结束时触发某些情况下拿到的还是旧值或者中间态。4.2 新增和删除两种姿势别搞混DataGridView的新增有“用户操作”和“代码控制”两条路线。用户操作方面只要不关闭AllowUserToAddRows表格最后一行永远是一个带星号的新行。用户在新行里输入数据触发UserAddedRow事件。我习惯在这个事件里给新行写默认值比如创建时间直接填当前时刻、状态默认“待处理”省得用户手填。删除行则靠AllowUserToDeleteRows控制用户按Delete键删除选中行。代码控制方面编程添加行经常有人用dataGridView1.Rows.Add(...)但只要设置了DataSource这个方法是不能用的或者用了也是错的因为行集合实际上是数据源映射出来的。正确做法是往数据源里加界面自动刷新DataRow newRow dt.NewRow(); newRow[Name] 新设备; newRow[Value] 0; dt.Rows.Add(newRow);删除也是一样的逻辑。绑定DataTable时不要直接dataGridView1.Rows.Remove(row)应该先把对应的DataRow标记删除再统一提交DataRowView rowView dataGridView1.Rows[e.RowIndex].DataBoundItem as DataRowView; rowView.Row.Delete(); dt.AcceptChanges();这套“改数据源、界面自动跟随”的思路是DataGridView和ListView最本质的区别也是我希望所有新手一开始就建立的习惯。4.3 行级校验与下拉列即时取值行级校验是DataGridView比较有特点的机制。处理RowValidating事件通过e.Cancel拦截行离开。比如金额不能为负、必填项不能为空private void dataGridView1_RowValidating(object sender, DataGridViewCellCancelEventArgs e) { DataGridViewRow row dataGridView1.Rows[e.RowIndex]; if (row.Cells[Amount].Value null || Convert.ToDecimal(row.Cells[Amount].Value) 0) { row.ErrorText 金额必须大于0; e.Cancel true; } else { row.ErrorText ; } }设置row.ErrorText之后行头会出现一个错误图标鼠标悬停会显示错误信息这是WinForms自带的交互反馈体验挺完整的。下拉列有个非常经典的坑DataGridViewComboBoxColumn里选完值后CellValueChanged事件没有触发取不到新值。原因是ComboBox的修改没有被立即标记为“单元格脏状态”要手动提交一次。解决方案是同时处理CurrentCellDirtyStateChanged事件private void dataGridView1_CurrentCellDirtyStateChanged(object sender, EventArgs e) { if (dataGridView1.IsCurrentCellDirty) { dataGridView1.CommitEdit(DataGridViewDataErrorContexts.Commit); } }加上这段之后下拉列一选完CellValueChanged会按预期触发再在里面读取新值、联动其他列就顺了。4.4 联动与底部合计别写成一坨屎联动指的是表格和其他控件的交互。最常见的场景点击表格行右侧一堆文本框和下拉框同步显示该行字段供用户细看或编辑。实现方式很简单CellClick或SelectionChanged里取当前行字段逐个赋值给控件。要注意的是大写锁死如果表格行很多SelectionChanged触发频率很高赋值操作要轻量不要在事件里做数据库查询之类的重活。底部合计是业务系统里特别常见的需求。我的做法是维护一个合计数据不遍历表格。在CellValueChanged、RowsAdded、RowsRemoved事件里做增量更新某一行金额变化了合计加上差值新增行加进来合计加上新值删除行后合计减掉原值。这样无论表格多少行计算量都是O(1)不会卡。如果合计是实时变化的建议把合计显示在独立的Label或状态栏上不要塞进DataGridView的新行里否则新增行的逻辑会和合计行打架处理起来非常别扭。5. 大数据量与上位机刷新性能优化和UI线程管理5.1 为什么循环Add会卡布局重算的代价很多人在给表格灌数据时习惯这么写foreach (var item in list) { dataGridView1.Rows.Add(item.Name, item.Value); }几百行数据没感觉一两千行就开始卡等到上万行基本就是肉眼可见的迟钝了。原因是每Add一行DataGridView都要重新计算布局、重绘一遍。行越多重绘越慢形成一个恶性循环。正确做法是先把数据全组装进DataTable再一次性赋值给DataSource。赋值前挂起布局计算赋值后恢复一次重绘dataGridView1.SuspendLayout(); try { dataGridView1.DataSource dt; } finally { dataGridView1.ResumeLayout(); }我实测过一个场景用循环Rows.Add插入一万行需要几秒换成DataTable绑定后基本是瞬间完成。对于大部分中小型业务系统这一步优化就够了。5.2 十万行以上的正规解法VirtualMode如果你真的需要展示几万、几十万行数据绑定模式已经不是最优解了。DataGridView的虚拟模式VirtualMode就是为这种场景设计的。开启虚拟模式后控件不真实持有所有行的数据只在需要显示某一格时触发CellValueNeeded事件从你维护的数据缓存里按行索引、列索引取对应的值dataGridView1.VirtualMode true; dataGridView1.RowCount 1000000; private void dataGridView1_CellValueNeeded(object sender, DataGridViewCellValueEventArgs e) { e.Value myDataCache.GetValue(e.RowIndex, e.ColumnIndex); }这样内存占用和绘制开销都大幅下降滚动时只在可见区域取数据。代价是你需要自己维护一个按行号索引的数据缓存并且如果允许编辑还要处理CellValuePushed把修改写回缓存。虚拟模式适合有一定经验的开发者新手项目如果没到十万行级别不建议第一时间上这个方案。5.3 上位机定时刷新不闪屏的做法上位机是DataGridView性能问题的高发区因为要定时刷新。常见的错误做法是每轮刷新都重新new一个DataTable再绑一次这样会导致滚动位置丢失、选中行丢失、界面闪烁而且拖拽卡顿。我的做法是更新已有行不重建数据源。具体来说采集数据到达后按设备ID找到DataTable里对应的行直接改行里的字段值如果设备数量超过了几百个再用一个字典做“设备ID到行号的映射”避免每次遍历查找。这样界面几乎不闪因为改单元格值只触发局部重绘。关于闪烁本身DataGridView默认没有双缓冲滚动时会有明显闪烁。解决方式有几种最简单的用反射把DoubleBuffered属性打开typeof(DataGridView).InvokeMember( DoubleBuffered, BindingFlags.SetProperty | BindingFlags.Instance | BindingFlags.NonPublic, null, dataGridView1, new object[] { true });更规范的做法是自定义一个继承DataGridView的类构造函数里设置DoubleBuffered true。两种我都在项目里用过效果都能明显减少滚动闪烁。另外列宽自适应是个性能杀手。AutoSizeColumnsMode Fill或者AllCells时每一行数据变化都可能触发列宽计算行数一多代价极高。做高频刷新界面时把AutoSizeColumnsMode设为None手动定好每列宽度刷新效率会提升一大截。5.4 跨线程更新DataGridView别碰那个红叉叉上位机和串口、PLC通信时数据是在工作线程里收到的直接在工作线程里操作dataGridView1轻则界面不刷新重则抛出“线程间操作无效”的异常。正确姿势是把更新逻辑通过BeginInvoke封送到UI线程执行private void OnPlcDataArrived(ListDeviceData datas) { if (dataGridView1.InvokeRequired) { dataGridView1.BeginInvoke(new Action(() UpdateGrid(datas))); return; } UpdateGrid(datas); }BeginInvoke是异步的不阻塞通信线程适合高频采集场景。如果更新频率过高导致UI线程处理不过来可以在更新方法里先判断一下是否有更新任务在排队或者减少刷新频率比如每200毫秒刷新一次界面而不是每收到一条数据就刷一次。这些都是我在实际项目里调出来的经验单纯把InvokeRequired的判断加上只是第一步刷新节流才是上位机界面流畅的关键。大批量导入Excel时的进度条也是同一个思路用BackgroundWorker在后台解析Excel并填充DataTableProgressChanged事件里更新ProgressBarRunWorkerCompleted事件里再一次性绑定DataGridView。不要在UI线程里做文件解析否则界面直接卡死进度条也没意义。6. 我踩过的DataGridView的坑按排查顺序整理6.1 列重复了每次重新绑定DataSource时没清Columns这应该是DataGridView最常见的坑之一。症状很明显同一个窗口里第一次绑定表格正常第二次换数据源后表头出现两套列一套是自己手工建的一套是自动生成的。第一次我也是一脸懵后来想通了绑定新DataSource时系统会把新数据源的所有列追加到列集合里旧列还在。解决在设置DataSource之前先dataGridView1.Columns.Clear()同时确保AutoGenerateColumns为false。这个顺序不能反反了旧列还是会被留下。现在我写绑定的通用方法时清列、关自动生成、手工加列、绑定数据源这四个动作永远是按顺序连在一起的。6.2 下拉框选完值CellValueChanged就是不触发这个坑特别折磨人因为代码看起来完全正确事件写了、列配了、DataPropertyName对上了但选完下拉后事件就是不来。原因前面提过ComboBox列的修改不会立即提交给单元格需要先手动CommitEdit。正确做法是同时处理CurrentCellDirtyStateChanged和CellValueChanged前者负责把编辑提交掉后者负责处理新值。顺带说一下普通TextBox列没有这个问题所以如果TextBox列的事件正常、Combo列不正常第一时间就要想到这个机制差异。6.3 红感叹号和DataError格式不匹配的灾难单元格出现红色感叹号鼠标放上去提示格式错误这是DataError事件的舞台。触发场景通常是单元格值类型和列要求不匹配、数据库里返回了DBNull、日期格式解析失败。我在项目里统一处理DataError事件不让异常直接抛到界面上private void dataGridView1_DataError(object sender, DataGridViewDataErrorEventArgs e) { e.ThrowException false; // 这里记录日志比如把行号、列号、原始值都写下来 }注意这只是兜底真正的问题还是要从源头解决DataTable列类型定义准确、空值在填充时处理为DBNull.Value或者空字符串、格式化错误提前校验。红感叹号看着像小问题但在客户现场出现得多了用户会对软件质量产生很大怀疑。6.4 排序后行索引错乱拿错数据就是从这里开始的用户点击列头排序后DataGridView显示的行顺序变了但如果你用e.RowIndex直接去数据源里取记录可能取到的是旧顺序的另一行数据。尤其做删除操作时删A行删成了B行。根本原因DataGridView排序只影响“视图顺序”不一定同步到你绑定数据源的行顺序。解决思路有两个一是排序统一交给数据源层也就是用DataView.Sort或BindingSource.Sort让视图和数据源保持同一个顺序二是取当前行时不要依赖行索引而是通过DataBoundItem取出唯一标识字段再做后续操作。两者结合基本能避免所有排序错乱问题。6.5 数字列排序像字典10比9小有一次用户反馈表格里的“数量”列排序是乱的10排在2前面。原因很简单如果DataTable里该列被定义成了string类型DataGridView默认按字符串排序字符串排序就是字典序1开头的永远排在2前面。解决建DataTable时把“数量”定义成int或decimaldt.Columns.Add(Qty, typeof(decimal))排序就自然变数字序了。如果数据从Excel导入时已经被读成字符串需要在填充前转换类型。这一条也可以用来解释为什么DataTable列类型定义这么重要它影响的不只是格式化还有排序行为。6.6 手动Rows.Add和DataSource绑定的冲突绑定数据源之后还想手动Rows.Add塞一行“特殊数据”结果不是异常就是看似成功实际没生效。这是因为DataGridView一旦绑定了DataSourceRows集合就变成了数据源的映射你不能直接往里塞。正确做法是往数据源里加行。如果是临时想加一行不计入数据源的“说明行”我的建议是别在DataGridView里做改用表格下方的Panel放一个Label或者单独做一个合计区域不要和DataGridView的行机制对抗。最后分享一个我自己的习惯。我在项目里把所有DataGridView的通用逻辑收进了一个GridHelper静态类行号绘制、DataError日志、双击复制单元格、回车跳到下一格都封装起来新窗口拉一个DataGridView进来直接调用。这么做之后后续项目的表格代码量明显下降至少我不用每张表都重写一遍事件。DataGridView是个老控件网上资料很多但实际项目里真正值钱的往往是这些边界情况和触发时机。希望这篇总结能帮你少走一点弯路尤其是正在写上位机或者数据录入界面的朋友遇到表格问题的时候可以回来翻一翻。