简介:面向 C# WinForms 开发者的 DataGridView 表头下拉筛选完整示例,针对默认表格控件缺乏数据筛选入口的痛点,演示如何通过自定义列头单元格实现按列快速过滤。压缩包共 125 个文件,约 542KB,核心包含 vb 与 cs 两种源码、exe 可执行程序、dll 类库、pdb 调试符号、resx 界面资源以及项目工程与配置文件,既能直接运行验证效果,也便于二次开发。已有 985 人学习下载。资料围绕自定义 DataGridViewColumnHeaderCell 绘制下拉箭头、监听 ColumnHeaderMouseClick 事件弹出筛选项、维护数据源唯一值列表、使用 CheckedListBox 支持多选,以及通过 LINQ 或 DataView.RowFilter 应用过滤条件等完整实现思路展开;同时提供“全部”选项用于清除筛选、恢复原始数据,并附有设计器文件、测试数据与两种工程目录结构,便于对照不同实现方式。适合希望提升表格交互效率、快速掌握自定义筛选控件的初中级 C# 开发者参考借鉴。
1. 表头下拉筛选:DataGridView 最常被用户点名要的功能
有过把 Excel 数据搬进 WinForms 项目经验的人,大概率被用户问过一句“这个表格怎么不能像 Excel 那样在表头筛一下”。明明数据都在 DataGridView 里,排序也默认支持,可筛选偏偏不在原生功能列表里。这个需求在业内通常就叫 DataGridView 表头下拉筛选:点击列头的下拉箭头,弹出一个带勾选框的列表,选中几项,表格立即只显示匹配的行。
之所以说它“看着小、做起来绕”,是因为它牵扯三层东西:表头单元格的自绘、鼠标事件的路由、以及 DataSource 的 RowFilter 拼接。只重画一个三角箭头不难,难的是让这个箭头在点击时不触发列排序,在弹窗打开时不抢焦点,在筛选后还能和其他列的状态共存。
这篇笔记适合两类人:一类是在维护老项目、被用户软磨硬泡要“Excel 同款”筛选的 WinForms 工程师;另一类是正在评估自研报表工具、想把表头交互做得更完整的人。我会直接从代码层面拆完整个方案,包括表达式拼接、空值处理、大数据量下的性能边界,以及我自己踩过的几个坑。
2. 选型与最小骨架:为什么继承表头单元格而不是重写整个控件
2.1 三条技术路线,我为什么选了继承 DataGridViewColumnHeaderCell
做表头下拉筛选,从业界常见的做法来看有三条路。第一条是在 DataGridView 外层包一个自定义控件,拦截 MouseDown 事件,自己计算点击位置是否落在列头右侧,然后手动弹出一个列表。优点是逻辑集中,缺点是几乎要把 DataGridView 的坐标体系重新摸一遍,尤其是在开启 RowHeaders、列宽可调、有冻结列的情况下,位置计算很容易翻车。
第二条是用第三方商业表格控件,比如知名的 Infragistics、DevExpress 之类,它们自带表头筛选。如果你在做一个新项目且预算充足,这确实最省事。但很多老项目是 DataGridView 已经写了几万行逻辑,整体替换控件意味着事件、样式、单元格类型全部重来,风险远大于收益。
第三条就是本文要展开的:继承 DataGridViewColumnHeaderCell,重写 OnPaint 和 OnMouseDown。这条路最贴合 DataGridView 自身的扩展模型,列头区域的一切交互都发生在单元格内部,不需要关心整个表格的坐标换算。表头那一段的点击排序、文本绘制、边框样式,基类都处理好了,我们只是“在右侧加一个箭头,在箭头区域拦截鼠标”。Qt 生态里用 QTableView 自定义 QHeaderView 做类似事情也是同样的思路,各做各的,别搞混。
2.2 继承之后,重写 OnPaint 是第一步
先写一个最简的 FilterHeaderCell 类,只做一件事:在列头右侧画一个下拉三角。这个类后面所有功能都从这里长出来。
public class FilterHeaderCell : DataGridViewColumnHeaderCell { private const int ArrowWidth = 18; // 箭头热区宽度,列宽过窄时可调 private bool _isFiltered; // 当前列是否有筛选条件,决定箭头颜色 protected override void OnPaint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates cellState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) { // 先让基类画文字、背景和排序箭头,再画我们自己的下拉三角 base.OnPaint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); Rectangle arrowRect = GetArrowRect(cellBounds.Width, cellBounds.Height); using (Brush brush = new SolidBrush(_isFiltered ? Color.DarkBlue : Color.Gray)) { Point[] triangle = new Point[] { new Point(cellBounds.Left + arrowRect.Left, cellBounds.Top + arrowRect.Top), new Point(cellBounds.Left + arrowRect.Right, cellBounds.Top + arrowRect.Top), new Point(cellBounds.Left + (arrowRect.Left + arrowRect.Right) / 2, cellBounds.Top + arrowRect.Bottom) }; graphics.FillPolygon(brush, triangle); } } // 返回相对单元格左上角的箭头矩形,绘制和点击判断都用它 private Rectangle GetArrowRect(int columnWidth, int headerHeight) { int x = columnWidth - ArrowWidth - 2; int y = (headerHeight - 5) / 2; return new Rectangle(x, y, 10, 5); } }这里有个关键点:GetArrowRect 我故意设计成返回“相对单元格”的坐标,而不是绝对坐标。OnPaint 里拿到的是 cellBounds,画的时候把 cellBounds.Left、cellBounds.Top 加上去;OnMouseDown 里拿到的 e.X、e.Y 天生就是相对单元格的,两边用同一套相对坐标,省去换算的麻烦。排序箭头默认画在列头左侧区域,和右侧的下拉箭头不冲突,这也是刻意选择右侧的原因。
2.3 最小可运行骨架:替换 HeaderCell 并验证绘制
类写好了,怎么让 DataGridView 实际用上它?最直接的做法是每创建一个列后手动替换 HeaderCell。我一般是在列创建时顺手做掉:
DataGridViewTextBoxColumn nameCol = new DataGridViewTextBoxColumn { DataPropertyName = "CustomerName", HeaderText = "客户名称", SortMode = DataGridViewColumnSortMode.Automatic }; nameCol.HeaderCell = new FilterHeaderCell(); dataGridView1.Columns.Add(nameCol);SortMode 必须保持 Automatic,因为用户已经习惯了点击表头排序,不能因为加了筛选箭头就把排序砍掉。那点击箭头时会不会同时触发排序?这正是下一个问题,也是整个方案最容易翻车的地方。
如果你有一张已经动态生成列的表,推荐挂在 ColumnAdded 事件里统一替换。做法是判断 HeaderCell 不是 FilterHeaderCell 就换掉,同时把原 HeaderCell 的 Text 和 Style 拷贝过来,避免丢失已有的表头文案和样式。这一步做完,运行程序就能看到列头右侧多了一个灰色三角,但它现在还只是个“装饰”,点击没有任何反应。
3. 弹出筛选列表:ToolStripDropDown 承载 CheckedListBox 的实现
3.1 为什么不用 Form 弹窗,而选 ToolStripDropDown
自己弹一个 Form 是最直觉的做法:new 一个窗体,放一个 CheckedListBox,ShowDialog。但做过的人都知道问题在哪——焦点。Form 一旦非模态显示,主窗体的 DataGridView 还在响应鼠标,用户点表头的排序箭头时,菜单可能闪一下就没;如果模态显示,用户又没法先看表格再决定是否多选其他列,体验很死板。
ToolStripDropDown 天生就是为“点击外部自动关闭”设计的。它内部处理了焦点转移和鼠标捕获,弹出的面板不会抢走主窗体的焦点,用户点表格其它区域时面板自动收起。我用它承载 CheckedListBox,几乎不需要额外写焦点管理代码,这是比 Form 方案省心得多的地方。
3.2 加载去重值与自定义显示项
弹出之前,先要把这一列所有可能的值取出来。注意这里要取的是原始数据源全量去重,而不是当前筛选后的数据。比如第一列已经筛了“华东”,第二列的下拉列表里仍然要能看到“华北”的选项,否则用户永远没法跨条件切换。
public static List<object> GetDistinctValues(object dataSource, string columnName) { var values = new HashSet<object>(); DataTable table = null; if (dataSource is BindingSource bs) table = bs.DataSource as DataTable; else if (dataSource is DataTable dt) table = dt; if (table != null && table.Columns.Contains(columnName)) { foreach (DataRow row in table.Rows) values.Add(row[columnName]); // HashSet 自动去重 } else if (dataSource is System.Collections.IEnumerable enumerable) { foreach (var item in enumerable) { var prop = item.GetType().GetProperty(columnName); if (prop != null) values.Add(prop.GetValue(item)); } } return values.OrderBy(v => v == null || v == DBNull.Value ? string.Empty : v.ToString()) .ToList(); }这里踩过一个很微妙的坑:不能用 DataGridView.Rows 去遍历取值。一旦筛选已经生效,Rows 里的行是过滤后的子集,下拉列表会越筛越少,永远回不到全量选项。所以取值必须回到 DataSource 层面的 DataTable 或原始集合。HashSet 去重时,DBNull 和 null 会当成两个不同对象,但它们在 DataTable 里实际都表示数据库空值,后面的筛选逻辑要统一处理。
列表项还需要处理空值显示。直接 Add(null) 进去,CheckedListBox 里显示一行空白,用户不知道那是什么。我一般用一个包装类:
public class FilterValueItem { public object Value { get; } public FilterValueItem(object value) { Value = value; } public bool IsNull => Value == null || Value == DBNull.Value; public override string ToString() => IsNull ? "(空白)" : Value.ToString(); }3.3 弹出面板的完整代码与坐标保护
OnMouseDown 里判断点击位置,命中箭头区域就弹出,否则交给 base 触发排序。这个分支是排序与筛选互不干扰的核心。
protected override void OnMouseDown(DataGridViewCellMouseEventArgs e) { // 不是表头行就交给默认行为 if (e.RowIndex != -1) { base.OnMouseDown(e); return; } Rectangle arrowRect = GetArrowRect( this.DataGridView.Columns[e.ColumnIndex].Width, this.DataGridView.ColumnHeadersHeight); if (arrowRect.Contains(e.X, e.Y)) ShowFilterPopup(e.ColumnIndex); else base.OnMouseDown(e); // 点击箭头之外区域,保持默认排序 }弹窗的定位要处理一个容易忽略的情况:列头在屏幕最下方时,面板会超出工作区。我用 Screen.GetWorkingArea 做一次边界校正,超了就往上弹。
private void ShowFilterPopup(int columnIndex) { DataGridView grid = this.DataGridView; if (grid == null || grid.Columns[columnIndex].HeaderCell != this) return; List<object> distinctValues = FilterHelper.GetDistinctValues(grid.DataSource, grid.Columns[columnIndex].DataPropertyName); var list = new CheckedListBox { CheckOnClick = true, BorderStyle = BorderStyle.None, Dock = DockStyle.Fill }; foreach (object v in distinctValues) { list.Items.Add(new FilterValueItem(v), false); } // 把当前已选中的值重新勾上 if (SelectedValues != null) { foreach (FilterValueItem item in list.Items) { if (SelectedValues.Contains(item.Value)) list.SetItemChecked(list.Items.IndexOf(item), true); } } list.Width = Math.Max(120, grid.Columns[columnIndex].Width + 40); list.Height = Math.Min(list.PreferredHeight, 260); var host = new ToolStripControlHost(list) { AutoSize = false, Padding = Padding.Empty, Size = list.Size }; _dropDown = new ToolStripDropDown { Padding = Padding.Empty }; _dropDown.Items.Add(host); _dropDown.AutoClose = true; Rectangle headerRect = grid.GetCellDisplayRectangle(columnIndex, -1, false); Point screenPt = grid.RectangleToScreen(headerRect); Rectangle workArea = Screen.GetWorkingArea(grid); if (screenPt.Y + headerRect.Height + _dropDown.Height > workArea.Bottom) _dropDown.Show(new Point(screenPt.X, screenPt.Y - _dropDown.Height)); else _dropDown.Show(new Point(screenPt.X, screenPt.Y + headerRect.Height)); _dropDown.Closed += (s, args) => _dropDown = null; }panel 的宽度我用了列宽加 40,给滚动条留出空间。PreferredHeight 是 CheckedListBox 根据项数自动算出的理想高度,超过 260 就截断出现滚动条,避免列值太多时撑满整个屏幕。CheckedListBox 里用户勾选后,点击外部或按 Esc,面板自动关闭,SelectedValues 属性在关闭时从 CheckedItems 里收集。
到这里,一个能点开、能勾选、能自动关闭的下拉面板就有了。但勾完之后表格还没反应——下一步把勾选结果翻译成 DataView 的 RowFilter 表达式。
4. 筛选执行与状态同步:把下拉选择变成 Filter 表达式
4.1 表达式拼接:类型不同,写法不同
BindingSource.Filter 底层走的是 DataView.RowFilter 语法,字符串、数字、日期、布尔、空值的写法完全不同。这是整个功能里最容易写出“看起来对、跑起来报错”的环节。下面这个静态方法按列类型分别拼表达式,并处理好单引号转义:
public static string BuildFilterExpression(DataGridView grid, int columnIndex, List<object> selectedValues) { if (selectedValues == null || selectedValues.Count == 0) return null; DataGridViewColumn col = grid.Columns[columnIndex]; string columnName = col.DataPropertyName; // 从绑定源拿列类型,避免拿不到 DataTable 时默认走字符串 DataTable table = null; if (grid.DataSource is BindingSource bs) table = bs.DataSource as DataTable; else if (grid.DataSource is DataTable dt) table = dt; Type colType = typeof(object); if (table != null && table.Columns.Contains(columnName)) colType = table.Columns[columnName].DataType; var parts = new List<string>(); foreach (object value in selectedValues) { string expr; if (value == null || value == DBNull.Value) { expr = $"[{columnName}] IS NULL"; } else if (colType == typeof(string)) { string safe = value.ToString().Replace("'", "''"); // 单引号翻倍转义 expr = $"[{columnName}] = '{safe}'"; } else if (colType == typeof(DateTime)) { expr = $"[{columnName}] = #{((DateTime)value):yyyy-MM-dd HH:mm:ss}#"; } else if (colType == typeof(bool)) { expr = $"[{columnName}] = {((bool)value ? 1 : 0)}"; } else if (colType == typeof(Guid)) { expr = $"[{columnName}] = '{value}'"; } else { // 数字类型统一用不变文化转字符串,避免小数点被本地化 expr = $"[{columnName}] = {Convert.ToString(value, CultureInfo.InvariantCulture)}"; } parts.Add(expr); } // 同一列多个值之间是 OR,列与列之间再由外层拼 AND return $"({string.Join(" OR ", parts)})"; }数字类型那个分支是血泪教训。默认的 ToString 会跟随当前系统区域设置,有些环境小数点会变成逗号,拼出来的 Filter 表达式直接解析失败。用 InvariantCulture 之后这个问题再没出现过。日期类型用 # 包裹是 RowFilter 的固有语法,如果项目的数据库日期精度只到天,可以简化为 #yyyy-MM-dd#,匹配更宽松。
4.2 多列状态合并与列头颜色回写
单列表达式拼好之后,还要解决多列筛选的协作问题。用户可能先筛了“状态”,又筛了“地区”,两个条件应该同时生效。做法是遍历每一列,找到 HeaderCell 为 FilterHeaderCell 的列,把各自的表达式收集起来用 AND 连接:
public static void ApplyFilters(DataGridView grid) { if (!(grid.DataSource is BindingSource bs)) { // 如果直接绑了 DataTable,建议在外面包一层 BindingSource return; } var conditions = new List<string>(); foreach (DataGridViewColumn col in grid.Columns) { if (col.HeaderCell is FilterHeaderCell filterCell && filterCell.SelectedValues != null && filterCell.SelectedValues.Count > 0) { string expr = BuildFilterExpression(grid, col.Index, filterCell.SelectedValues); if (!string.IsNullOrEmpty(expr)) conditions.Add(expr); } } bs.Filter = conditions.Count > 0 ? string.Join(" AND ", conditions) : null; grid.Invalidate(); // 触发重绘,让有筛选的列头箭头变色 }这里强烈建议统一走 BindingSource 而不是直接操作 DataTable.DefaultView。两者的底层都是 DataView,但 BindingSource 是 DataGridView 的官方搭档,后续做 Current 定位、数据导航都更顺。如果项目里已经写成 dataGridView.DataSource = dataTable,只需插入一行代码把 DataSource 换成 bindingSource,然后把 bindingSource.DataSource 指到原来的 DataTable。
最后一步是在 FilterHeaderCell 里加一个 SelectedValues 公共属性。弹窗关闭后,从 CheckedItems 里把原始 Value 收集进这个属性,然后调用 ApplyFilters。重绘时根据 SelectedValues 是否为空决定箭头颜色,深蓝代表该列有筛选,灰色代表无筛选。这个视觉反馈很重要,没有它,用户勾了几列之后自己都会忘记哪列有过滤条件。
4.3 列的装配:一次性替换所有 HeaderCell
手写每列换 HeaderCell 在代码生成器场景不现实。我习惯在数据绑定前或 Form_Load 里统一遍历一次:
foreach (DataGridViewColumn col in dataGridView1.Columns) { DataGridViewColumnHeaderCell old = col.HeaderCell; if (old is FilterHeaderCell) continue; FilterHeaderCell newCell = new FilterHeaderCell { Value = old.Value, Style = old.Style.Clone() }; col.HeaderCell = newCell; }要注意 Style 一定用 Clone,不能用同一个引用。否则修改某一列的表头样式时,其它列会被连带影响,这是 DataGridView 样式继承机制里一个很经典的坑。做完替换后,确保每一列都绑定了 DataPropertyName,FilterHeaderCell 才能从数据源取值;没绑定的列建议不启用筛选,否则下拉列表永远为空。
5. 避坑:表头下拉筛选最容易翻车的 5 个场景
5.1 点击箭头时列排序也被触发了
现象:点筛选箭头,表格立刻按照这一列升序重排了,下拉面板还要等第二次点击才能弹出来。
原因:列头默认的 SortMode=Automatic,DataGridView 在 MouseDown 阶段就拦截了点击并触发排序。我最初在 OnMouseUp 里做命中判断,排序已经发生,拦截已经晚了。
解决:把命中判断放到 OnMouseDown 里,命中箭头区域时不调用 base.OnMouseDown,排序逻辑自然不会执行。箭头区域之外的点击继续调用 base,排序保留。这样用户点列头文字区域仍然可以排序,点右侧小三角才是筛选,两者互不干扰。
5.2 弹出的列表刚打开就闪烁关闭
现象:用 Form 做弹窗时,鼠标从表头移动到面板的瞬间,窗口消失了,或者面板打开后主窗体标题栏闪烁。
原因:Form 非模态显示后,DataGridView 和 Form 各自抢鼠标捕获。ToolStripDropDown 如果没有被正确设置 AutoClose,也会出现关闭延迟或提前关闭的怪问题。
解决:不再用 Form,改用 ToolStripDropDown 承载 CheckedListBox,并保持 AutoClose=true。另外要注意,ToolStripDropDown 一定不能设置 TopLevel 之类属性,它的显示和关闭完全由自身管理,不需要额外写 LostFocus 处理。弹出期间如果用户点击同一列表头的箭头区域,在 OnMouseDown 里先关掉 _dropDown 并 return,不要让排序和面板关闭同时发生。
5.3 筛选后行号错乱,代码取错数据
现象:筛选后点击某一行,程序报索引越界,或者取出来的是筛选前的另一个数据。
原因:DataGridView 显示的是视图里的行,可视行索引和底层 DataTable 的行索引不再对应。很多人习惯用 dataGridView.Rows[e.RowIndex].Cells[0].Value 直接取值,筛选前没问题,筛选后 RowIndex 是视图索引。
解决:不要通过 DataGridView.Rows 的索引去回查数据行。如果只需要当前选中行的数据,用 dataGridView.CurrentRow.DataBoundItem 拿到绑定对象;如果某列的值需要回写到原表,先通过 ((DataRowView)dataGridView.CurrentRow.DataBoundItem).Row 拿到真正的 DataRow。还有一种是拼命想用 BindingSource.Find 来定位行,Find 的参数是列名和值,在筛选状态下也能正确命中视图位置,但多数场景不如 DataBoundItem 直接。
5.4 字符串里的单引号和空值导致表达式解析失败
现象:下拉筛选某列时抛出 System.Data.SyntaxErrorException,或者空值行怎么都筛不出来。
原因:用户数据里有“O'Brien”这类带单引号的字符串,拼进 Filter 表达式后破坏了(也更准确地说是终止了)字符串的字面量解析。空值则是另一个问题:DataTable 中的空值绝大多数是 DBNull,RowFilter 对 NULL 的写法必须是 IS NULL,写成= ''匹配不到任何行。
解决:字符串值在拼接前统一 Replace("'", "''"),这是 SQL 和 RowFilter 通用的转义方式。空值判断放在最前面,检测到 null 或 DBNull 就生成列 IS NULL表达式,而不是走字符串分支。另外,如果列里既有真正的空字符串也有 DBNull,要做合并显示“(空白)”,筛选时要把空字符串和 DBNull 都选中,否则用户勾了空白项却只筛出一半数据。
5.5 十万行数据打开下拉卡死两三秒
现象:数据量到了几十万行,第一次点开某一列的下拉列表,界面假死,几秒后才弹出来,第二次点就快很多。
原因:GetDistinctValues 在 UI 线程里全表遍历并做去重排序,首次加载没有任何缓存,几十万行字符串的排序在 UI 线程足以造成明显卡顿。第二次快是因为操作系统页缓存和数据行的字符串对象已经被创建过一轮,但本质上还是卡过。
解决:把取列表值的逻辑改为异步预加载,或者至少延迟到后台线程执行。更彻底的方案是把列值缓存到字典里,首次计算后缓存,数据源变化时清空缓存。详情在下一章展开,这里先记住一个原则:凡是和行数成正比的遍历,都不要在 UI 线程同步做,哪怕这次数据量不大,也要写成可扩展的形式。
6. 进阶:缓存列值分布、持久化筛选状态与快速验证
6.1 用异步预热替代首屏卡顿
我处理过一张五十多万行的业务表,列值去重在 UI 线程跑一次大约要两秒半,用户直接抱怨“点一下卡一下”。后来我把列值统计改成后台线程预加载,在 Form_Shown 里对所有可筛选列提前算一次,存进一个字典缓存:
public class FilterValueCache { private readonly Dictionary<string, List<object>> _cache = new(); public async Task<List<object>> GetValuesAsync(DataGridView grid, DataGridViewColumn col) { string key = col.DataPropertyName; if (_cache.TryGetValue(key, out var cached)) return cached; List<object> values = await Task.Run(() => FilterHelper.GetDistinctValues(grid.DataSource, key)); _cache[key] = values; return values; } public void Invalidate() => _cache.Clear(); }下拉面板打开时调用 GetValuesAsync,异步结果返回前可以先显示一个“加载中”占位。缓存失效的时机是数据源重新绑定或数据行批量增删之后,一般在绑定完成后调用 Invalidate。这个做法把一次全表遍历从“每次打开都卡”降成了“数据变更后才卡一次”,配合后台线程基本无感。
6.2 筛选状态的保存与恢复
筛选条件在用户关掉程序再打开后丢失,是很多内部工具被吐槽的点。常见做法是把每列选中值序列化成 XML,存到 Environment.SpecialFolder.ApplicationData 下。恢复时在 Form_Shown 里反序列化,把值放回对应列的 SelectedValues,再执行一次 ApplyFilters。
// 保存 var doc = new XElement("Filters"); foreach (DataGridViewColumn col in dataGridView1.Columns) { if (col.HeaderCell is FilterHeaderCell fc && fc.SelectedValues != null && fc.SelectedValues.Count > 0) { doc.Add(new XElement("Filter", new XAttribute("Column", col.DataPropertyName), fc.SelectedValues.Select(v => v?.ToString() ?? "").ToArray())); } } doc.Save(Path.Combine(folder, "gridFilters.xml"));恢复时注意类型转换:XML 读回来全是字符串,要按列的实际类型转换成 int、DateTime 或 bool 再塞进 List
6.3 一列筛选是否真的生效:用最小用例验证
最后分享一个我常用的验证技巧。手动勾选后,如果怀疑筛选结果不对,先不盯界面,直接输出 BindingSource.Filter 的最终值:
可以先把 Filter 表达式复制出来,在代码里用 DataTable.Select 或 DataView 单独跑一遍,看返回行数是否和界面一致。如果表达式单独跑是对的而界面上不对,问题出在 BindingSource 没有正确刷新,这时调用 bindingSource.ResetBindings(false) 一般能解决。如果表达式单独跑就错,按类型逐项检查引号、数字格式、日期格式三处最常出错的位置。这个排查顺序帮我省掉了大量“看着界面猜原因”的时间。回头看,这个功能不难,但细节确实不少。现在凡是行数可能增长的操作,我都会默认先想清楚它会不会卡住 UI 线程再写代码。希望帮到你。
本文还有配套的精品资源,点击获取