ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

DevExpress WinForms Data Grid进阶:绑定、主从表与大数据量性能优化

DevExpress WinForms Data Grid进阶:绑定、主从表与大数据量性能优化 做WinForms开发的人迟早会在DevExpress WinForms的Data Grid数据绑定上栽一次跟头。不少同事在我旁边拉了个小窗口把DataTable往GridView.DataSource上一塞看起来跑起来了可等需求变成“按状态过滤”“主从联动”“10万行不卡”时这一行代码就成了最先被推翻的地方。我当年为了搞清BindingSource和Grid之间到底隔了几层连续加过两个夜班。上一篇我们聊完了DevExpress WinForms Data Grid的基础数据绑定包括DataSource、DataTable、Columns的自动生成今天这篇是进阶篇重点拆解BindingSource的实际价值、主从结构的搭建、大数据量下的异步绑定、未绑定列和编辑校验这些才是决定一个网格能不能真正放上生产环境的关键。如果你刚接触Data Grid建议先把上一篇的基础过一遍再回来看这里。1. BindingSource这层中间件强烈建议不要跳过1.1 为什么不建议直接把DataTable塞给GridView直接gridControl.DataSource dataTable的写法在很多博客和官方Demo里都出现代码量最少界面也能马上出数据。但我不建议在正式项目里这么干原因很简单后续想加过滤、排序、分页、定位当前行这些操作全都要和GridView自己的逻辑纠缠在一起业务层和界面层的边界会慢慢糊掉。BindingSource可以理解成“数据源和界面之间的一张调度桌”。你先让数据表进BindingSource再把BindingSource交给Grid。之后想过滤就设置bs.Filter想排序就设置bs.Sort想定位就用bs.Position网格基本不需要动。它本身还帮你维护了一条“当前记录”的游标绑定后网格的当前行和BindingSource的当前记录天然对应这在主从联动和批量操作时非常省事。BindingSource bsOrders new BindingSource(); bsOrders.DataSource dtOrders; // dtOrders是一个已经加载好的DataTable gridOrders.DataSource bsOrders; // 后续业务代码 bsOrders.Filter Status Open; bsOrders.Sort OrderDate DESC; bsOrders.Position bsOrders.Find(OrderId, 10248);这里的Filter和Sort并不会破坏原DataTable的数据它们只是改变了BindingSource对外暴露的视图。真实项目里用户多条件筛选、切换排序字段这种高频需求我基本都是通过BindingSource完成的GridView只负责展示。1.2 BindingSource与实体列表打交道时的注意事项用ListT直接绑定Data Grid并不是不行但要踩一个很隐蔽的坑普通List在新增、删除元素时不会主动通知网格所以你往List里Add一条记录界面上可能毫无变化必须重新gridControl.DataSource list或者调gridControl.RefreshDataSource()。如果你用的是BindingList 或ObservableCollection 情况就好很多因为它们实现了集合变化的通知接口。BindingSource本身也自带一层“召唤”机制所以很多时候你以为列表已经自动刷新了其实是BindingSource兜底扛住了。把实体集合交给BindingSource时我会这样写BindingSource bs new BindingSource(); bs.DataSource new BindingListOrder(orders); gridControl.DataSource bs;从实践角度讲这个习惯能让你在写业务代码时不用反复操心UI刷新问题。特别是列表数据在后台线程变化时你只需要在正确线程里更新集合刷新逻辑交给通知机制去处理。1.3 Grid当前行与BindingSource.Current的对应关系这是一处最容易被忽略的联动点。Data Grid里有一个“FocusedRowHandle”用来表示当前高亮行但分组、过滤之后FocusedRowHandle和BindingSource.Position并不一定相同。我吃过一个亏界面上放了“下一笔待处理订单”按钮直接取gridView.FocusedRowHandle 1来回跳结果数据过滤后定位完全错乱。正确的做法是通过当前行的主键值或业务对象来定位而不是用行号// 拿到当前聚焦的业务对象 Order currentOrder gridView.GetFocusedRow() as Order; if (currentOrder null) return; // 在BindingSource里按主键查找位置 int position bsOrders.Find(OrderId, currentOrder.OrderId); bsOrders.Position position;这样即使过滤条件变化、排序顺序变化只要主键值没变定位就不会跑偏。这个思路同样适用于程序自动刷新数据后的行重建。只要你在刷新前记住了主键刷新后按主键回定位用户体验就稳定得多。2. 主从表联动两种绑定思路和几个容易翻车的细节2.1 基于DataRelation的主从绑定适合纯DataTable场景主从表Master-Detail是业务系统里很常见的需求一笔订单对应多行明细一个客户对应多张合同。如果你的数据源就是DataTable最简单直接的方式是在DataSet里建Relation然后让GridView自动识别关系。DataSet ds new DataSet(); ds.Tables.Add(dtOrders); // 主表 ds.Tables.Add(dtOrderItems); // 明细表 DataRelation relation new DataRelation(OrderItems, dtOrders.Columns[OrderId], dtOrderItems.Columns[OrderId]); ds.Relations.Add(relation); gridControl1.DataSource dtOrders;绑定之后主表左侧会自动出现展开“号”点开就能看到明细。这里有一个必须注意的前提关联字段两侧都要有索引或者能快速匹配的键否则展开时会有性能问题。另外两个表都必须属于同一个DataSet跨DataSet的DataRelation是建不起来的。很多人在这种模式下遇到的第一个怪毛病是主从绑定了但“号”就是不出来。原因大多是GridView的关系层没有设置好或者MasterRowEmpty事件没有被正确处理。如果你用DataRelation方式通常系统能自动识别但如果明细过滤后为空行前的“号”也会消失这是正常行为并不是绑定失败。2.2 基于独立业务对象的主从绑定适合实体列表当你的数据源是实体对象集合时DataRelation方式就不太灵光了。我推荐用MasterRowGetChildRow事件按需返回子集合。这种方式的优势是灵活子表数据可以是懒加载的点击展开才去取。gridControl1.DataSource orders; // ListOrder viewOrders.MasterRowEmpty (s, e) { e.IsEmpty false; }; viewOrders.MasterRowGetChildRow (s, e) { Order order viewOrders.GetRow(e.RowHandle) as Order; if (order null) return null; return order.Items; // ListOrderItem };用这种方式时主表和子表各自独立不需要额外维护关系对象业务模型本身就已经包含了层次关系。实际项目中我通常在主表实体里暴露Items属性事件里直接返回即可。2.3 主从联动里最容易翻车的三个细节第一MasterRowEmpty事件里的e.IsEmpty要设置好。如果你不处理这个事件控件可能不知道子表到底有没有数据从而不显示展开按钮。空状态和无法展开在用户界面上的体感完全不同空状态更应该是“展开后空白页”而不是“没有展开按钮”。第二排序后展开明细数据错位。这个问题我排查过很久。原因是在MasterRowGetChildRow里如果用e.RowHandle直接去取主表数据排序会改变RowHandle的语义。安全做法是事件内通过GetRow(e.RowHandle)获取业务对象再取子集合不要依赖索引。// 错误示范拿RowHandle当数组下标用 int index e.RowHandle; Order o orders[index]; // 正确示范用GetRow取业务对象 Order o viewOrders.GetRow(e.RowHandle) as Order;第三多级嵌套时层级名称要对上。三级主从比如“客户—订单—明细”在GridView的LevelTree里要分别设置每一层的RelationName。一旦名字对不上某一级就会静默失效界面上看起来就是“能展开一层再下一层怎么也出不来”。3. 十万行级别的数据加载Server Mode与Instant Feedback Mode的取舍3.1 直接绑定的卡顿根源很多人喜欢“先跑起来再说”但数据量一旦过万直接绑定的体验就会断崖式下跌。拖一个包含5万行数据的DataTable进Grid界面假死两三秒是常事点击筛选再等十几秒也不罕见。卡顿的根源不完全在“绘制”本身GridView有虚拟滚动能力真正拖累的是控件的行状态管理和内部索引重建。因为网格要为每一行维护显示状态、校验状态、行高等信息当数据源变化时这些信息需要重新计算。所以数据量上来后无脑直接绑定不是可否的问题而是性能事故的隐患。3.2 Server Mode把排序和过滤推回数据源端Server Mode的核心思想是网格不再一次性拿全部数据而是“需要哪些行就向数据源要哪些行”。排序、过滤这些重操作也由数据源端执行界面只接收结果的分页片段。对接数据库或者远程服务时这种模式尤其合适。DevExpress.Data.ServerModeDataSource serverDS new DevExpress.Data.ServerModeDataSource(); serverDS.KeyExpression Id; serverDS.SortExpression [OrderDate] DESC; serverDS.Source myOrderService; // 实现对应数据源契约的服务对象 gridControl1.DataSource serverDS;KeyExpression必须是一个稳定且唯一的字段相当于每行数据的身份证。没有稳定主键的服务端模式基本没法正确工作。我之前接一个老系统的接口返回的实体里没主键字段后来在底层包装了一层GUID虚拟主键才跑起来。Server Mode在操作上的限制也比普通绑定多编辑必须配合一个可更新的后端源不是所有数据类型都支持最常见的做法是把它当成“只读大报表”来用。3.3 Instant Feedback Mode前端内存里的虚拟化方案如果数据已经全部加载在内存List里又想要大列表滚动流畅Instant Feedback Mode是更轻量的选择。本质上它是虚拟模式的一种只绘制当前可见区域的数据行并且带了行缓存。gridControl1.DataSource bigList; // 几十万条内存对象 viewLarge.OptionsBehavior.Editable false; viewLarge.OptionsView.EnableDeferredClipping true; viewLarge.RowAutoHeight false;实测下来六七十万行的内存列表滚动和筛选都还能接受。但要注意这个模式下自动行高是不可用的因为要精确计算每一行的像素高度虚拟模式撑不住那种不确定性。EnableDeferredClipping能改善滚动时的白屏感尤其公司配的办公电脑性能一般时效果明显。3.4 选择策略与我实测的参考数据用一张表把两个模式的区别说清楚维度Server ModeInstant Feedback Mode数据规模定位百万级起步配合后端分页内存中几十万行以内更合适数据源要求数据源要能处理排序、过滤、分页请求通常绑定内存集合即可排序与过滤操作推给后端界面等待相对短前端内存中执行数据大时仍有等待编辑能力受数据源限制通常偏只读可以编辑但需谨慎处理行状态最合适的场景对接数据库、WCF、WebAPI大表列表数据已全部装载的单机系统如果数据在本地内存里几十万行以内我更推荐Instant Feedback Mode配置省事。如果数据要跨服务Server Mode是更长的正道。这中间没有绝对好坏只有合不合适。4. 未绑定列与表达式计算列数据源里不存在的数据4.1 典型场景序号、状态文本和跨字段运算业务界面上经常有一些数据源里并不存在的列。比如行号比如“已付款/待付款”这种由金额和状态组合出来的展示文本又比如“金额 单价 × 数量”这类计算列。把它们写进业务实体或者数据库表里反而别扭因为它们不是真实业务字段只是界面展示逻辑。Data Grid对这种需求支持得很到位有两种常见实现方式表达式计算列和CustomUnboundData事件。4.2 表达式列最省事优先使用如果你要计算的字段或逻辑可以用一种类Excel的表达式描述那就优先使用表达式列。不需要写事件绑定之后算出来的值直接显示在界面上。GridColumn colTotal view.Columns.AddField(LineTotal); colTotal.UnboundType DevExpress.Data.UnboundColumnType.Decimal; colTotal.UnboundExpression [UnitPrice] * [Quantity]; colTotal.Visible true;字段名包含空格或特殊字符时一定要用中括号括起来比如[Unit Price]。表达式里的大小写也要注意它有自己的解析规则写错时多数不报错只是不出值。表达式列的另一个好处是可以参与排序、筛选和汇总。我做过一个报表界面最后一列是“总金额”用户在GridView的筛选器里能直接按金额过滤用户体验很顺。4.3 CustomUnboundData事件适合复杂的计算逻辑当计算逻辑不是简单表达式比如要查另一张表来拼状态文字就要用CustomUnboundData事件。把列的UnboundType设为对应类型然后在事件里给值。private void viewAudit_CustomUnboundData(object sender, CustomColumnDataEventArgs e) { if (e.Column.FieldName ! AuditStatus) return; if (e.IsGetData) { Order row viewAudit.GetRow(e.ListSourceRowIndex) as Order; if (row null) return; if (row.IsApproved) e.Value 已审核; else if (row.IsSubmitted) e.Value 已提交; else e.Value 草稿; } }需要注意的是ListSourceRowIndex并不是哪个场景下都等于界面上看到的行号。分组、过滤时先用GetRow(e.ListSourceRowIndex)拿业务对象再判断字段这样才不容易出错。性能方面有一个我反复强调的细节如果数据量大事件里每一行都要执行方法而表达式列是由引擎层优化计算的所以能用表达式就不要用事件。事件列更适合那种“列数少、计算重但不频繁”的场景。4.4 未绑定列最常见的返工点未绑定列默认是可以编辑的但编辑内容不会自动写回数据源。很多初用者在这一步对不齐界面上改了金额刷新一下又变回去了。我的处理方式是要么把计算列的OptionsColumn.AllowEdit设为false要么在编辑事件里同时把结果写回真实数据源字段保证两边口径一致。还有一个导出相关的坑计算列导出到Excel时表达式列通常很正常事件列有时导出为空白。在给客户交付导出功能前一定先测一遍“未绑定列在导出文件里有没有值”。5. 绑定模式下的行编辑校验让错误停在提交之前5.1 三个校验层级看清网格的通知顺序数据绑定的网格不是摆设用户会在界面上直接改数据。校验逻辑至少分三个层级单元格级、行级、业务提交级。网格负责前两个层级第三个层级通常在保存按钮的业务代码里做。单元格级校验最常用的是ValidatingEditor事件当某个单元格结束编辑时触发。行级校验用RowValidating事件适合判断“开始日期晚于结束日期”这类跨字段逻辑。viewOrders.ValidatingEditor (s, e) { if (viewOrders.FocusedColumn.FieldName Quantity) { if (e.Value null || Convert.ToInt32(e.Value) 0) e.Valid false; } }; viewOrders.RowValidating (s, e) { Order o viewOrders.GetRow(e.RowHandle) as Order; if (o ! null o.EndDate o.StartDate) e.Valid false; };当校验失败时界面会触发InvalidValueException。默认弹出一个错误对话框也可以在这里自定义错误展示方式。viewOrders.InvalidValueException (s, e) { e.ExceptionMode DevExpress.XtraEditors.Controls.ExceptionMode.NoAction; // 用自己统一的提示组件展示 e.ErrorText };5.2 错误提示的呈现与定位校验失败的默认表现是行首出现错误图标鼠标悬停能看到错误文本。如果你要做更明显的提示可以调用SetRowError手动给指定行设置错误信息。这样可以在保存前把所有错误行挨个标记出来用户一眼就能看到问题在哪。需要注意的是单元格校验失败并不代表整个行保存失败。用户改完A字段B字段还是错的保存时你仍然需要自行遍历一遍全部行把有错误的行标红或定位到第一个错误行。5.3 保存前判断数据是否真的被改过网格里的编辑状态是“延迟提交”的。用户可能已经输入了新内容但焦点还没离开编辑器此时你直接读实体对象读到的还是旧值。所以任何“保存”按钮的逻辑第一件事应该是强制提交当前编辑状态gridView1.PostEditor(); gridView1.UpdateCurrentRow(); if (dataSet.HasChanges()) { // 这里再走业务保存逻辑 }PostEditor()把当前编辑器里的值提交到网格UpdateCurrentRow()进一步把网格当前行的值写回数据源对象。这两个方法连用是我在多次“保存后丢最后一笔字符”的故障里总结出来的固定动作。如果你的数据源是DataTableHasChanges()可以帮你在保存前判断是否有脏数据避免无谓的数据库交互。6. 现场排查绑定问题我的排错路线和自检清单6.1 数据源明明有内容界面却一片空白这个问题的排错路线我建议按顺序查三处先看gridControl.DataSource指向的对象是否还是同一个实例。很多人启动时绑了一个DataTable后面又执行dtOrders new DataTable()重新赋值网格还在绑旧引用新数据进不来。调试时在断点里看gridControl.DataSource dtOrders是否为true。再看View上有没有残留的过滤器。GridView有ActiveFilterString、RowFilter、ShowFilterPopup等机制有时候开发环境调试完没清掉过滤条件发布到客户那边就是“数据源有值界面全空”。右键视图选择“清除全部过滤”如果数据立刻出现就说明是Filter状态问题。最后查Columns是否被手动冻结或者隐藏了。GridView的列可见性默认由数据源决定但一旦你手动设置过Visiblefalse字段存在也不会显示。6.2 行不能编辑、单元格灰掉单元格不可编辑的原因非常多最常见的几个开关是这样的gridControl.Enabled或gridView.OptionsBehavior.Editable被设成false整张表只读。列对象的OptionsColumn.AllowEdit被设成false只影响该列。数据源本身是只读视图比如DataView设置了AllowEditfalse。该列是未绑定列而你没有给网格设置正确的编辑器。我调试时习惯先看gridView.OptionsBehavior.Editable和当前列的AllowEdit这两个属性再把数据源换成BindingSource包一层很多时候问题就消失了。原因不是BindingSource神奇而是它让数据源的读写状态更容易被看清。6.3 列顺序和列宽突然不对别急着怀疑绑定有些部署环境里Grid会在用户退出时自动保存布局下次启动再恢复。如果你的数据库字段改了旧布局文件里还记录着老列状态就会出现“代码里明明是A列运行时就是看不到或顺序乱”。排查方法很简单右键GridView选择“恢复默认布局”或者不加载RestoreLayoutFromXml/Registry。如果恢复后列顺序正常十有八九是布局持久化问题不用动绑定代码。我个人的习惯是在正式环境里只保存用户自定义的列宽、列顺序不保存字段配置。数据源结构变化时就不会被旧布局拖后腿。最后再分享一个小技巧我平时会把绑定链路自检封装成一个方法在界面加载后调用取DataSource类型、取行数、取第一条记录、取当前可用列数量全部打印到调试窗口。遇到说不清的问题先把这四个数字打出来定位思路会清楚很多。
返回列表