XAF框架GridView定制化实战:从列控制到报表改造的完整指南
做XAF开发的朋友基本都有同感刚接触eXpressApp Framework时列表页面开箱即用很香但一旦业务要求上来比如“这个列表要给财务加一列合计”“那个状态要在行首显示成徽章样式”“某些用户不能看某些列”默认的GridView就变得非常“笨拙”。这篇内容我把这几年在XAF GridView定制化上踩过的坑、沉淀下来的套路和可以直接抄走的写法整理一遍覆盖列字段控制、行为逻辑调整、布局持久化以及把列表改造成轻量报表这几个高频场景适合正在做XAF项目二次开发、被列表需求缠住的开发者参考。1. 定制GridView的第一层搞清它和ListView的关系别再乱改控件XAF里的GridView不是孤立的控件它是XAF数据模型和实际UI控件之间的表现层。很多人一上来就想直接找ASPxGridViewWeb端或GridControlWinForms端的属性去改结果改了没反应或者被XAF的机制覆盖。核心原因是没有搞清楚ListView、Editor和真正的GridView三者之间的层级关系。1.1 ListView只是“虚拟视图”GridView才是真正的渲染控件在XAF里ListView是XAF层面上的列表视图它承载的是业务数据的集合定义、列集合定义、排序过滤规则等“元信息”。真正的可视化控件是你项目里引用平台对应的Editor比如Web端是ASPxGridViewListEditorWinForms端是GridListEditor。这两者在运行时生成实际的GridView控件。所以我定制化的第一原则能通过XAF的ListView层做的修改绝不直接动GridView层只有XAF没有提供对应能力时才去操作GridView本身。这个原则能帮你避免大量的版本兼容性灾难——因为XAF的API在不同版本里变化不大但底层控件比如DevExpress 18.x和23.x的GridViewAPI差异是很大的。1.2 拿到GridView的入口View.ControlsCreated与Editor属性无论Web端还是WinForms端标准做法是在ViewController或普通ViewController里监听View的ControlsCreated事件然后通过View.Editor拿到Editor再根据平台类型转换成对应的Editor类型最终从Editor的GridView属性取出真实的表格控件。拿Web端举例public class InvoiceListViewController : ViewControllerListView { protected override void OnActivated() { base.OnActivated(); ControlsCreated OnControlsCreated; } private void OnControlsCreated(object sender, EventArgs e) { if (View.Editor is ASPxGridViewListEditor editor) { ASPxGridView grid editor.GridView; // 到这里你已经拿到真正的ASPxGridView了 grid.CustomizeDataRowTemplate OnCustomizeTemplate; grid.ClientSideEvents.Init onGridInit; } } }这段代码里有几个容易出错的点。第一View.Editor在ControlsCreated之前可能是null所以必须挂在事件里处理。第二从Editor拿GridView之后建议立刻做一次功能开关的配置而不是零零散散到处设置方便后续维护。第三如果是WinForms项目对应的Editor类型是GridListEditor拿到的控件是GridControl后续的API逻辑完全不同不要试图共用同一套代码。1.3 常见误操作在View.Shown里设置控件属性会时常失效我见过不少同行把GridView的属性设置放在了View.Shown事件或自定义View的构造函数里结果发现时灵时不灵。因为XAF的列表视图有自己的生命周期控件创建ControlsCreated→ 数据绑定SelectionChanged、DataSourceChanged→ 显示ViewShown。GridView的很多布局属性如列宽、分组、排序在数据绑定之后会被XAF重新应用一遍尤其是列表的ModelModel.Columns会强制覆盖GridView的列设置。所以如果你只设置一次布局属性很可能被后续的模型应用阶段冲掉。我的兜底方案是监听View.SelectionChanged或DataSourceChanged做二次应用。或者更简单粗暴一点——每次ControlsCreated之后用BeginUpdate包裹住属性设置强制在模型应用之后再刷一次。2. 列级定制从哪里拿到列信息怎么改字段名、列宽和显示顺序GridView定制里最日常、最高频的就是列定制。需要显示哪些列、列的顺序、列的Caption、列宽、字段的输出格式都属于列级操作。XAF在Model层提供了Columns节点运行时的ViewController里也有对应的操作入口掌握这一层90%的列需求都能解决。2.1 使用Model的Columns节点做静态配置——推荐优先在XAF的Model Editor里找到对应ListView节点展开Columns子节点每个字段对应一个Column。在这个界面上可以很直观地设置Index列的显示顺序数值小的靠左。Caption列的标题文字如果不设置默认用字段本身的Caption。Width列的宽度单位是像素Web端还可以设置为百分比模式。SortOrder列表第一次加载时的默认排序配合SortIndex使用。GroupIndex分组字段把列表变成分组视图。这里有个推荐习惯同一个列表视图由于XAF里的Model是多层继承的UserModel、ApplicationModel、ModuleModel等在Module项目里设置Model通常是最好的位置。因为ModuleModel是模块级的所有引用该Module的应用程序都会生效而ApplicationModel只对当前应用生效。如果你的项目是多个应用复用同一个模块建议把常规列表的列配置放在Module的Model里个性化需求才放Application层。2.2 ViewController里动态改列按用户、按权限调整列集合静态配置能解决80%的列需求但还有一类需求动态性较强比如普通用户登录时隐藏“成本价”列财务角色登录时显示成本价和利润列或者在某个操作完成后列表要自动追加一列“最近处理人”。这类需求放Model里也能做但设计上比较绕我一般是直接在ViewController里针对角色或业务状态动态调整View.Columns。protected override void OnActivated() { base.OnActivated(); View.Columns.CollectionChanged OnColumnsCollectionChanged; var currentUser SecuritySystem.CurrentUserName; if (IsInRole(currentUser, Finance)) { View.Columns[CostPrice].Visible true; View.Columns[Profit].Visible true; } else { View.Columns[CostPrice].Visible false; View.Columns[Profit].Visible false; } }有一点很重要以这种方式调整列时优先使用View.Columns的属性不要直接操作GridView的Columns集合。View.Columns是XAF的列模型集合操作它之后XAF会同步到底层GridView直接操作GridView的列虽然一时生效但列模型的刷新会取消你的改动或者导致“明明设置了隐藏刷新后又冒出来”的问题。2.3 格式化显示日期、金额、百分比一个属性搞定列显示格式的定制Static模式用Model的DisplayFormat属性即可动态模式则用GridView列本身的Propertiesgrid.Columns[InvoiceDate].PropertiesEdit.DisplayFormatString yyyy-MM-dd; grid.Columns[TotalAmount].PropertiesEdit.DisplayFormatString c2; grid.Columns[TaxRate].PropertiesEdit.DisplayFormatString p0;注意GridView列的PropertiesEdit.DisplayFormatString和XAF Model里的DisplayFormat并不是同一个东西。如果你既在Model里设置了格式又在代码里设置格式代码优先但模型一旦发生重建代码设置值可能会丢。所以我的习惯是格式化显示尽量放Model里代码只处理那些无法提前确定的动态格式。2.4 一个小坑Captions和ToolTips的国际化多语言项目里列的Caption默认拿的是业务对象的字段Caption这个Caption往往在模型里做了翻译。但如果你直接在代码里给GridColumn.Caption 金额赋值那么切换语言后Caption不会跟着变。推荐的做法是自定义一个扩展方法通过XAF的本地化服务取翻译文本public static void SetCaptionFromResource(this GridColumn column, string resourceKey) { var displayName Localizer.GetString(resourceKey); if (!string.IsNullOrEmpty(displayName)) { column.Caption displayName; } }类似的还有ToolTip表格列头和行内容的悬浮提示很多业务系统要求鼠标悬停能看到字段说明、数据口径等这类信息直接放在业务对象字段的Description属性里然后统一绑定到GridView列的ToolTip维护成本很低。3. 行级行为定制用GridView事件处理双击、行选中和自定义操作按钮列定制只是视觉层面的调整真正让GridView“活起来”的是行为层的定制。比如双击一行打开详情、点击行首图标触发业务操作、行状态变色等。这些需求几乎都要用到GridView的行事件而XAF官方文档覆盖不全需要自己摸索。3.1 双击行打开详情视图——比默认跳转更灵活的做法XAF的ListView默认支持双击行跳转到DetailView但跳转逻辑是固定的直接打开当前记录的默认DetailView。如果想控制跳转到的视图、先做权限校验、或者带上参数则需要接管双击事件。Web端示例grid.RowDblClick (s, e) { var rowHandle grid.FindVisibleIndexByValue(e.VisibleIndex); var obj grid.GetRow(rowHandle) as Invoice; if (obj ! null obj.Status InvoiceStatus.Draft) { // 草稿单打开可编辑视图 var os Application.CreateObjectSpace(typeof(Invoice)); var detailView Application.CreateDetailView(os, obj, true); Application.ShowViewStrategy.ShowInNewWindow(detailView, true); } else { e.Handled true; // 阻止默认跳转 } };这里有个关键点XAF的Application.CreateDetailView创建的视图不会自动管理对象空间的生命周期如果是简单展示无所谓如果要编辑最好显式创建一个新的ObjectSpace来承载这个详情视图避免在当前列表的ObjectSpace里产生脏数据。WinForms端的做法很类似只不过事件名是DoubleClick行索引的获取方式也略有不同核心思路不变。3.2 行选中事件在Controller里监听SelectionChanged更安全很多人习惯直接挂GridView的SelectionChanged事件来感知当前选中行但XAF的ListView本身已经封装了当前选中对象的概念你在ViewController里直接重写OnSelectionChanged方法才是最稳妥的protected override void OnSelectionChanged() { base.OnSelectionChanged(); if (View.SelectedObjects.Count 1) { currentInvoice View.CurrentObject as Invoice; UpdateActionState(); } }这样做的好处是你的逻辑不受GridView平台差异的影响Web端和WinForms端共用一套Controller代码。再配合Action的SelectionDependencyType属性比如设为RequireSingleObject可以让按钮自动在无选中时可点击状态被禁用非常省事。3.3 自定义操作按钮挂在Action容器里挂在GridView里行内多行列表常见的需求是在每一行加一个“作废”按钮或“审批”按钮。XAF里做这个需求有几个层次简单做法加一个PopupWindowShowAction或SimpleAction设置TargetViewType ListView工具栏上就会多出按钮选中行后点击执行。进阶做法把按钮直接嵌入行内即每行末尾显示“作废”“查看日志”等小按钮。行内按钮的实现在Web端和WinForms端差异最大。Web端可以用GridView.CustomizeDataRowTemplate往模板里注入控件WinForms端则通常使用RepositoryItemButtonEdit放在一列里通过ButtonClick事件触发。private void OnCustomizeTemplate(object sender, ASPxGridViewCustomizeDataRowTemplateEventArgs e) { var button new ASPxButton(); button.Text 作废; button.ClientSideEvents.Click $function (s, e) {{ onVoidRow({e.KeyValue}); }}; e.Template new CustomRowTemplate(button); }这种做法的好处是视觉上直观缺点是每行都渲染一个按钮控件数据量大的时候性能会下降。如果你只是做少量管理操作问题不大如果列表动辄上万行建议改用“目标行高亮右键菜单”或者“选中后点击工具栏按钮”的方案性能和体验会更好。4. 把GridView变成轻量报表汇总行、格式化合计、分页策略XAF的GridView不是只有“展示明细列表”这一个用途。我经常在项目里直接把某个明细列表页面改造成“查询报表页”要有合计行、要有分组汇总、要能自定义显示精度、还要保持分页查询的性能。这部分是GridView定制里最有“含金量”的部分因为涉及到的不只是XAF还有底层控件对数据源的处理方式。4.1 开启汇总行ShowTotalSummary与自定义合计逻辑GridView自带汇总能力开启ShowFooter之后可以给每个字段设置SummaryItemgrid.TotalSummary.Clear(); grid.TotalSummary.Add(new ASPxSummaryItem(TotalAmount, DevExpress.Data.SummaryItemType.Sum) { DisplayFormat 合计: {0:c2}, Tag TotalAmountSum }); grid.TotalSummary.Add(new ASPxSummaryItem(Tax, DevExpress.Data.SummaryItemType.Sum) { DisplayFormat 税: {0:c2}, Tag TaxSum });需要注意在XAF里GridView的默认数据源是XAF的BindingSource或ServerModeDataSource。如果你直接用ASPxSummaryItem只要数据源属性名正确加总逻辑会直接在数据源层面执行不需要遍历所有行——这个设计对大数据量很友好。但是有个非常容易踩的坑如果你改了GridView的列字段名例如在Model里把字段映射成其他名称ASPxSummaryItem.FieldName填的不是列名而是实体字段名。比如列Caption改成了“金额”但FieldName必须写“TotalAmount”否则汇总结果一直是0或者直接报字段未找到。4.2 使用View的Criteria与ListView的Filter实现“报表参数”既然要做报表一般都需要接收查询条件。很多人单独建一个查询视图然后传参数进来。其实直接在GridView列表上做过滤条件也很成熟XAF的ListView本身支持Criteria属性这个Criteria可以是代码设置的也可以是用户点击快速过滤按钮动态拼出来的。在ViewController里可以监听过滤按钮的点击并通过View.CollectionSource.Criteria[]增加自定义过滤条件var collectionSource View.CollectionSource; collectionSource.Criteria[MyReportFilter] CriteriaOperator.Parse( Status ? AND CreateDate ? AND TotalAmount ?, InvoiceStatus.Approved, dateFrom, minAmount );这个做法的优点是可以实现“列表即报表”用户在当前列表页面上筛选数据汇总行跟着变化而不用跳转页面重新加载。由于Criteria是XAF原生的过滤机制所有数据源的权限过滤规则比如按用户数据范围过滤记录依然生效——这一点是直接操作GridView的FilterRow完全做不到的。4.3 分页策略对汇总的影响汇总行到底汇总了多少数据很多人在列表上加了汇总行发现汇总的数据量对不上比如当前页有20行但汇总出来的金额是所有满足条件记录的总金额。这个其实是GridView本身的属性决定的。Web端的ASPxGridView默认汇总行为是在数据源是ServerModeDataSource时汇总行往往是对全部记录做统计如果是普通List数据源则是对当前数据源实例有时数组在内存中做统计。XAF在默认配置下可能走的是服务器模式Server Mode因此你的汇总行统计的是全部数据不是当前页。这一点在业务上可能正是你想要的但如果你只想统计当前页则需要在数据源层面做一次分页处理后再把当前页的数据源绑定给GridView。WinForms的GridControl也有类似情况默认汇总针对的是当前数据源的所有行。所以明确你的需求是“全量汇总”还是“当前页汇总”再决定是否要对数据源做预处理不要等到上线了才发现统计口径不对。4.4 格式化合计文本和前缀文本的实际写法汇总值的显示格式除了普通的{0:c2}还经常需要在合计文本前面加业务前缀比如“本期开票合计”。这个字符串里面混了中英文对齐问题建议写成这样的方式DisplayFormat 金额合计: {0:#,##0.00} 元如果你想让汇总行更醒目可以在FooterCellStyle里设置背景色和字体grid.TotalSummary.Add(new ASPxSummaryItem(...) { DisplayFormat ..., Aggregate ... }); grid.Styles.FooterCell.BackColor System.Drawing.Color.FromArgb(245, 245, 245); grid.Styles.FooterCell.Font.Bold true;注意不要直接给TotalSummary里设置HTML编码标签因为ASPxGridView的汇总文本默认不是HTML渲染的写了b标签会被转义成普通字符串显示。如果确实要富文本效果需要先开启RenderMode Template或者借助FooterCellTemplate而不是画蛇添足。5. 布局与状态的持久化让用户调整好的列宽、顺序不丢失GridView定制里最容易“顺手做坏”的一件事是布局持久化。用户调整了列宽、列顺序、列隐藏显示、分组方式结果一刷新又变回默认了。这类问题虽然不是功能缺陷但被用户反复抱怨的杀伤力极大。XAF其实默认提供了一套布局持久化机制但需要显式打开并配置正确的存储方式。5.1 XAF自带的布局持久化机制与开启方法在XAF里针对ListView的布局持久化会保存到Model的UserModel层。用户在界面上调整GridView布局后以XML或者二进制的形式保存到当前用户的模型差异Model Difference中。Web端和WinForms端都有对应的设置WinForms在Model Editor里找到ListView对应的节点设置GridView.SaveLayout属性为Xml或者Registry并指定SaveLayoutToXmlFilePath。Web端ASPxGridView本身有SettingsLoadingPanel、ClientSideEvents.EndCallback配合SaveClientLayout属性。我的建议是优先使用XAF原生的Model Difference持久化而不是自己用GridView.SaveLayoutToXml单独存一份XML文件。因为XAF原生的持久化方案会和用户的权限模型、角色差异耦合在一起而自建XML文件虽然实现简单但在多终端上部署会很麻烦——用户在这台机器上设定好的布局换台电脑就丢了。XAF原生的持久化代码如下在ViewController里设置protected override void OnActivated() { base.OnActivated(); ControlsCreated (s, e) { if (View.Editor is GridListEditor gridEditor) { gridEditor.GridView.OptionsLayout.Columns.StoreAllOptions true; gridEditor.GridView.OptionsLayout.Columns.AddNewColumns false; gridEditor.GridView.OptionsLayout.Columns.RemoveOldColumns false; } }; }5.2 权限相关的布局限制某些列不能让用户永久隐藏布局持久化一旦开启用户就可以随意调整列显示隐藏。但在企业系统里有些列是老板要求“必须显示”的比如“合同编号”“审批状态”。如果用户把它们隐藏了之后每次打开列表都看不到老板就会认为是系统坏了。针对这类需要我实践下来最可靠的做法是在View.Columns.CollectionChanged事件里监听列的Visible变化如果发现被改成不可见的列属于锁定列集合立刻改回可见private void OnColumnsCollectionChanged(object sender, CollectionChangeEventArgs e) { if (e.Action CollectionChangeAction.Remove || e.Action CollectionChangeAction.Refresh) return; var column View.Columns[e.NewIndex]; if (lockedColumns.Contains(column.PropertyName) !column.Visible) { column.Visible true; } }这种方法会有一瞬间的闪烁用户看到列刚消失又被拉回来但在“必须显示”这类强约束下这个闪动反而能给用户清晰的反馈——这列你是动不了的。比起静默强制这种可视化反馈更体验友好。5.3 跨会话重置布局的例外场景用户折腾出来的布局一团糟需要“一键恢复默认布局”。实现这个功能的路径是清除当前用户的Model Difference中对应ListView的布局差异节点。在XAF里可以通过Model节点操作实现var listViewModel View.Model; listViewModel.ResetValues(true); // 重置默认值不过这个重置的粒度比较大可能会把View上其它非布局的设置也一起重置。更精细的版本是通过XAF的DiffStore来删除对应节点的差异var diff (IModelApplication)Application.Model; var layer diff.DifferenceStore; // 定位ListView节点后调用RemoveNode我自己在项目里的做法是给“恢复默认布局”单独做一个Action并把它固定在界面显眼位置触发时只重置当前列表View的列集合和布局相关属性然后再调用View.Refresh()刷新界面。虽然实现起来比直接掉API多几十行代码但这个功能在业务用户那里的满意度非常高。6. 实战总结与几个高频问题的排查方法最后分享一些我在GridView定制时积累的排查经验。XAF的GridView问题大部分不是逻辑写错了而是层级关系没理清、生命周期时机没搞对、不同平台API差异导致的表现不一致。遇到问题建议按这五步排查。6.1 事件注册的时机问题为什么我的GridView事件没有触发先检查代码是否在正确的时机注册。比如RowDblClick、CustomizeDataRowTemplate这类控件级事件必须在GridView初始化之后注册而XAF的机制是先创建ListView的Model再创建Editor最后才创建GridView控件。推荐统一使用的生命周期顺序OnActivated注册Model层事件View.Columns.CollectionChanged。ControlsCreated注册GridView控件级事件。DataSourceChanged处理数据绑定之后的逻辑。如果你已经很确定注册时机没问题那问题多半出在事件被覆盖了——比如GridView在XAF内部被重新创建了一次你的控件引用是旧对象。碰到这种情况建议在ControlsCreated里重新获取Editor.GridView的最新实例而不是把旧实例存在一个字段里长期使用。6.2 字段名找不到、属性异常先检查业务对象的Model很多GridView列异常比如某些列找不到字段名、汇总结果一直是null、编辑器无法弹出本质原因是业务对象的XAF Model里字段定义有问题。比如Nullable类型字段没有设置Type、枚举字段没有设置Converter、关联对象没有配置Aggregated这些都会导致列在运行时拿不到正确的元数据。排查方式很简单在Application调试窗口打开Model Editor查看对应ListView.Columns节点的字段定义是否完整字段的类型映射是否正确。不要盯着ASPxGridView的报错信息去猜XAF的报错有时候指向的是底层控件真正的问题在Model层。6.3 大数据量列表GridView直接卡死怎么办列表数据量超过两三万行时XAF默认的列表配置会让GridView的加载和渲染变得非常吃力尤其是带自定义模板按钮的列表。这类问题有三个递进解决方案在ListView的Model里开启ServerMode让数据源延迟加载。XAF在Web端开启后ASPxGridView能利用服务端回调做数据分页避免一次性把所有记录都拉到客户端。关闭GridView的SettingsContextMenu和Settings.ShowFilterRow减少页面渲染的计算量。如果行内按钮导致行模板开销太大改成Action容器的按钮或者把行操作折叠进“右键菜单”。记住不要为了给每一行加一个图形按钮牺牲掉整张列表的加载性能。这类需求的用户往往更在意列表打开的速度而不是行内按钮的便利性。6.4 不同角色看到的列不一样不用复制视图用View的Criteria和Model Layer很多人在做“不同角色不同列表”时第一反应是创建多个ListView分别配置不同的列。这个方法能用但运维成本很高几乎每加一个字段就要同步到所有视图里。更合理的方案是保持一个ListView在运行时根据角色的不同动态修改View.Columns的Visible属性还需要权限隔离的字段配合XAF的PermissionPolicy规则统一控制。这样业务对象加一个字段你只需要改一处Model默认配置角色间的差异逻辑全部收敛在ViewController里。6.5 移植Web端和WinForms的差异提醒同一个XAF模块如果同时发布Web端和WinForms端GridView定制代码里最容易出问题的就是直接操作了平台相关类型。我在项目里约定了一套平台抽象接口例如定义IRowActionCell接口在Web端和WinForms端分别实现“行内按钮的输出逻辑”Controller层只和接口交互。这样虽然刚开始要多写一些适配代码但换来的是后续新增平台如果哪天要出移动端时Controller层代码完全不用动。6.6 我踩过一个印象最深的坑汇总数据重复计算有一次在财务列表中加“金额合计”用户在界面上点了几次排序结果合计金额翻倍了。排查到最后发现问题不是GridView本身而是页面用了XAF的RefreshAction刷新当前视图刷新时GridView重新绑定了数据源而我在数据源绑定前又往实体集合里插入了一条临时“合计行”导致合计行参与进了GridView的汇总计算。从那以后我给自己定了一条规则不要为了显示汇总行而伪造实体数据行汇总需求一律使用GridView的汇总功能或者单独建立一个VO对象模型展示统计数据。这条规则后来避免了很多类似事故。6.7 最后一个实用技巧自定义行的行背景色列表行还需要根据业务状态标色例如“已逾期”标红、“待审”标黄。这个需求使用ASPxGridView的HtmlRowCreated事件判断当前行的数据对象后设置行背景色grid.HtmlRowCreated (s, e) { if (e.RowType ! GridViewRowType.Data) return; var record grid.GetRow(e.VisibleIndex) as Invoice; if (record ! null record.Status InvoiceStatus.Overdue) { e.Row.BackColor System.Drawing.Color.FromArgb(255, 235, 235); } };这段逻辑里最关键的一点是判断e.RowType只对数据行上色避免对标题行、合计行、空行误操作不然页面一加载就会因为空行数据判空而报错。GridView的定制化是个越做越深的领域很多问题不是XAF本身难而是DevExpress底层控件和XAF的抽象层叠加之后排查链路变长。所以我一直建议团队里写XAF的同事先理清模型、视图、控制器之间的分工再动代码。该放在Model里的别用代码硬写该在Controller里动态调整的也别在Model里堆一大堆条件。这样后续接手的人就不用靠猜来维护你的GridView定制逻辑了。