简介:这是一份面向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:每个单元格编辑结束后,值直接写进对应的 DataRow,
RowState变成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 // 方案二:BindingList<T> 绑定,支持增删行且能通知网格刷新 BindingList<TaskItem> list = new BindingList<TaskItem>(); 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,编辑也会写回。但注意BindingList<T>要求 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。这里的“直接修改”其实是两层:界面改的是 DataTable,DataTable 再带着状态去数据库,中间这层状态就是你做增量更新的凭据。
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 List<TaskItem> { 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 转 bool,set 时把 bool 转回 int public bool IsDone { get => Status == 1; set => Status = value ? 1 : 0; } }绑定的时候把 CheckBox 列的DataPropertyName指到IsDone,而不是Status。Status列如果不显示,就干脆不加入 DataGridView 的列集合,或者给它加[Browsable(false)]特性防止自动列把它带出来。
var list = new BindingList<TaskItem> { 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两个标记。如果不把它们设为 true,DataGridView 会继续执行它的默认类型转换,你转换完的值可能又被覆盖一次。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 后,编辑完了数据没有写回对象
现象:用List<TaskItem>做 DataSource,改了单元格里的文本,点保存后再遍历 List,属性值还是编辑前的。
原因:三种情况最常见。第一种,TaskItem是 struct,值类型绑定后 DataGridView 操作的是副本,改完不会影响原列表。第二种,属性没有 public setter,或者只有 getter,DataGridView 找不到写入口。第三种,绑定的不是 List 本体,而是 LINQ 语句的结果,比如list.Where(x => x.Status == 1).ToList(),这个新 List 和原列表不是同一批对象引用。
解决:值类型一律改成引用类型 class;属性确认有 public setter;LINQ 结果如果要支持回写,必须先把投射结果放进一个 BindingList 再绑。更稳妥的做法是一开始就用BindingList<T>,它可以避免很多隐性引用问题。
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。List<T>不实现IBindingList的AddNew,所以新行功能会被静默禁用;BindingList<T>支持,但如果 T 没有无参构造函数,AddNew会抛 MissingMethodException,新行填到一半就报错。
解决:绑定源换BindingList<T>,并确保实体类有一个 public 无参构造函数。保存时不要遍历网格的 Rows,而是遍历绑定源,因为新行只有在真正提交后才会出现在绑定源里,遍历 Rows 会因为IsNewRow判断不及时而漏掉数据。
5.5 在 CellValueChanged 里直接写数据库,界面卡得不行
现象:每次单元格一改就触发一次数据库 INSERT 或 UPDATE,多开几个窗口后界面明显卡顿,数据库连接也被频繁开关。
原因:CellValueChanged在值提交后立刻触发,绑定数据源时初始化也会批量触发。如果在里面写同步数据库操作,等于是把每秒钟几十次的事件当成了数据库调用的触发器。
解决:CellValueChanged里只做标记或记录日志,把改动收集到一个临时集合里,等用户点“保存”按钮时再一次性提交。另外初始化时先挂事件后赋值DataSource,可以减少一轮误触发;如果必须区分初始化赋值和用户修改,用CellBeginEdit里设置一个_isEditing标志位来过滤。
private bool _isEditing; private readonly List<int> _dirtyRows = new List<int>(); 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 Dump<T>(IEnumerable<T> 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 里做 diff
CellValueChanged触发时,单元格的 Value 已经是新值,你拿不到旧值,想记“谁把什么改成了什么”就没法直接做。养成习惯:在CellBeginEdit里把旧值存进字典,在CellValueChanged里对比。
private readonly Dictionary<Point, 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。这套组合基本覆盖了所有“改了存不上”的场景。希望帮到你。
本文还有配套的精品资源,点击获取