简介:针对C# WinForms开发者,提供一份以DataGridView控件直接修改数据为核心的完整源码示例包。资源围绕编辑模式启用、CellEndEdit与CellValidating事件处理、EndEdit同步数据源、自定义编辑控件以及行增删监听等关键知识点展开,通过可直接运行的示例展示如何用EditOnEnter进入编辑、在CellEndEdit中获取行列索引与修改值,并利用CellValidating拦截非法输入,最终将更改同步回数据源,覆盖DataGridView交互中的常用场景,适合初学者对照练习,也可供已有项目集成时参考。包内共28个文件,以9个cs源码文件为主,同时包含config配置、resx界面资源、exe可执行程序等辅助内容,压缩包仅53KB,结构精简便于直接打开学习。已有918人学习下载,项目内窗体与逻辑代码相互对应,从界面操作到后台更新均有现成示例,可帮助快速掌握表格数据修改的完整流程。
1. DataGridView 直接修改数据:一次点击背后的编辑闭环
先还原一个真实场景:你在写 WinForms 后台管理系统,需求里有一句“表格里直接改备注,点保存落库”。做界面的半天就画完了,但真到联调才发现:单元格确实能双击、能输入、能回车,点完保存数据库里却还是老数据。这不是 DataGridView 不能改,而是它默认只完成了“界面可编辑”这一步,把数据同步给了你。DataGridView 直接修改数据这个需求,本质上不只是打开一个开关,而是要把数据源绑定、编辑模式、事件校验、提交时机这四个环节接成一个闭环。这篇文章写给正在做后台管理、进销存、轻量 CRUD 的 C# 开发,目标是让你从“能用”走到“敢用”,不踩那些我踩过的坑。
2. 让 DataGridView 真正能直接修改:数据源绑定与三个启动参数
2.1 先选一条数据路:绑定 DataTable 还是手动填充 Rows
DataGridView 能不能“直接修改”,第一个决定因素是数据从哪来。我一般不会在界面上手工构造 Rows/Cells,那样做只适合纯展示,不适合回写。真正要进入编辑闭环,至少要有两层结构:内存数据容器(DataTable、BindingList 、List ),以及显示控件 DataGridView。两者之间再放一层 BindingSource 更好,它帮你统一处理排序、过滤和 CurrencyManager 的编辑提交,后面做新增行、上下翻页都会顺一些。
最常见的做法是把 DataTable 直接赋给 DataSource:
DataTable dtProducts = new DataTable("Products"); dtProducts.Columns.Add("Id", typeof(int)); dtProducts.Columns.Add("Name", typeof(string)); dtProducts.Columns.Add("Price", typeof(decimal)); BindingSource bsProducts = new BindingSource(); bsProducts.DataSource = dtProducts; dataGridView1.DataSource = bsProducts;逻辑说明:这里 DataGridView 本身并不知道 SQL 在哪,它只认数据源。用户编辑单元格时,DGV 会把值写回 DataTable,DataTable 通过 RowState 记录这行是新增、修改还是删除。BindingSource 在这里是中间层,主要价值是让你后续能用EndEdit()统一把正在编辑的数据“交出来”,并且能对多个控件共享同一份数据。没有 BindingSource,你也能改,但保存时的时机控制会麻烦很多。
另一个可选方案是绑定实体集合。如果项目里已经有 ORM,可以用BindingList<T>包装实体类。这里有个隐蔽前提:实体类要实现INotifyPropertyChanged,否则界面改了,实体对象并不知道,保存时拿到的还是旧值。这个坑我在第 5 章单独讲。
2.2 三个启动参数:ReadOnly、EditMode、AllowUserToAddRows/DeleteRows
从“能看”到“能改”,最关键的三个参数都在 DataGridView 的属性面板上。很多项目里页面加载后半天点不进去,十有八九是 ReadOnly 被设成了 true;而能点进去但新行出不来,多半是 AllowUserToAddRows 被关了。
| 参数 | 默认值 | 作用 | 常见配置 |
|---|---|---|---|
| DataSource | null | 数据源绑定对象 | 绑 DataTable 或 BindingSource |
| ReadOnly | false | 全局只读开关 | 只读页面设 true |
| EditMode | EditOnKeystrokeOrF2 | 进入编辑的触发方式 | 快速录入时改 EditOnKeystroke |
| AllowUserToAddRows | true | 底部显示“新行”行标 | 需要新增时保持 true |
| AllowUserToDeleteRows | true | 选中行按 Delete 删除 | 谨慎关闭,删除要二次确认 |
| SelectionMode | CellSelect | 控制选中粒度 | 整行编辑时设 FullRowSelect |
| CurrentCell | 首个单元格 | 当前编辑锚点 | 保存前先CommitEdit |
要注意 ReadOnly 分三层:DataGridView 的 ReadOnly 是总开关;列上的 ReadOnly 管一列;单元格上的 ReadOnly 管一格。判断某个单元格到底能不能编辑,三层都得是 false,任何一个为 true 都会让编辑被拒绝。实际排查时我习惯直接看列配置,因为很多人会顺手在模板列里勾了 ReadOnly,自己后来忘了。
EditMode 的四个枚举值里,最容易被误用的是EditProgrammatically。这个模式下用户怎么点都不会进入编辑,必须代码调用BeginEdit(),一般用于“点按钮才改”的联动场景。EditOnEnter好处是不容易误触发,回车或双击才进编辑;EditOnKeystroke只要用户一敲字符,当前单元格立刻进入编辑,适合像 Excel 一样批量录入的场景。
2.3 最小可跑通的绑定与列配置代码
直接贴一个能在 WinForms 里跑起来的最小配置。我不会用 AutoGenerateColumns,因为它会把 DataTable 的每个字段都变成一列,连 Id 都露出来,列头还是英文的,后期想加个格式都难:
dataGridView1.AutoGenerateColumns = false; dataGridView1.EditMode = DataGridViewEditMode.EditOnEnter; dataGridView1.SelectionMode = DataGridViewSelectionMode.FullRowSelect; dataGridView1.AllowUserToAddRows = true; dataGridView1.AllowUserToDeleteRows = true; dataGridView1.Columns.Clear(); dataGridView1.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "Name", HeaderText = "产品名称", Width = 180 }); dataGridView1.Columns.Add(new DataGridViewTextBoxColumn { DataPropertyName = "Price", HeaderText = "单价", Width = 100, DefaultCellStyle = new DataGridViewCellStyle { Format = "C2", Alignment = DataGridViewContentAlignment.MiddleRight } }); dataGridView1.DataSource = bsProducts;逻辑说明:AutoGenerateColumns = false之后,列与 DataTable 字段的关系完全靠DataPropertyName建立。用户在单元格里输入的值,会被 DataGridView 转成 DataTable 对应列的类型再写回去。Price 列做成货币格式,显示是¥1,200.00,但底层保存的还是 decimal。这是一个完整的“直接修改”闭环:键入、转换、写回 DataTable。
参数说明:DefaultCellStyle.Format只影响显示,不影响存储,这个要分清。有的人发现界面显示带货币符号,但导出 Excel 时变成纯数字,就以为数据丢了,其实没丢,格式只是皮。
绑定好了,其实你已经能双击改数据了。但很快会遇到新的问题:用户输入了价格 0 也允许提交、日期列怎么输都报错、改动后不知道怎么拿“被改了的行”。这些靠事件处理来解决。
3. 编辑过程靠事件接管:CellParsing、CellValidating、CellEndEdit 的分工
3.1 从双击到回车,DataGridView 里发生了什么
不了解事件顺序之前,很多人把CellValueChanged当成万能入口,结果发现它被触发很多次,做出来的逻辑飘忽不定。实际上,一个完整的编辑周期是:CellBeginEdit进入编辑 → 用户输入触发CurrentCellDirtyStateChanged→ 回车或切换焦点时,CellValidating先跑 → 校验通过后CellParsing把显示文本转成列类型 →CellEndEdit收尾 → 如果值确实变了,再触发CellValueChanged。
这里最容易混的是CellValidating和CellParsing。我把它们当两道工序:CellValidating检查的是“用户输入的文本是否合法”,拦截非法内容;CellParsing负责“把合法文本变成指定类型”,比如把字符串"2025-06-01"变成 DateTime。顺序之所以重要,是因为你如果在CellValidating里尝试转换类型,而列类型是 DateTime,还没到CellParsing这步,容易拿到不稳定的值。各干各的,是最省心的写法。
3.2 CellValidating:提交前的最后一关
实际业务里,价格不能为负数、数量不能为空,这类校验我几乎全放在CellValidating。它的优势是可以阻止用户离开当前单元格,相当于把错误当场摁住:
private void dataGridView1_CellValidating(object sender, DataGridViewCellValidatingEventArgs e) { if (e.RowIndex < 0 || dataGridView1.Rows[e.RowIndex].IsNewRow) return; if (dataGridView1.Columns[e.ColumnIndex].Name == "Price") { string input = e.FormattedValue?.ToString(); if (!decimal.TryParse(input, out decimal price) || price <= 0) { dataGridView1.Rows[e.RowIndex].ErrorText = "价格必须大于 0"; MessageBox.Show("价格必须大于 0", "输入有误"); e.Cancel = true; // 阻止离开这个单元格 } else { dataGridView1.Rows[e.RowIndex].ErrorText = ""; } } }逻辑说明:e.FormattedValue取到的是用户在当前编辑框里输入的文本,不是最终存入 DataTable 的值。e.Cancel = true是让 DataGridView 留在当前单元格,不响应回车和切格子。这里有个细节不能省:校验失败时一定要给用户明确的提示。很多人只写e.Cancel = true,没有提示,结果是用户看到这个格子点哪都没反应,以为程序卡死,实际是被校验拦截了。
ErrorText的用法值得提一下:它会在行头显示一个红色小图标,鼠标悬停在行头上能看到错误信息。配合 MessageBox 是最好的组合,一个解释原因,一个常驻提示,用户不会茫然。
3.3 CellParsing:把显示文本转成强类型值
默认情况下,DataGridView 已经能做基本类型转换,比如字符串到 int、decimal。但碰上格式特殊的日期、带单位的数据,就必须用CellParsing接管。举个真实例子:日期列显示成yyyy/MM/dd,用户偏偏输入2025-6-1,默认转换可能直接抛异常,或者解析成别的时间。这时候用 CellParsing 处理:
private void dataGridView1_CellParsing(object sender, DataGridViewCellParsingEventArgs e) { if (dataGridView1.Columns[e.ColumnIndex].Name != "BirthDate") return; if (e.Value == null) return; string input = e.Value.ToString(); if (DateTime.TryParseExact(input, "yyyy-M-d", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime result)) { e.Value = result; e.ParsingApplied = true; } }逻辑说明:e.Value在进入 CellParsing 时是编辑框的显示文本,把它解析成 DateTime 后赋回e.Value,再设置e.ParsingApplied = true,意思是“我自己已经转换完了,不要再走默认转换”。TryParseExact的格式串yyyy-M-d兼顾了2025-6-1和2025-06-01两种写法。注意要引入命名空间System.Globalization。
参数说明:ParsingApplied = true不是必须写,你不写也没报错,DataGridView 会继续用自己的逻辑转一遍。但那样的话,你在这里做的解析成果可能被覆盖,所以只要你在 CellParsing 里处理了,一定要把它设为 true,这是很多老手也容易漏的点。
3.4 CellEndEdit 与 CellValueChanged:一个收尾,一个收账
CellEndEdit触发时,单元格已经完成校验和类型转换,你可以在这里做行级计算。比如数量×单价=金额,金额列是计算列,没绑到 DataTable,就可以在 CellEndEdit 里刷新同行其他单元格的显示值。它拿不到修改前后的值,只能通过行号去读当前单元格的值。
CellValueChanged则是“值真的变了”的信号,适合做数据收集。它有个经典的误触发场景:脚本往某个格子里赋值也会触发它。所以处理代码里我一定会先做过滤:
private void dataGridView1_CellValueChanged(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex < 0) return; if (dataGridView1.Rows[e.RowIndex].IsNewRow) return; // 只有用户真实编辑产生的变化才会走到这里 object newValue = dataGridView1.Rows[e.RowIndex].Cells[e.ColumnIndex].Value; }逻辑说明:过滤条件是血泪经验。DataGridView 在新增行时会触发大量CellValueChanged,如果没有IsNewRow判断,收集器里会出现一堆空值键,越攒越乱。加了过滤之后,这个事件才能作为可靠的“修改收集器”使用。
如果你依赖CellValueChanged,还有一个前置动作要做,就是配合CurrentCellDirtyStateChanged主动提交编辑。否则用户改完一格去点下一格,数据可能在那一格还处于“草稿”状态,值变更事件不触发:
private void dataGridView1_CurrentCellDirtyStateChanged(object sender, EventArgs e) { if (dataGridView1.IsCurrentCellDirty) { dataGridView1.CommitEdit(DataGridViewDataErrorContexts.Commit); } }这段代码的作用是:用户在一个格子里敲完字,还没按回车,DataGridView 就已经把编辑值提交给数据源,CellValueChanged立刻触发。不加这段,你要等焦点彻底离开那一格才拿得到新值,而且某些情况下保存时会漏掉最后一行修改。项目里只要看到“保存后最后改的那一行丢失”,先检查这里。
4. 修改后的数据走哪条路:DataTable 直改与手动收集的两种提交策略
4.1 策略一:绑定 DataTable,用 TableAdapter/SqlDataAdapter 一次性 Update
数据源绑的是 DataTable,用户改完后,DataTable 内部会把每一行的状态标成 Added、Modified 或 Deleted。保存时最常见的做法是建一个SqlDataAdapter,调用它的Update():
private void btnSave_Click(object sender, EventArgs e) { // 先把界面正在编辑的单元格提交到 DataTable dataGridView1.EndEdit(); bsProducts.EndEdit(); DataTable changed = dtProducts.GetChanges(); if (changed == null) { MessageBox.Show("没有检测到修改"); return; } using (SqlConnection conn = new SqlConnection(connStr)) { using (SqlDataAdapter adapter = new SqlDataAdapter("SELECT Id, Name, Price FROM Products", conn)) { SqlCommandBuilder builder = new SqlCommandBuilder(adapter); adapter.Update(dtProducts); } } }逻辑说明:dataGridView1.EndEdit()和bsProducts.EndEdit()是两个容易写漏的步骤。第一个结束当前单元格的编辑状态,第二个确保 BindingSource 把整行的修改提交给 DataTable。没有这一步,正在“编辑中”的那一行可能不在 GetChanges 结果里,表现为“其余行保存了,最后改的那行丢了”。GetChanges()是 DataTable 自带的能力,只返回有状态变化的行,我很依赖它做保存前的核对。
SqlCommandBuilder确实能自动生成 INSERT/UPDATE/DELETE 语句,但它生成的是全字段更新,条件里带上每一列,性能差不说,遇到表结构有主键之外唯一约束时也可能误更新。我一般只在开发环境用它快速验证流程,生产环境会手写 Update 语句或者用存储过程。如果你是在做小型内部工具,数据量不大、字段不多,它可以省很多事,但要清楚它的边界。
4.2 策略二:不绑数据源,用 CellValueChanged 手动收集变更
有些场景没法绑 DataTable,比如数据来自第三方接口返回的 List,或者表格列足够多但只需要保存其中两列。这时候“直接修改数据”的落点就不是 DataTable 了,而是你自己维护的一个改动清单。我喜欢用一个字典收集用户改过的单元格,保存时只提交这些列:
private Dictionary<(int rowIndex, string columnName), object> pendingChanges = new Dictionary<(int, string), object>(); private void dataGridView1_CellValueChanged(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex < 0 || dataGridView1.Rows[e.RowIndex].IsNewRow) return; string colName = dataGridView1.Columns[e.ColumnIndex].DataPropertyName; if (string.IsNullOrEmpty(colName)) return; object newValue = dataGridView1.Rows[e.RowIndex].Cells[e.ColumnIndex].Value; var key = (e.RowIndex, colName); if (pendingChanges.ContainsKey(key)) pendingChanges[key] = newValue; else pendingChanges.Add(key, newValue); }逻辑说明:这里仍然要先挂上前面提到的CurrentCellDirtyStateChanged,否则 CellValueChanged 触发时机不稳定。字典的键用了“行号+列名”,行号是控件里的可视行号,如果 DataGridView 被排序过,行号会随显示变化漂移,保存时就会对错行。我的做法是:如果 DataGridView 允许排序,键里不存行号,存该行的主键值,从Rows[e.RowIndex].Cells["Id"].Value取出来组合成键。这样排序、过滤都不会影响对应关系。
保存时遍历字典,按列名拼 UPDATE 参数。这个方式的优点是完全可控,只更新被改过的列,适合做字段级审计;缺点是增删行要另外处理,不像 DataTable 自带 RowState 能区分 Added/Deleted。
4.3 选型建议:什么规模选什么方案
| 业务形态 | 推荐方案 | 理由 |
|---|---|---|
| 小型后台单表,行数少于 2000,允许拖拽 | DataTable + SqlDataAdapter | 改动最少,DataTable 自带行状态 |
| 多表联查结果只改其中几列 | 手动 CellValueChanged 收集 | 避免全列更新造成误解 |
| 项目已有 ORM,实体类较规范 | BindingList + INotifyPropertyChanged | 与现有仓储模式融合好 |
| 5 万行以上的大列表展示 | 虚拟模式 | 不整表加载,后面章节讲 |
| 需要严格做修改日志 | 手动收集方案 | 能拿到修改前后值和列名 |
选型时还要看一个隐藏因素:新增行和删除行怎么做。DataTable 方案里,新增行通过DataGridView.AllowUserToAddRows在底部新行输入,保存时 DataTable 里那行状态是 Added,adapter.Update会自动走 INSERT。手动收集方案里,新行没有任何“行状态”概念,你要在保存时逐个检查哪些行没有主键,自己拼 INSERT。所以我常跟人说,能绑 DataTable 就绑 DataTable,省下的不只是代码,是各种莫名边界情况。
5. 避坑:DataGridView 直接修改数据的 5 个翻车现场
5.1 数据看着改了,数据库纹丝不动
现象:用户把“产品名称”从 A 改成 B,表格里显示也变了,点保存提示成功,再查数据库还是 A。这类问题在群里被问得最多。
原因通常有两个。第一,编辑行没有真正提交到 DataTable,EndEdit()没调,DataGridView 只是“界面改了”,底层 DataRow 的 RowState 还是 Unchanged。第二,提交前没有检查 GetChanges,你 UPDATE 的是一个原本就不包含那行的 DataTable,当然成功但无变化。
解决:保存前统一执行:
dataGridView1.EndEdit(); bsProducts.EndEdit(); DataTable changed = dtProducts.GetChanges(DataRowState.Modified | DataRowState.Added); if (changed == null) { MessageBox.Show("没有检测到修改"); return; }然后拿changed逐行打印 RowState 确认再 Update。这个检查动作 30 秒就能做完,能省去你半夜怀疑数据库的时间。
5.2 校验失败 e.Cancel=true 后,界面像死了一样
现象:价格列输入了负数,CellValidating里校验不通过并设了e.Cancel = true。结果用户发现鼠标点到别的格子、按 Tab、敲回车都无反应,窗口标题栏还会出现“未响应”的感觉。其实程序没死,是当前单元格被强制留在编辑状态。
原因:e.Cancel = true的本质是阻止 DataGridView 把焦点移出当前单元格。如果用户不知道这回事,他只会觉得程序坏了。这是 CellValidating 最容易翻车的地方。
解决:设置 Cancel 之前,必须给用户可见的提示。我习惯用 MessageBox 先说明错误原因,再把ErrorText写到那一行。同时要在提示文字里告诉用户按 Esc 可以撤销本次修改、退出编辑状态。这样即使他不想改了,也有后悔药可以吃。
5.3 数字列输字母,既不报错也不保存
现象:Price 列是 decimal 类型,用户输入 abc,回车后看起来什么都没发生,单元格也没红框,保存后价格还是旧值。
原因:DataGridView 在数据转换失败时会触发DataError事件,但如果你没有处理它,这个异常可以被忽略掉,界面维持原值也不提示。从用户视角就是“我改了,你没理我”。
解决:给 DataGridView 挂上DataError事件,把错误信息明确抛给用户:
private void dataGridView1_DataError(object sender, DataGridViewDataErrorEventArgs e) { if (e.Exception != null) { string colName = dataGridView1.Columns[e.ColumnIndex].HeaderText; MessageBox.Show($"“{colName}”列需要输入 {dataGridView1.Columns[e.ColumnIndex].ValueType.Name} 类型的值。", "数据格式错误"); e.ThrowException = false; } }逻辑说明:e.ThrowException = false表示吞掉异常,交给界面继续工作。这时用户虽然被提示了错误,但单元格里输入的非法内容还在,可以让他继续修改。如果不捕获,程序会直接抛异常弹到调用栈,黑匣子一样。这里有个取舍:我希望在 CellValidating 里做主要校验,DataError 只兜底处理格式转换异常,两者不冲突,前者管业务规则,后者管类型转换。
5.4 绑了实体类集合,改了界面保存时还是旧值
现象:DataSource 绑的是List<User>,界面能显示人名,双击也能改,但保存时把实体序列化到接口,返回的还是加载时的老数据。
原因:List<T>不会监听集合元素的属性变化,DataGridView 的编辑只修改了界面上显示的视图,没有写回实体对象的属性。真正能写回的前提是数据源是BindingList<T>,且实体类实现INotifyPropertyChanged,这样 DGV 在值变更后才会去调用属性的 setter。
解决:实体类实现通知接口:
public class User : INotifyPropertyChanged { private string _name; public string Name { get => _name; set { if (_name != value) { _name = value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name))); } } } public event PropertyChangedEventHandler PropertyChanged; }然后将数据源换成BindingList<User>。这样用户在单元格里改完,BindingList 会通知 DataGridView,同时实体对象的属性也被真正更新了。如果你不想改造实体类,退路是在CellValueChanged里手动取出实体的属性 setter 赋值,但那等于把职责扛在事件里,代码会越来越脏。
5.5 新增行保存后消失,明明输了一堆字
现象:底部新行里录入了数据,点保存没有任何报错,刷新表格后那一行不见了。检查 DataTable,发现 GetChanges 里根本没有 Added 状态的行。
原因:新增行的数据如果没有在合适的时机提交到 BindingSource,它的 RowState 会一直停留在 Detached,而 Detached 行不会出现在 GetChanges 的 Added 结果里。很多人在CellValueChanged里做收集,新行首次输入时 IsNewRow 还是 true,被过滤逻辑跳过去了,之后再也没有机会进入收集范围。
解决:保存前必须做一次完整的EndEdit()链。我的做法是:
dataGridView1.EndEdit(); bsProducts.EndEdit(); dtProducts.AcceptChanges(); // 不要在这里乱调用注意AcceptChanges()是个危险操作,它会把所有行的 RowState 重置为 Unchanged,调用完再 Update 等于什么都没提交。我唯一会在确认“本次数据确实需要入库”之后才碰它。新增行的问题是先查GetChanges(DataRowState.Added),如果为空但界面上明明有内容,检查一下是不是还在编辑状态没提交。
6. 进阶:虚拟模式下直接修改数据,以及保存前的验证习惯
6.1 五万行数据,为什么我宁愿开 VirtualMode
表格数据量超过几万行后,直接把整个 DataTable 赋给 DataGridView,滚动会明显卡顿,内存占用也高得吓人。虚拟模式(VirtualMode = true)可以让控件按需向你要数据,只渲染当前显示的行。代价是你必须自己管理数据缓存,并接管两个事件:CellValueNeeded负责给控件提供当前单元格的值,CellValuePushed负责把用户修改后的值写回缓存。这就实现了大列表下的“直接修改”:
dataGridView1.VirtualMode = true; dataGridView1.RowCount = productCache.Count; private void dataGridView1_CellValueNeeded(object sender, DataGridViewCellValueEventArgs e) { Product item = productCache[e.RowIndex]; e.Value = item.GetValue(dataGridView1.Columns[e.ColumnIndex].Name); } private void dataGridView1_CellValuePushed(object sender, DataGridViewCellValueEventArgs e) { Product item = productCache[e.RowIndex]; item.SetValue(dataGridView1.Columns[e.ColumnIndex].Name, e.Value); }逻辑说明:CellValuePushed是虚拟模式下“直接修改数据”的保存入口,它等价于普通模式里的CellValueChanged,但只负责把你修改的新值放到缓存。真正入库还是在保存按钮里遍历缓存,按需生成 UPDATE 语句。要注意虚拟模式下不能用绑定的BindingSource做排序过滤,这些功能要自己在缓存层实现,这也是为什么我会说“不是必不得已,别只为性能上虚拟模式”。
6.2 每次保存前,我都会做的检查动作
无论用哪种方案,我保存数据前都有一个固定习惯:先把 RowState 打出来看一眼。这不是流程仪式,而是真的避免过“以为改了其实没改”的乌龙。方法是保存按钮最前面加 3 行调试输出:
dataGridView1.EndEdit(); bsProducts.EndEdit(); foreach (DataRow row in dtProducts.GetChanges()?.Rows ?? new DataRow[0]) { Console.WriteLine($"{row.RowState} -> {row["Id"]} / {row["Name"]}"); }看输出里有没有 Modified/Added 行,再决定是否继续 Update。如果输出为空,说明界面数据和 DataTable 没同步,先排查EndEdit和CurrentCellDirtyStateChanged,而不是强行去数据库里找问题。这个检查动作帮我挡掉过好几次“用户改完老觉得没保存上”的投诉。早期我图省事,跳过检查直接写 Update,最后发现是编辑行没提交,白白排查了半晚上。希望这个习惯对你有用,也希望前面这套配置和踩坑经验能帮你少走几趟弯路。
本文还有配套的精品资源,点击获取