简介这是一份面向C#开发者的DataGridView直接修改数据示例资源适合在.NET WinForms项目中需要实现表格内编辑、校验与数据同步的开发人员。资源包共28个文件以9个C#源码文件为核心搭配项目配置文件、可直接运行的exe及编译辅助文件整体仅53KB轻量易用。已有918人学习下载。通过该资源可掌握DataGridView的编辑模式设置、CellEndEdit与CellValidating事件处理、EndEdit同步数据源以及自定义列编辑控件等关键技巧附带完整WinForms工程结构源码与可执行程序齐全便于直接调试运行或移植到自身项目中能有效提升表格数据交互的开发效率。1. DataGridView 直接修改数据第一步是搞明白它会改到哪一层先说一个反直觉的结论DataGridView 默认就是可编辑的你只要绑定了数据源直接在单元格里敲字值就已经写到了内存数据上。但大多数人把它当只读报表用是因为从“能敲字”到“能安全地修改并提交数据”之间还差着一层对编辑模式、数据源类型、事件提交时机的理解。这篇笔记要解决的问题就是让 DataGridView 从展示控件变成一个真正能用的编辑器包括怎么开启直接修改、怎么把 List 里的 0/1 显示成 CheckBox 再正确回写以及那些让你改了数据却存不进去的经典坑。适合正在做 WinForms 数据维护界面、想把表格直接当表单用的朋友。2. 开启可编辑的三种配置ReadOnly、EditMode 与数据源类型怎么搭配2.1 ReadOnly 和 EditMode 的优先级先过两遍再动手DataGridView 用法里最容易让人误判的就是 ReadOnly原因在于它有三层网格级、列级、单元格级。很多人只把dataGridView1.ReadOnly false设完就开始写事件结果发现某一列死活进不了编辑排查半天发现是设计器里那一列被单独勾了 ReadOnly。反过来也有人把网格级 ReadOnly 设为 true还想让某一列能编辑这是做不到的——网格级优先它一锁下面两层直接失效。实际优先级是网格级大于单元格级再大于列级。单元格如果没有显式设置 ReadOnly会沿列传下来的值。我一般把网格级 ReadOnly 统一设为 false只在列上做限制这样界面逻辑清晰不会出现“明明没勾只读某几行却改不了”的玄学问题。// 网格级只读一旦置 true所有列都不能编辑 dataGridView1.ReadOnly false; // 默认就是 false // 编辑模式单击单元格就进入编辑做内部工具很顺手 dataGridView1.EditMode DataGridViewEditMode.EditOnEnter; // 列级只读业务字段可改主键和创建时间这类字段锁死 dataGridView1.Columns[Id].ReadOnly true; dataGridView1.Columns[CreateTime].ReadOnly true; // 单元格级只读某种状态下单独锁某行比如已审批的行 dataGridView1.Rows[3].Cells[Remark].ReadOnly true;这里要留意 EditMode 的四个选项。EditOnKeystrokeOrF2是默认值适合宽表输入字符或按 F2 才进编辑EditOnEnter是鼠标点进去就编辑窄表用起来像表单但单击选中也会触发编辑误触率高一点EditOnF2Only只在按 F2 时进编辑适合不允许误改的场景EditProgrammatically则完全由代码调用BeginEdit(true)控制入口。对“直接修改数据”这个需求我一般选EditOnEnter原因很简单用户点进去就能改不需要多余操作。2.2 数据源类型决定修改能不能回写DataTable 与 List 的区别DataGridView 本身不存数据它只是一面镜子能不能直接在表里改、改了能不能保留取决于镜子里映的是谁。常见做法有这三种数据源行为差别很大DataTable每个单元格编辑结束后值直接写进对应的 DataRowRowState变成Modified这是最名副其实的“直接修改”。List 如果 T 是引用类型且属性带 public setter编辑已有行的值也会写回属性但 List 不通知界面也不支持添加新行。BindingList 支持添加新行、删除行还带列表变更通知是 List 的推荐替代。很多人踩的第一个坑是绑了个 List发现能改单元格但点保存后重新打印 List值还是旧的。前面说了引用类型且有 setter 时其实能写回遇到真没写回的情况基本是三种T 是 struct、属性没有 setter、或者绑的是 LINQ 投射出来的匿名集合。匿名类型属性都是只读的只能看不能写。另外 struct 绑定是经典陷阱DataGridView 操作的是列表项的副本改了副本原列表纹丝不动属于改完直接怀疑人生的类型。// 方案一DataTable 绑定改完立刻落在 DataRow 上 DataTable dt new DataTable(); dt.Columns.Add(Id, typeof(int)); dt.Columns.Add(Name, typeof(string)); dt.Columns.Add(Status, typeof(int)); dt.Rows.Add(1, 张三, 0); dt.Rows.Add(2, 李四, 1); dataGridView1.DataSource dt; // 用户在界面把张三改成张五之后 // dt.Rows[0][Name] 已经是张五RowState 是 Modified // 方案二BindingListT 绑定支持增删行且能通知网格刷新 BindingListTaskItem list new BindingListTaskItem(); list.Add(new TaskItem { Id 1, Status 0, Name 任务一 }); dataGridView1.DataSource list; public class TaskItem { public int Id { get; set; } public string Name { get; set; } public int Status { get; set; } }从代码里能看出一个关键差异DataTable 方案改完不需要再做任何同步数据源已经变了BindingList 方案里只要TaskItem的Status属性有 setter编辑也会写回。但注意BindingListT要求 T 必须有无参构造函数否则网格底部的新行功能会在添加时报异常这一点放到后面避坑部分再展开。2.3 最小可运行示例绑定 DataTable 后直接编辑并取回改动把上面的知识点落成一个最小可运行的例子。需求是界面上直接改点保存按钮后只把有改动的行取出来提交。这个模式特别适合做内部数据维护工具性能也好不用每次全量更新。private void Form1_Load(object sender, EventArgs e) { dataGridView1.DataSource BuildTable(); } private DataTable BuildTable() { DataTable table new DataTable(); table.Columns.Add(Id, typeof(int)); table.Columns.Add(Status, typeof(int)); // 0 未开始1 已完成 table.Columns.Add(Remark, typeof(string)); table.Rows.Add(1, 0, 准备资料); table.Rows.Add(2, 1, 已验收); return table; } private void buttonSave_Click(object sender, EventArgs e) { // 结束当前单元格的编辑把界面上未提交的值推进 DataTable dataGridView1.EndEdit(); DataTable changed ((DataTable)dataGridView1.DataSource).GetChanges(); if (changed ! null changed.Rows.Count 0) { // 这里每行的 RowState 是 Added / Modified / Deleted // 用 SqlDataAdapter.Update(changed) 或你自己的落库逻辑 MessageBox.Show($有 {changed.Rows.Count} 行被修改); } else { MessageBox.Show(没有修改); } }这段代码里有几个点容易出问题。第一EndEdit()必须放在拿GetChanges()之前否则编辑框里还悬着没提交的值会被丢掉。第二GetChanges()返回的 DataTable 只包含改动行如果用户只改了一行这个表里就一行直接交给适配器做更新最合适。第三如果用户新增了行RowState是Added删除行则是Deleted。这里的“直接修改”其实是两层界面改的是 DataTableDataTable 再带着状态去数据库中间这层状态就是你做增量更新的凭据。3. 把 List 的一列 0/1 显示成 CheckBox两个方案与 3 个必调参数3.1 先看错误示范把 int 直接绑到 CheckBox 列为什么会翻车WinForms 里常见的需求场景是数据库存的是 0/1 整数但界面上希望用 CheckBox 来展示和编辑比如“启用”“完成”这种语义。很多人第一反应是加一列DataGridViewCheckBoxColumn然后DataPropertyName直接指到 int 属性上结果发现要么显示成空白方框要么一操作就弹 DataError要么勾选状态和值对不上。这是因为 DataGridViewCheckBoxCell 默认期望的值类型是 bool你拿 int 喂进去格式化那一步就过不去。// 错误示范把 int 属性直接绑到 CheckBox 列 var list new ListTaskItem { new TaskItem { Id 1, Status 1, Name 完成 }, new TaskItem { Id 2, Status 0, Name 未完成 } }; dataGridView1.AutoGenerateColumns false; var colCheck new DataGridViewCheckBoxColumn { DataPropertyName Status, // Status 是 int HeaderText 完成, TrueValue 1, // 试图让 1 映射为勾选 FalseValue 0 }; dataGridView1.Columns.Add(colCheck); dataGridView1.DataSource list; // 结果大概率触发 DataError勾选状态对不上甚至整列空白这段代码的问题在于TrueValue和FalseValue只是给 DataGridView 一个“显示值”的映射参考底层绑定模型在把单元格值解析成 int 属性时仍然会按 bool 去处理类型不一致就会出现解析异常。不要在这种错误示范上花时间调参老老实实用下面两个稳定方案之一。3.2 方案一实体加一个 bool 转换属性UI 和数据库各取所需最省心的做法是在实体类里加一个专门的 bool 属性让界面只跟 bool 打交道数据库字段Status继续保留 int。这个方案的好处是双向转换逻辑收在实体内部网格和业务层都不用关心转换细节。public class TaskItem { public int Id { get; set; } // 数据库字段0 未开始1 已完成 public int Status { get; set; } // 界面用的布尔属性get 时把 int 转 boolset 时把 bool 转回 int public bool IsDone { get Status 1; set Status value ? 1 : 0; } }绑定的时候把 CheckBox 列的DataPropertyName指到IsDone而不是Status。Status列如果不显示就干脆不加入 DataGridView 的列集合或者给它加[Browsable(false)]特性防止自动列把它带出来。var list new BindingListTaskItem { new TaskItem { Id 1, Status 0 }, new TaskItem { Id 2, Status 1 } }; dataGridView1.AutoGenerateColumns false; dataGridView1.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName Id, HeaderText 编号 }); dataGridView1.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName Name, HeaderText 任务 }); dataGridView1.Columns.Add(new DataGridViewCheckBoxColumn { DataPropertyName IsDone, HeaderText 完成 }); dataGridView1.DataSource list; // 用户勾选后Status 自动变成 1取消勾选后Status 自动变成 0这里有个隐含的同步逻辑勾选 CheckBox 后IsDone的 setter 被调用里面把Status赋值为 1。所以保存时你读的是Status界面显示的是IsDone两边互不干扰。如果是 MySQL/SQLite 这类没有真正 bool 字段的库这个方案也是最贴合的实体层做一次映射数据访问层零改动。3.3 方案二用 CellFormatting 与 CellParsing 做 0/1 转换有些场景下实体类是外部程序集里的不方便加转换属性或者你不想为了界面显示去污染领域模型。这时候可以用 DataGridView 的两个事件在 UI 层做转换CellFormatting负责把 int 转成 bool 给 CheckBox 显示CellParsing负责把用户勾选的结果转回 int 写回数据源。private void dataGridView1_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { // 只处理 Status 列避免影响其他列 if (dataGridView1.Columns[e.ColumnIndex].DataPropertyName Status) { e.Value Convert.ToInt32(e.Value) 1; // int - bool供 CheckBox 显示 e.FormattingApplied true; // 告诉网格格式已处理别再用默认转换 } } private void dataGridView1_CellParsing(object sender, DataGridViewCellParsingEventArgs e) { if (dataGridView1.Columns[e.ColumnIndex].DataPropertyName Status) { e.Value (bool)e.Value ? 1 : 0; // bool - int写回数据源 e.ParsingApplied true; } }这个方案的关键是FormattingApplied和ParsingApplied两个标记。如果不把它们设为 trueDataGridView 会继续执行它的默认类型转换你转换完的值可能又被覆盖一次。CellParsing里的e.Value进入事件时是用户编辑控件的原始值对于 CheckBox 列就是 bool赋值成 int 后配合ParsingApplied true才能保证最终写进属性的是 0/1 而不是 true/false。用事件方案时列配置要简单一点把DataPropertyName指回Status不再需要额外的 bool 属性。相对实体方案它的缺点是校验和转换逻辑分散在 UI 层换一个界面就要重写一遍适合实体类不可改的存量系统。3.4 DataGridViewCheckBoxColumn 的 3 个必调参数与提交时机不管用上面哪个方案DataGridViewCheckBoxColumn本身有三个参数需要过一遍。第一个是ThreeState除非业务里确实需要“未设置”这种中间态否则保持false三态 CheckBox 很难看且容易让用户困惑。第二个是TrueValue和FalseValue在绑定 bool 属性时不需要额外设置默认就是 bool 的 true/false只有当绑定源是非 bool 类型时才有必要用这两个参数告诉网格哪些值对应勾选和未勾选。第三个是DataPropertyName必须精确匹配绑定的属性名大小写不敏感但拼错就直接变成空列。var colCheck new DataGridViewCheckBoxColumn { DataPropertyName IsDone, HeaderText 完成, ThreeState false, TrueValue true, FalseValue false, FlatStyle FlatStyle.System // 视觉上更贴近系统原生 CheckBox };注意FlatStyle.System在某些皮肤类库下会有兼容问题如果发现勾选态显示异常改回FlatStyle.Standard。提交时机上有个比参数更隐蔽的坑CheckBox 列被点击后默认不会立刻把勾选状态提交给数据源而是停留在“编辑中”的脏状态。你必须在CurrentCellDirtyStateChanged事件里主动调用CommitEdit否则会出现界面显示已勾选、绑定对象里的值却一直是 0 的诡异现象这个细节放在下一章专门讲。4. 让修改真正落库编辑事件链、单元格校验与行级校验4.1 从 CellBeginEdit 到 CellEndEdit编辑生命周期里谁先谁后DataGridView 的编辑过程不是“用户敲完字就结束了”它有一套固定的事件顺序。用户进入编辑触发CellBeginEdit修改过程中数据还在编辑控件里没有进单元格的 Value直到结束编辑时触发CellValidating做校验通过后触发CellValidated再触发CellEndEdit此时值才真正被写进单元格。理解这个顺序对调试“改了等于没改”的问题至关重要。// 进入编辑干净地清掉上一轮的错误提示 dataGridView1.CellBeginEdit (s, e) { dataGridView1.Rows[e.RowIndex].ErrorText ; }; // 结束编辑值已经写进单元格这里可以拿最终值做后置处理 dataGridView1.CellEndEdit (s, e) { object newValue dataGridView1.Rows[e.RowIndex].Cells[e.ColumnIndex].Value; Console.WriteLine($第 {e.RowIndex} 行第 {e.ColumnIndex} 列的新值: {newValue}); };要注意CellEndEdit触发时单元格的 Value 已经是新值但如果你用 DataSource 绑定这个新值也已经被推送到数据源了。所以这里不适合做“值没变就撤销”的判断那应该在CellValidating里处理。还有一个容易忽略的点CellBeginEdit会先于CellValidating触发所以把上一轮的ErrorText清掉放在这里是对的如果你放在CellEndEdit里清错误提示会在界面上残留一整轮编辑操作。4.2 CurrentCellDirtyStateChanged为什么 CheckBox 勾选后数据源没变这是 DataGridView 直接修改数据最经典的一个坑必须单独讲。对于 TextBox 列用户输入字符后按回车或点别处编辑控件里的文本会提交给单元格然后走事件链写回数据源。但 CheckBox 列不一样它的编辑控件就是那个方框点击后 DataGridView 不会自动认为“编辑完成”并提交而是把它标记为脏状态等焦点真正离开单元格才提交。如果在手机界面上看起来正常但保存时发现绑定的 List 里全是旧值十有八九是这个坑。解决办法是在CurrentCellDirtyStateChanged事件里主动提交private void dataGridView1_CurrentCellDirtyStateChanged(object sender, EventArgs e) { if (dataGridView1.IsCurrentCellDirty) { // 把 CheckBox 编辑控件里的勾选状态提交给单元格 // 这样 CellValueChanged 才会触发值才会写回数据源 dataGridView1.CommitEdit(DataGridViewDataErrorContexts.Commit); } }CommitEdit(DataGridViewDataErrorContexts.Commit)的语义是“把当前编辑值提交”第二个参数是错误上下文一般传Commit就行。提交后用户勾选的动作会立刻触发CellValueChanged进而写进绑定的实体或 DataRow。如果忘了这段代码CheckBox 的状态只有在用户点击了别的单元格后才会提交表现为“勾完没有立即保存离开这行才生效”。这个行为在单行编辑时最容易让人误判属于血泪经验。4.3 CellValidating 做单元格级校验错误值不再能溜走数据能改是一回事改错还不能被发现是另一回事。单元格级校验的入口就是CellValidating它发生在值提交之前此时e.FormattedValue是编辑控件里的内容你校验的就是它。如果校验不通过设置e.Cancel true用户就按不出这个单元格系统会一直停在编辑状态直到输入合法值或按 Esc 取消编辑。private void dataGridView1_CellValidating(object sender, DataGridViewCellValidatingEventArgs e) { // 只校验 Age 列 if (dataGridView1.Columns[e.ColumnIndex].Name ! Age) return; string input e.FormattedValue?.ToString()?.Trim(); if (!int.TryParse(input, out int age) || age 0 || age 120) { dataGridView1.Rows[e.RowIndex].ErrorText 年龄必须是 0~120 的数字; e.Cancel true; // 阻止用户离开这个单元格 } } private void dataGridView1_CellValidated(object sender, DataGridViewCellEventArgs e) { // 校验通过后清掉行错误提示避免残留 dataGridView1.Rows[e.RowIndex].ErrorText ; }这里要注意ErrorText的清除时机。如果你只在CellValidating里设置ErrorText而不在CellValidated里清空那用户改对之后行头的小图标还挂着悬停提示还是上一条错误很容易让人以为数据没保存成功。还有一点CellValidating里不要读写当前正在编辑的单元格的Value因为此时值还没提交你读到的可能是上一轮的旧值应该只读e.FormattedValue。4.4 RowValidating 做行级校验新增行要单独放过有些规则是行级的比如“同一行里完成时间不能早于开始时间”。这种校验放在RowValidating里最合适它在单元格校验全部通过、行即将失去焦点时触发。private void dataGridView1_RowValidating(object sender, DataGridViewCellCancelEventArgs e) { // 新行不校验用户还没输入完别拦着 if (dataGridView1.Rows[e.RowIndex].IsNewRow) return; var start dataGridView1.Rows[e.RowIndex].Cells[StartDate].Value as DateTime?; var end dataGridView1.Rows[e.RowIndex].Cells[EndDate].Value as DateTime?; if (start.HasValue end.HasValue end start) { dataGridView1.Rows[e.RowIndex].ErrorText 结束时间不能早于开始时间; e.Cancel true; } }IsNewRow这个判断是必须的。网格底部的新行在用户输入前就存在如果你对整行做必填校验新行一获得焦点就会被 Cancel用户连输入都进不去界面像卡死一样。处理方式就是跳过新行让CellValidating去承担新行内的字段校验。另外RowValidating里访问的Cells[...].Value已经是单元格提交后的值可以直接用来做跨列比较。5. DataGridView 直接修改数据的避坑清单5 个现象与对应处理5.1 勾了 CheckBox列表里的值还是旧的现象界面上 CheckBox 明明勾上了绑定对象或 DataTable 里的对应字段还是 0点保存后什么都没变。原因CheckBox 列的编辑状态没有被提交。默认行为下点击 CheckBox 只把它标记为“当前单元格脏”要等焦点完全离开单元格才提交期间你如果直接做保存操作读到的是旧值。解决在CurrentCellDirtyStateChanged里判断IsCurrentCellDirty然后调用CommitEdit(DataGridViewDataErrorContexts.Commit)让勾选操作立即提交并触发CellValueChanged。这个修复放上去之后保存读到的值就和界面一致了。提示如果你用了 BindingSource 包了一层保存前记得也调一次bindingSource.EndEdit()它会递归提交整个绑定上下文。5.2 绑定 List 后编辑完了数据没有写回对象现象用ListTaskItem做 DataSource改了单元格里的文本点保存后再遍历 List属性值还是编辑前的。原因三种情况最常见。第一种TaskItem是 struct值类型绑定后 DataGridView 操作的是副本改完不会影响原列表。第二种属性没有 public setter或者只有 getterDataGridView 找不到写入口。第三种绑定的不是 List 本体而是 LINQ 语句的结果比如list.Where(x x.Status 1).ToList()这个新 List 和原列表不是同一批对象引用。解决值类型一律改成引用类型 class属性确认有 public setterLINQ 结果如果要支持回写必须先把投射结果放进一个 BindingList 再绑。更稳妥的做法是一开始就用BindingListT它可以避免很多隐性引用问题。5.3 输入了非法格式DataError 弹出来界面像卡死现象某列绑定的是 int 属性用户输入了“abc”焦点一离开界面上弹出 DataGridView 默认的错误对话框或者值直接消失再点别的单元格又弹一次。原因DataGridView 在把编辑控件里的字符串解析成 int 时抛了 FormatException默认DataError事件把异常重新抛到 UI 线程表现得像程序崩溃。解决挂一个全局DataError处理器把异常吞掉并提示用户别让它一层层冒出去。dataGridView1.DataError (s, e) { e.ThrowException false; // 不把异常抛给框架避免反复弹窗 dataGridView1.Rows[e.RowIndex].ErrorText 输入格式不正确; };要注意e.ThrowException false之后单元格会留在编辑状态还是放弃编辑取决于触发场景。通常配合CellValidating一起用先拦截非法输入DataError只作为最后一道保险。5.4 底部新行保存后又消失或者新增行按钮根本不出现现象网格底部没有“新行”那一行或者有但填完数据点保存后新行没了绑定 List 时尤其明显。原因AllowUserToAddRows true只是 UI 开关真正能不能加行取决于数据源是否支持AddNew。ListT不实现IBindingList的AddNew所以新行功能会被静默禁用BindingListT支持但如果 T 没有无参构造函数AddNew会抛 MissingMethodException新行填到一半就报错。解决绑定源换BindingListT并确保实体类有一个 public 无参构造函数。保存时不要遍历网格的 Rows而是遍历绑定源因为新行只有在真正提交后才会出现在绑定源里遍历 Rows 会因为IsNewRow判断不及时而漏掉数据。5.5 在 CellValueChanged 里直接写数据库界面卡得不行现象每次单元格一改就触发一次数据库 INSERT 或 UPDATE多开几个窗口后界面明显卡顿数据库连接也被频繁开关。原因CellValueChanged在值提交后立刻触发绑定数据源时初始化也会批量触发。如果在里面写同步数据库操作等于是把每秒钟几十次的事件当成了数据库调用的触发器。解决CellValueChanged里只做标记或记录日志把改动收集到一个临时集合里等用户点“保存”按钮时再一次性提交。另外初始化时先挂事件后赋值DataSource可以减少一轮误触发如果必须区分初始化赋值和用户修改用CellBeginEdit里设置一个_isEditing标志位来过滤。private bool _isEditing; private readonly Listint _dirtyRows new Listint(); private void dataGridView1_CellBeginEdit(object sender, DataGridViewCellCancelEventArgs e) { _isEditing true; } private void dataGridView1_CellValueChanged(object sender, DataGridViewCellEventArgs e) { if (!_isEditing) return; // 初始化赋值时不记录 if (!_dirtyRows.Contains(e.RowIndex)) _dirtyRows.Add(e.RowIndex); } private void buttonSave_Click(object sender, EventArgs e) { dataGridView1.EndEdit(); // 遍历 _dirtyRows 里的行号只提交这些行 }6. 验证修改是否真的落库记录修改日志的三个调试习惯6.1 用 DataRow.RowState 查看 DataTable 的修改痕迹这是我最爱用的一招能把 DataTable 这个黑匣子直接打开看。绑定 DataTable 后只要怀疑“界面改了但数据源没变”先别急着查数据库把每一行的 RowState 打出来立刻就能分辨值是进没进、还是进了没提交。foreach (DataRow row in ((DataTable)dataGridView1.DataSource).Rows) { if (row.RowState ! DataRowState.Unchanged) { Console.WriteLine($Id{row[Id]}, RowState{row.RowState}); } }Unchanged表示没动过Modified表示被修改Added和Deleted对应新增和删除。如果这里打的还是Unchanged说明编辑压根没提交去查事件链如果已经是Modified说明值到了内存层问题出在 Commit 或 SQL 层。6.2 给 List 写一个快照打印函数绑定 List 或 BindingList 时没有 RowState 可看就用反射写一个通用快照函数把每个对象的属性值拉出来打印。public static string DumpT(IEnumerableT items) { if (items null) return null; return string.Join(Environment.NewLine, items.Select(item string.Join( | , typeof(T).GetProperties() .Where(p p.CanRead) .Select(p ${p.Name}{p.GetValue(item)})))); }调用后用Console.WriteLine(Dump(bindingList))在保存前后各打一次对比两次输出就能定位是 UI 没写回还是写回了但保存逻辑没读到。这个方法对匿名类型同样有效排查 LINQ 投射集合非常实用。6.3 在 CellBeginEdit 里存旧值在 CellValueChanged 里做 diffCellValueChanged触发时单元格的 Value 已经是新值你拿不到旧值想记“谁把什么改成了什么”就没法直接做。养成习惯在CellBeginEdit里把旧值存进字典在CellValueChanged里对比。private readonly DictionaryPoint, object _oldValues new(); private void dataGridView1_CellBeginEdit(object sender, DataGridViewCellCancelEventArgs e) { _oldValues[new Point(e.ColumnIndex, e.RowIndex)] dataGridView1.Rows[e.RowIndex].Cells[e.ColumnIndex].Value; } private void dataGridView1_CellValueChanged(object sender, DataGridViewCellEventArgs e) { Point key new Point(e.ColumnIndex, e.RowIndex); if (!_oldValues.TryGetValue(key, out object oldValue)) return; object newValue dataGridView1.Rows[e.RowIndex].Cells[e.ColumnIndex].Value; if (!object.Equals(oldValue, newValue)) { Console.WriteLine($第 {e.RowIndex} 行 {dataGridView1.Columns[e.ColumnIndex].HeaderText}: {oldValue} - {newValue}); } }这套 diff 日志平时看不出多大价值一旦线上数据被改错它就是事后排查的后悔药。我早期做内部数据维护工具没少在这些细节上翻车后来固定成三条习惯CheckBox 列必挂 CommitEdit实体绑定必用 BindingList 而不是裸 List保存前必调 EndEdit。这套组合基本覆盖了所有“改了存不上”的场景。希望帮到你。本文还有配套的精品资源点击获取