简介:面向WPF开发者及希望进阶桌面表格开发的初学者,这份DataGrid实例围绕数据绑定、列定义、样式模板、增删改交互、排序分页与数据验证等常见需求展开,帮助读者理解如何在真实项目中搭建清晰、可编辑且易维护的数据视图。压缩包共347个文件,以191个C#源码、44个XAML界面文件、39个BAML编译资源及若干项目、程序集文件为主,整体约1.98MB,便于对照后台逻辑与前台界面结构学习。已有427人学习。资料包含主从表联动、完整样式定制、异常与接口验证、交互操作等示例,可帮助读者掌握ObservableCollection数据源绑定、关闭自动列生成后的模板列定制、行编辑事件处理、排序事件以及Required、Regex等验证规则配置,也能参考自定义头标和上下文菜单等扩展思路,适合作为WPF表格功能开发与查漏补缺的实践参考。
1. 一个卡了三秒的表格:DataGrid 实例能解决什么
某公司后台的订单页,五千行数据,鼠标一拖滚动条白屏两秒;勾选框要点两下才生效;编辑完最后一格直接点保存,那一格的值就丢了。这些毛病我在 WPF 项目里见过太多次,几乎每个桌面端后台都有一张被赶工写出来的表格。这份 WPF 下的 DataGrid 实例,做的就是把这些常见需求一次性做对:数据绑定、增删改查、排序筛选、模板列、下拉列、冻结列、条件样式、导出 CSV,全部是可运行的样例代码,不是零散片段。
它适合两类人:刚接手 WPF 桌面项目、被 DataGrid 的各种隐式行为坑过的新手;以及从 WinForm 往 WPF 搬表格、发现DataGridView那套经验大半失效的老手。下面按“这项目长什么样 → 数据怎么流 → 怎么跑顺 → 怎么改”的顺序拆。
2. 项目骨架:实例的目录结构、技术栈与 MVVM 分层
2.1 目录结构:XAML、模型、视图模型分开放
实例的工程结构不复杂,但每一层都有明确分工,照着这个切法改业务表格会省很多事:
WpfDataGridDemo/ ├─ App.xaml ├─ MainWindow.xaml 表格主界面:列定义、行样式、右键菜单 ├─ MainWindow.xaml.cs 只留窗口生命周期和纯 UI 交互 ├─ Models/ │ └─ OrderRow.cs 行实体:INotifyPropertyChanged + IEditableObject ├─ ViewModels/ │ ├─ ViewModelBase.cs INotifyPropertyChanged 的公共实现 │ └─ OrderListViewModel.cs 集合、命令、筛选、导出 ├─ Behaviors/ │ └─ DataGridRowNumberBehavior.cs 行号等附加行为 ├─ Converters/ │ └─ StatusToBrushConverter.cs ├─ Resources/ │ └─ DataGridStyles.xaml 单元格、行、表头样式集中管理 └─ Services/ └─ CsvExporter.cs 导出实现,不依赖 Office这么切的关键理由有三个。一是样式集中放Resources,改主题只动一个文件,不会出现“这个页面的表头是灰的、那个页面是白的”这种历史遗留。二是导出抽到Services,以后想从 CSV 换成 xlsx,只替换一个类的实现,ViewModel 一行不用改。三是MainWindow.xaml.cs保持干净,只留下“打开窗口、关闭窗口、滚动定位”这类纯 UI 动作,业务逻辑一律进 ViewModel。
提示:如果实例里只有一张表,不必强上分层;但只要预计后面还要加第二张、第三张表,第一张表就按这个结构写,回头抽公共基类时你会庆幸。
2.2 技术栈与运行环境:不用第三方 UI 库也能做出像样的表格
整套东西是纯 WPF + C# + XAML,DataGrid属于System.Windows.Controls命名空间。这里有个版本常识值得记一下:DataGrid从 .NET Framework 4.0 起才正式进入 WPF 官方控件集,更早的版本得去 WPF Toolkit 里找。所以你在维护一个很老的项目时,如果发现DataGrid的命名空间不是System.Windows.Controls,那大概率是 Toolkit 版本,行为和现在的官方版有差异,别直接抄博客里的代码。
目标框架选 Windows 桌面即可,.NET Framework 4.x 和 .NET 6/8 的 windows 目标都能跑这套写法,没有需要特殊处理的 API。开发环境用 Visual Studio,装“NET 桌面开发”工作负载就行,不需要装任何第三方控件库——表头样式、斑马纹、单元格对齐这些,靠Style和DataTrigger完全做得出来,反而比引一个重 UI 框架更好控制。
数据层在实例里是内存集合,好处是能直接跑、能离线看效果。真实项目里把它换成数据库查询结果即可,ViewModel 拿到的仍然是ObservableCollection<T>,绑定的那一侧完全不用动。导出走的是StreamWriter写 CSV,不依赖 Office 组件,服务器上没有 Excel 也能导。
2.3 选 MVVM 还是后台代码:先看实例怎么分层的
新手最容易纠结的一点:DataGrid 到底该用 MVVM 绑,还是在xaml.cs里直接grid.ItemsSource = list?实例的选择是“主流程 MVVM,边缘动作放后台”,我认为这个取舍是对的。
主流程指数据集合、当前选中行、增删改命令、筛选条件、导出动作,这些全部走 ViewModel 的属性和ICommand,好处是改需求时不用动 XAML,加个单元测试也方便。边缘动作指行号渲染、双击进入编辑、滚动到指定行这类纯视觉行为,硬塞进 ViewModel 反而是负担,写成附加行为或者干脆留在 code-behind 里更清爽。
另一个明确的决定是AutoGenerateColumns="False"。DataGrid默认会按数据源的属性名自动生成列,看着省事,实际项目里几乎一定要关掉:列头是英文属性名、顺序不可控、想加个按钮列或下拉列还得跟自动生成的列打架。手动定义列虽然多写几行 XAML,但列宽、顺序、格式化、编辑模板全在自己手里。
3. 核心链路拆解:一条订单数据从集合走到单元格
3.1 行实体:INotifyPropertyChanged 与 IEditableObject 一个都不能少
行实体是整个链路的地基。最常见的翻车是只写了普通属性,结果界面怎么改都不刷新:
public class OrderRow : INotifyPropertyChanged, IEditableObject { private string _customer = string.Empty; private decimal _amount; private string _status = "待处理"; public string Customer { get { return _customer; } set { if (_customer == value) return; // 值没变就别通知,省一次无谓刷新 _customer = value; Raise(nameof(Customer)); } } public decimal Amount { get { return _amount; } set { if (_amount == value) return; _amount = value; Raise(nameof(Amount)); } } public string Status { get { return _status; } set { if (_status == value) return; _status = value; Raise(nameof(Status)); } } // 编辑前的快照,用于按 Esc 取消编辑时回滚 private OrderRow _backup; public void BeginEdit() { _backup = (OrderRow)MemberwiseClone(); } public void EndEdit() { _backup = null; } public void CancelEdit() { if (_backup == null) return; Customer = _backup.Customer; Amount = _backup.Amount; Status = _backup.Status; _backup = null; } public event PropertyChangedEventHandler PropertyChanged; private void Raise(string name) { var handler = PropertyChanged; if (handler != null) handler(this, new PropertyChangedEventArgs(name)); } }INotifyPropertyChanged解决的是“值变了界面要知道”,IEditableObject解决的是“编辑中途反悔了怎么办”。DataGrid在进入行编辑时会调BeginEdit,提交时调EndEdit,用户按 Esc 或切走时调CancelEdit——你没实现的话它就什么都不做,用户改了一半按 Esc,值会留在界面上,看着像没保存成功,其实内存里已经脏了。
参数上的两个细节:MemberwiseClone是浅拷贝,字段全是值类型或字符串时够用,如果实体里有嵌套对象,得自己写深拷贝;Raise里先取handler再判空,是为了避开多线程下事件被置空的竞态,虽然 UI 线程改属性时基本遇不到,但养成习惯没坏处。
3.2 集合与视图:ObservableCollection 打底,ICollectionView 管排序筛选
集合这一层,ObservableCollection<T>是默认答案:
public class OrderListViewModel : ViewModelBase { public ObservableCollection<OrderRow> Orders { get; private set; } public ICollectionView OrdersView { get; private set; } public string Keyword { get; set; } public OrderListViewModel() { Orders = new ObservableCollection<OrderRow>(SeedData()); // 用 CollectionViewSource 包一层,避免多个控件抢同一个默认视图 var cvs = new CollectionViewSource { Source = Orders }; OrdersView = cvs.View; OrdersView.SortDescriptions.Add( new SortDescription(nameof(OrderRow.Amount), ListSortDirection.Descending)); OrdersView.Filter = FilterByKeyword; } private bool FilterByKeyword(object item) { var row = item as OrderRow; if (row == null) return false; if (string.IsNullOrWhiteSpace(Keyword)) return true; return (row.Customer ?? string.Empty) .IndexOf(Keyword, StringComparison.OrdinalIgnoreCase) >= 0; } public void RefreshFilter() { OrdersView.Refresh(); // 改完 Keyword 必须显式刷新,视图不会自己重新跑 Filter } }ObservableCollection负责“集合本身增删了要通知”,行内属性变化还是要靠INotifyPropertyChanged,两者缺一不可,这是新手最常混的一对。ICollectionView则是表格的“视图层滤镜”:Filter决定哪些行可见,SortDescriptions决定排序,后面加GroupDescriptions就能做分组。注意Filter是个委托,改完Keyword不会自动重跑,必须Refresh(),忘了这行就会得到“输入框打了字但表格没动”的经典 bug。
GetDefaultView和new CollectionViewSource{Source=...}.View的差别也值得说一句:前者返回的是这个集合共享的默认视图,如果同一个集合同时绑到两个表格上,两边的排序会互相干扰;后者每次给你一份独立视图,代价是多一点内存。实例里选了后者,图个干净。
3.3 列定义:文本列、模板列、下拉列各写一遍
列定义的部分最能体现“照着抄就能改”:
<DataGrid ItemsSource="{Binding OrdersView}" AutoGenerateColumns="False" CanUserAddRows="False" CanUserDeleteRows="False" SelectionUnit="FullRow" HeadersVisibility="Column" GridLinesVisibility="Horizontal" RowHeight="32"> <DataGrid.Columns> <DataGridTextColumn Header="客户" Width="180" Binding="{Binding Customer, UpdateSourceTrigger=PropertyChanged}"/> <DataGridTextColumn Header="金额" Width="110" Binding="{Binding Amount, StringFormat={}{0:N2}}" ElementStyle="{StaticResource RightAlignedCell}"/> <DataGridComboBoxColumn Header="状态" Width="120" SelectedItemBinding="{Binding Status, UpdateSourceTrigger=PropertyChanged}"/> <DataGridTemplateColumn Header="备注" Width="*" SortMemberPath="Remark"> <DataGridTemplateColumn.CellTemplate> <DataTemplate> <TextBlock Text="{Binding Remark}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center" Margin="4,0"/> </DataTemplate> </DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellEditingTemplate> <DataTemplate> <TextBox Text="{Binding Remark, UpdateSourceTrigger=PropertyChanged}" BorderThickness="0"/> </DataTemplate> </DataGridTemplateColumn.CellEditingTemplate> </DataGridTemplateColumn> </DataGrid.Columns> </DataGrid>DataGridTextColumn最简单,Binding里带UpdateSourceTrigger=PropertyChanged是为了每敲一个字符就同步到实体,而不是等焦点离开——不写这个,用户改完直接点保存按钮,那一格的值还在编辑器里没提交,就是我前面说过的丢数据。
ElementStyle指向一个TextBlock样式,用来做金额右对齐、留右边距,这样数字列看起来才像财务表。
DataGridComboBoxColumn是坑最多的一列:它不继承自FrameworkElement,不在可视树里,没有DataContext,所以ItemsSource="{Binding StatusList}"这种写法一定绑不上。实例的做法是把下拉数据源做成静态资源或通过ElementStyle里的Setter注入,下面避坑一章会展开。
DataGridTemplateColumn是万能兜底,CellTemplate管显示、CellEditingTemplate管编辑,两者可以完全不同。注意它没有Binding属性,DataGrid自己不知道按什么排序,所以要显式写SortMemberPath,否则点这一列的表头没反应。
3.4 编辑提交与删除:CommitEdit 的顺序别写反
用户正在编辑某一格,直接点工具栏的“保存”,这是最容易丢数据的分支:
private void OnSaveClick(object sender, RoutedEventArgs e) { // 第一步:把当前单元格里正在编辑的值推回实体 Grid.CommitEdit(DataGridEditingUnit.Cell, true); // 第二步:结束整行编辑状态,触发行级校验和 IEditableObject.EndEdit Grid.CommitEdit(DataGridEditingUnit.Row, true); var vm = DataContext as OrderListViewModel; if (vm == null) return; vm.SaveCommand.Execute(null); }两个CommitEdit的单位不一样,顺序不能反。先Cell后Row,是因为行级提交前必须保证单元格里的编辑值已经写回对象;反过来写,行编辑结束了但单元格还挂着临时值,那一格就丢了。第二个参数exitEditingMode传true,表示提交完顺便退出编辑态,不传的话表格会停在编辑框里,视觉上像没保存。
删除同理,SelectedItems拿到的是IList,里面是object,转成OrderRow后再从Orders移除。移除会触发CollectionChanged,DataGrid自动少一行,不需要手动刷新。批量删除时要倒序删或者先ToList()再遍历,正序边遍历边删集合会抛异常。
4. 五万行也不卡:虚拟化、冻结列与条件样式的落地写法
4.1 虚拟化三件套:IsVirtualizing、CanContentScroll、Recycling
数据量一上来,最先崩的是滚动性能。DataGrid本身支持 UI 虚拟化,但有几个开关必须一起开才对:
<DataGrid ItemsSource="{Binding OrdersView}" EnableRowVirtualization="True" EnableColumnVirtualization="True" VirtualizingPanel.IsVirtualizing="True" VirtualizingPanel.VirtualizationMode="Recycling" ScrollViewer.CanContentScroll="True" ScrollViewer.IsDeferredScrollingEnabled="False">EnableRowVirtualization默认就是True,只渲染可视区域内的行,五万行和五百行在视觉上没有区别。EnableColumnVirtualization默认是False,列特别多(二三十列)时打开它才有意义,列少的时候开了反而增加调度开销。
VirtualizationMode有两个值,Standard是每次滚动都新建容器,Recycling是复用已经滚出视野的容器。选Recycling内存更稳,但要注意一个副作用:容器被复用了,行样式里如果有依赖“这一行是第几行”的逻辑,滚一下就可能串到别的行上。
ScrollViewer.CanContentScroll="True"这一条最容易被忽略。它默认是True,但很多人为了让滚动“顺滑一点”把它改成False,或者在外面套一层ScrollViewer,结果虚拟化直接失效——因为虚拟化依赖逻辑滚动单位来判断哪些行在视野里,改成像素滚动之后它就不知道了,于是把五万行全渲染出来,内存瞬间起飞。
4.2 冻结列、行号、右键菜单这些细节
冻结列一行属性就够:FrozenColumnCount="2",左边两列在横向滚动时固定不动。这个属性的实现是复制一份列到左侧的冻结区,所以别设得太随意,冻结列总宽度超过控件宽度时会出现横向滚动异常。
行号不在数据里,走RowHeader加事件:
private void OnLoadingRow(object sender, DataGridRowEventArgs e) { // GetIndex 拿的是视图内序号,排序、筛选之后会重算 e.Row.Header = (e.Row.GetIndex() + 1).ToString(); }把它挂到LoadingRow而不是初始化时统一赋值,是因为虚拟化下容器会被复用,滚回来的行会重新触发这个事件,行号才能跟着排序结果走。GetIndex()需要较新的 .NET,老项目里用Items.IndexOf(e.Row.Item)代替。
右键菜单有个隐形坑:ContextMenu不在可视树里,DataContext继承不过去,Command="{Binding DeleteCommand}"会静默失效。稳妥写法是显式指定数据源:
<DataGrid.ContextMenu> <ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}"> <MenuItem Header="删除选中行" Command="{Binding DeleteCommand}"/> </ContextMenu> </DataGrid.ContextMenu>PlacementTarget就是弹出菜单的那个DataGrid,从它身上取DataContext再喂给菜单,绑定链路就通了。
4.3 条件样式与转换器的取舍
状态列的着色,实例用的是DataTrigger而不是IValueConverter:
<DataGrid.RowStyle> <Style TargetType="DataGridRow"> <Style.Triggers> <DataTrigger Binding="{Binding Status}" Value="已取消"> <Setter Property="Foreground" Value="#9E9E9E"/> <Setter Property="Background" Value="#FAFAFA"/> </DataTrigger> <DataTrigger Binding="{Binding Status}" Value="异常"> <Setter Property="Foreground" Value="#C62828"/> </DataTrigger> </Style.Triggers> </Style> </DataGrid.RowStyle>DataTrigger的好处是纯声明式,值只有几种时最直观,也不用担心转换器打错字返回null导致整行样式丢失。值来自后端枚举且种类会增长时,转换器更合适,但要注意两点:Convert方法在每次刷新时对每一行都会调用,里面千万别new SolidColorBrush,改成静态只读并且Freeze()掉,否则五万行就是五万次对象分配;转换器返回DependencyProperty.UnsetValue可以让样式回落到默认值,返回null则可能导致绑定异常。
5. 避坑与排查:DataGrid 上最容易翻车的五个点
5.1 绑定与刷新类:UI 不动、列不出来
坑一:改了集合里对象的属性,表格纹丝不动。现象是把Orders[0].Amount改大,界面上还是旧值;换成重新赋值整个集合才有反应。 原因是实体要么没实现INotifyPropertyChanged,要么实现了但绑定的是字段而不是属性——WPF 绑定只认属性,public decimal Amount;这种公有字段绑上去永远是初始值。 解决办法是把字段改成属性并在 setter 里发通知,同时集合用ObservableCollection<T>,两者都到位界面才会刷新。
坑二:手工写了列,运行时却多出一堆英文列头。现象是 XAML 里定义了六列,界面上冒出十二列,前六列还是Customer、Amount这种属性名。 原因是AutoGenerateColumns默认为True,你手写的列和自动生成的列会同时存在。排查时先看 XAML 根节点有没有写AutoGenerateColumns="False"。 还有一种情况是写了False但某些列还是空白的,那多半是Binding路径拼错了,WPF 不会抛异常,只在输出窗口打一行System.Windows.Data Error: 40,养成跑起来先看输出窗口的习惯,能省掉大量瞎猜。
坑三:DataGridComboBoxColumn 的下拉永远是空的。现象是编辑那一列时下拉框点开没数据,而同一个ItemsSource绑到页面上的独立ComboBox却正常。 原因是DataGridColumn不继承自FrameworkElement,它不在可视树里,拿不到DataContext,所以ItemsSource="{Binding StatusList}"无从解析。 解决办法是改用ElementStyle/EditingElementStyle里的Setter注入,或者直接换成DataGridTemplateColumn,在编辑模板里用RelativeSource AncestorType=DataGrid找祖先拿DataContext。后者多写几行 XAML,但绑定链路一目了然,我一般优先选它。
5.2 编辑与性能类:丢数据、滚动串行
坑四:勾选复选框要点两下才生效。现象是第一次点没反应,第二次才打上勾。 原因是DataGridCheckBoxColumn默认的编辑行为是“第一次点击进入编辑态,第二次点击才改值”。临时用键盘空格键能绕过去,但不是办法。 解决办法是给这一列换成模板列,在CellTemplate里直接放一个CheckBox绑到属性上,UpdateSourceTrigger=PropertyChanged,点击即生效;代价是这一列不再走行级编辑提交。另一个常见来源是保存时漏了CommitEdit,用户勾完直接点保存,值还挂在编辑器里。
坑五:数据量一大,滚动就卡,内存还一路涨。现象是前一百行很顺,往下拖就开始一顿一顿,任务管理器里内存几百兆往上飙。 先查虚拟化:外层是不是套了ScrollViewer,CanContentScroll是不是被设成了False,RowHeight有没有设成Auto——Auto会让每一行都测量实际内容,虚拟化直接废掉。 再查样式:RowStyle里有没有x:Shared="False"的资源、有没有每行new画刷或图片、转换器里干了重活。 最后查列:DataGridTemplateColumn的模板里塞了WebBrowser、复杂DataGrid嵌套这类重控件,五万行滚起来必卡,能改成按需加载就改,改不了就上分页。
注意:这几个坑里,前三个是“一开始就写对”的问题,后两个是“数据量上来才暴露”的问题。前者靠代码评审能拦住,后者只能靠压测,别等上线后再查。
6. 二次开发:把示例表格改造成业务表格的三步
拿到这份实例,真正要干的通常不是跑通,而是把它换皮成自己业务里的表格。我一般按三步走。
第一步,换实体。把OrderRow换成你的业务对象,属性名直接对应数据库字段或 DTO 字段,INotifyPropertyChanged和IEditableObject两套实现原样保留。这一步最容易偷懒的地方是漏掉集合属性的处理——如果你的实体里有List<Item>这种子集合,MemberwiseClone是浅拷贝,CancelEdit回滚时子集合不会还原,得自己补深拷贝。
第二步,决定分页还是虚拟化。几千行内存集合,虚拟化足够;上万行、还要带复杂模板列,直接上分页更稳。分页的骨架很简单:
public class PagedResult<T> { public IReadOnlyList<T> Items { get; set; } public int TotalCount { get; set; } public int PageIndex { get; set; } public int PageSize { get; set; } public int PageCount { get { return (TotalCount + PageSize - 1) / PageSize; } } } // ViewModel 里把集合换成整页替换,而不是逐条 Add private void ApplyPage(PagedResult<OrderRow> page) { Orders.Clear(); // 会触发 Reset,比逐条 Remove 快得多 foreach (var row in page.Items) Orders.Add(row); PageIndex = page.PageIndex; TotalCount = page.TotalCount; }Orders.Clear()触发的是集合 Reset 通知,表格一次性重建,比循环Remove快一个数量级。分页时记住把SortDescriptions清空,排序交给后端做,前端再排一次会和分页结果打架。
第三步,验证。改完别用三条假数据点两下了事,塞五万条进去,把滚动条从头拖到尾,看内存曲线和帧率;再把每一列都点开编辑一次,按 Esc 取消,看点保存后值会不会回滚;最后导出一次 CSV,用表格软件打开确认中文不乱码(UTF8Encoding(true)带 BOM 就是为这个)。
改造点对应的文件我整理成一张表,改的时候照着找:
| 改造点 | 动哪个文件 | 注意什么 |
|---|---|---|
| 换实体字段 | Models/OrderRow.cs | 属性而非字段,setter 里发通知 |
| 换列和列宽 | MainWindow.xaml的DataGrid.Columns | 模板列必须写SortMemberPath |
| 换配色和行高 | Resources/DataGridStyles.xaml | 画刷Freeze(),别放x:Shared="False" |
| 换数据来源 | ViewModels/OrderListViewModel.cs | 分页时清空SortDescriptions |
| 换导出格式 | Services/CsvExporter.cs | 字段含逗号要加引号转义 |
从那以后我每次接表格需求,都先塞五万条假数据把滚动条拖一遍,再谈样式和交互——顺序反过来,返工的是自己。希望帮到你。
本文还有配套的精品资源,点击获取