ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WPF DataGrid颜色串行之谜:从隐式样式到虚拟化复用的排查方案

WPF DataGrid颜色串行之谜:从隐式样式到虚拟化复用的排查方案 搞WPF的DataGrid我想大家都遇到过这种诡异的局面明明只在某一行的某个单元格里设置了一种颜色运行起来后却像传染了一样别的行、别的列甚至整张表格的颜色都跟着变或者表格往下滚动几屏再滚回来一些行突然继承了跟它数据毫不相干的背景色。团队群里管这个叫颜色串行排查起来往往一头雾水——样式代码看起来每个都没问题合在一起就跑偏。这篇文章专门把DataGrid里最容易引发串行的四类根因拆开讲透包括WPF的隐式样式作用机制、虚拟化容器复用、Style资源的共享与合并规则、DataTrigger里列判断的隐性错位最后给出我自己项目里实际在用的排查链路和一套数据驱动的着色方案。适合用WPF做数据密集型表格、被单元格样式和行样式折腾过的开发者不管你是刚接手别人代码还是已经在写自定义样式应该都能从中找到对应的坑。1. 先还原现场这块表格到底是怎么串起来的在动手排查之前我建议大家先把串行的表现形式精确描述出来。因为不同现象背后的根因完全不同如果不先定位成哪一种串很容易在一个错误的方向上浪费时间。根据我这些年收到的反馈和自己在项目里的经历DataGrid的颜色串行基本逃不出以下三种形态。第一种形态是全表变色。你只是在App.xaml或者某个Window的Resources里加了一个TextBlock的隐式样式然后整张DataGrid的文本颜色全都变了连那些你认为没有设置任何样式的列也跟着变。这种串行最难察觉因为问题根本不在DataGrid相关代码里而在一个看似不相干的全局样式上。第二种形态是滚动残留。表格本身有虚拟化行数是几万条可视区只有十几行。你往下滚动新出现在屏幕上的行带着之前某一行留下的背景色出现了颜色跟当前行的数据状态完全对不上。这种串行在事件驱动着色的代码里特别常见。第三种形态是列定位错位。你写了一个DataTrigger打算给第2列或者列头为金额的列设置特殊背景结果用户拖动列顺序或者某一列被折叠隐藏后颜色跑到了另外一列上。这种串行跟列的显示位置强相关通常只在运行时才暴露。我建议把这三个现象先对照一下串行表现第一时间怀疑的对象真正的根因方向全表文本或背景被统一改掉DataGrid自身的CellStyle全局隐式样式顺着资源查找链作用到了单元格内部控件滚动后旧颜色出现在新数据行上数据绑定或触发逻辑行容器被虚拟化回收复用旧状态没被重置拖动/隐藏列后颜色错位样式里的列下标判断DataTrigger依赖了DisplayIndex或Header等不稳定信息这里有个经验只要你在排查中反复出现我明明没改这里它怎么变了的感觉先优先检查第一类和第三类只要出现滚一下颜色就乱了的感觉优先检查第二类。下面我把每种根因的机制和相关代码完整写出来。2. 根因一隐式样式顺着可视化树滴灌导致整表变色WPF的依赖属性查找有个特点找资源时元素会从自己的Resources开始一层一层向上找直到Application.Resources甚至系统主题资源。而一个Style如果没写x:Key只写了TargetType它就成了隐式样式会在整棵可视树范围内自动作用于所有匹配该类型的元素。问题就出在这。DataGridTextColumn生成的单元格内容本质是一个TextBlock。假定你在App.xaml里写了下面这段初衷可能只是想统一全局文本的默认颜色Application.Resources Style TargetTypeTextBlock Setter PropertyForeground Value#FF337733 / /Style /Application.Resources这时候DataGrid里所有没有显式指定Style的TextBlock都会变成绿色。注意我说的是没有显式指定Style。如果你给某些列写了ElementStyle而这些ElementStyle的TargetType同样是TextBlock那么隐式样式就会被显式样式顶掉导致同一个表格里一部分列文本保持原色一部分列文本变成了全局绿色看起来就像颜色在列之间串了。为什么会这样依赖属性优先级的规则是本地值优先于Style Setter显式Style优先于隐式Style。当元素通过Style属性拿到一个显式样式后隐式样式就不再参与计算了。你要明白这个逻辑ElementStyle里哪怕只写了一个FontWeight它也是一个完整的Style实例会整体替代隐式样式。于是Foreground回到默认值黑色而不是全局样式里的绿色。这种同一张表中列颜色不统一的现象排查的时候很容易怀疑是DataGrid的样式被谁改乱了实际上就是全局隐式样式被局部显式样式局部屏蔽造成的。解决这个问题的推荐做法要么全局样式加x:Key改为按需引用要么在DataGrid资源里把隐式样式盖回来。加x:Key是最稳妥的把全局TextBlock样式变成显式命名资源Application.Resources Style x:KeyGlobalTextStyle TargetTypeTextBlock Setter PropertyForeground Value#FF337733 / /Style /Application.Resources这样它不会再自动作用到任何TextBlock上。想统一某个区域的文本颜色就在那个Window或控件上显式引用StackPanel StackPanel.Resources Style TargetTypeTextBlock BasedOn{StaticResource GlobalTextStyle} / /StackPanel.Resources /StackPanel如果你必须保留全局隐式样式同时要求DataGrid单元格不继承那就在DataGridTextColumn的ElementStyle里把Foreground显式设置为你需要的颜色。这个方案能覆盖掉隐式样式但要注意它只解决显式覆盖隐性的问题对于全局样式和局部样式混用造成的不可预期还是建议清理资源层级。另一个类似的问题是全局隐式DataGridCell样式。有人会在Application.Resources里定义TargetTypeDataGridCell的样式这会让所有DataGrid的Cell都套用同一套Setter和Trigger。如果你只希望某个页面里的表格有特定背景就一定要给这个页面单独放一份隐式样式覆盖或者干脆不用全局DataGridCell样式。我的习惯是DataGridCell级别的东西永远放在DataGrid.CellStyle里不做全局隐式样式。隐式样式能带来便利但在DataGrid这种控件复用程度极高的场景里它带来的排查成本远大于收益。3. 根因二虚拟化容器回收让旧颜色寄生到新行上这才是真正让很多人头疼的串行。DataGrid默认开启UI虚拟化行容器DataGridRow并不会为每一行数据创建而是只生成可视区域内需要的少量容器。当用户滚动时离开可视区的行容器不会销毁而是放进一个回收池随后分配给新进入可视区的数据行。这个机制叫Container Recycling目的是避免频繁创建UI元素性能收益非常大但也给样式状态残留埋了雷。很多人习惯在LoadingRow事件里根据数据的业务状态去设置行背景这是典型的错误用法。我见过太多这样的代码private void OrdersGrid_LoadingRow(object sender, DataGridRowEventArgs e) { var order e.Row.DataContext as OrderInfo; if (order ! null order.IsOverdue) { e.Row.Background new SolidColorBrush(Colors.LightPink); } }注意这个写法只处理了命中条件的情况。当滚动导致行容器被回收后容器被重新分配给一条新数据此时又触发LoadingRow新数据的IsOverdue为false程序走不到任何赋值分支可容器当前还带着上一轮被设置的LightPink背景于是新行就继承了旧行的颜色表现就是滚动后颜色串行。有人会说那我加个else重置行背景不就行了e.Row.Background (order ! null order.IsOverdue) ? new SolidColorBrush(Colors.LightPink) : Brushes.White;确实能解决大部分情况但这种基于事件的着色方案还有一个隐患——如果你给多行都设置了同一个Brush实例而这个Brush实例后面被修改了所有引用它的行背景会全部一起变。这同样是串行的一种形态。WPF里的SolidColorBrush派生自Freezable正常情况下如果被Freeze了就是只读共享但没冻结时它就是个可变对象。你在代码里new了Brush后赋给第1行又赋给第2行某一天因为某个状态切换把Brush.Color改成了别的颜色那第1行和第2行会同时变色排查起来比普通残留更隐蔽。正确思路是把行背景交给数据驱动而不是在事件里手工改UI属性。数据驱动的意思很简单让一行显示什么颜色由这条数据本身的可观察属性决定。最直观的方案是给ViewModel加一个Brush类型的计算属性然后直接绑定到RowStyle里DataGrid.RowStyle Style TargetTypeDataGridRow Setter PropertyBackground Value{Binding RowBackgroundBrush} / /Style /DataGrid.RowStyle只要RowBackgroundBrush在数据状态变化时主动触发PropertyChanged行背景就会跟着变化完全不需要容器事件参与。这也就从根本上避免了容器复用带来的残留。如果你对给ViewModel放Brush有洁癖觉得表现层逻辑不该混进业务数据那也可以退一步在RowStyle里用DataTrigger根据业务状态切换颜色。DataTrigger是依赖属性系统在样式层面直接计算结果不依赖事件虚拟化复用时UI状态会自动跟随DataContext变化不会残留DataGrid.RowStyle Style TargetTypeDataGridRow Setter PropertyBackground ValueWhite / Style.Triggers DataTrigger Binding{Binding IsOverdue} ValueTrue Setter PropertyBackground Value#FFFFE0E0 / /DataTrigger /Style.Triggers /Style /DataGrid.RowStyle这里要注意背景颜色不要用复杂的画刷对象直接用颜色字符串即可WPF会为每个元素解析出独立的画刷避免共享实例串色。4. 根因三样式复用与TargetType合并规则带来的跨列传染第三种串行藏在Style资源的共享机制里。WPF资源字典里的Style、DataTemplate等对象默认是全局共享的x:Shared属性默认值为true意思是所有引用同一个资源的位置拿到的是同一个实例。正常情况下这没问题因为Style本身很少被运行时修改。但一旦你的代码写了类似通过资源名取出一个Style然后往里加一个Setter或者从资源里取出一个Brush改了颜色再赋给某个单元格你就等于在修改共享资源影响面直接扩散到所有引用者。我自己踩过一个具体的坑。当时为了让某些单元格的边框在特定条件下变粗我在CellStyle里放了一个BorderBrush的资源引用然后在代码里通过dg.Columns[2].GetCellContent拿到TextBlock后修改了它所在Border的BorderBrush但没意识到这个BorderBrush是资源字典里的同一个共享实例。结果特效没做出来反而一整列所有单元格的边框颜色都变了而且刷新后也恢复不了因为共享Brush对象本身被改了。还有个更隐蔽的跨列传染场景和Style的TargetType合并规则有关。DataGridCell的CellStyle、DataGridTextColumn的ElementStyle、DataGridColumn的CellStyle三者经常混用如果不理解它们的覆盖顺序容易写出一套看起来Level 1正确但Level 2把颜色盖掉的样式。比如你在DataGrid.RowStyle里给整行设置了浅蓝色背景又在某个列的CellStyle里给单元格设置了白色背景。从视觉上看那一列就变成了白色其他列保持浅蓝就像该列颜色串了。这不是串行纯属覆盖关系——DataGridCell的背景属性默认是null让行背景透出来一旦设置了CellStyle.Background单元格就会盖住行背景。所以排查时遇到只有某列颜色不对的情况优先去查这个列对应的CellStyle和ElementStyle而不是RowStyle。另一个跟TargetType相关的坑是DataGridTemplateColumn里你写的DataTemplate如果根节点是Border那么这个Border不会自动继承单元格的样式。很多人误以为模板里的Border和CellStyle是同一个东西其实模板只是ContentPresenter里承载的内容CellStyle是作用于DataGridCell这个控件本身的。如果我想让单元格内容有一个单独的底色我通常会这样写模板DataGridTemplateColumn Header状态 DataGridTemplateColumn.CellTemplate DataTemplate Border Background{Binding StatusBackgroundBrush} Padding4 TextBlock Text{Binding StatusText} / /Border /DataTemplate /DataGridTemplateColumn.CellTemplate /DataGridTemplateColumn这里把背景放在Border上而不是放在DataGridCell的Style上有两个好处。第一它不参与DataGridCell和DataGridRow之间的覆盖博弈颜色完全由数据自身决定第二滚动回收时Border会随DataTemplate重新绑定数据状态不会残留。如果你的颜色逻辑仅针对某一个业务列这种写在模板里的方式比全局CellStyle里写触发器要直观得多我推荐优先使用。还要提醒一点如果Style里使用了DynamicResource引用某个画刷资源而该画刷资源在运行期被替换那么所有引用该资源的单元格都会同时更新。这种机制本身是特性不是Bug但在团队协作里如果某个全局资源被无意中替换就有可能出现我明明只改了一格颜色全表都跟着变的误报。排查时不妨看一眼目标单元格的颜色到底是StaticResource还是DynamicResource来源能省下不少力气。5. 根因四DataTrigger里的列判断在隐藏与重排后失灵第四类串行是列判断失灵。很多人想在单元格Style里写一个DataTrigger当某一列满足条件时应用颜色。最直观的思路是拿列的DisplayIndex或Header来判断。这种思路在程序启动的瞬间是正常的一旦发生列拖动、列隐藏、列排序判断条件就错位了。先说DisplayIndex。DataGridColumn.DisplayIndex表示列当前在UI上的显示顺序。当用户把某一列从第2列拖到第5列时该列的DisplayIndex从1变成4其他列的DisplayIndex在那一瞬间全部重排。如果你在CellStyle里写了类似这样的触发器Style TargetTypeDataGridCell Style.Triggers DataTrigger Binding{Binding DisplayIndex, RelativeSource{RelativeSource AncestorTypeDataGridColumn}} Value2 Setter PropertyBackground ValueYellow / /DataTrigger /Style.Triggers /Style首先这个写法本身就不对DataGridColumn并不是可视化树中的元素DataGridCell的可视化祖先里根本找不到它RelativeSource绑法在运行时大概率拿不到值。就算你用别的方式拿到了DisplayIndex只要某一列被隐藏列的显示索引就会重新压缩排列原来第3列的DisplayIndex会变成2于是黄色背景跑到了完全不同的列上。再说列头判定。用Header文本判断也不可靠。项目做了国际化切换语言后Header从金额变成Amount触发器立刻失效。而且如果两个列头重名判断就会批量命中。我见过的最离谱的案例是一个表格里两个列都叫数量其中一个设置了特殊背景结果两个列都变黄看起来就像颜色串到了另一列。正确的做法是列的身份标识用稳定信息不要用显示位置也不要直接依赖用户可见文本。DataGridColumn继承自DependencyObject它有一个Tag属性可以用。你可以给目标列打上稳定的标签DataGridTextColumn Header金额 Binding{Binding Amount} TagAmountColumn /然后在CellStyle里用DataGridCell.Column.Tag来判断。DataGridCell是样式应用的目标元素Column属性直接指向所属的列所以触发器可以这么写Style TargetTypeDataGridCell Style.Triggers DataTrigger Binding{Binding Column.Tag, RelativeSource{RelativeSource Self}} ValueAmountColumn Setter PropertyBackground Value#FFFFF3CD / /DataTrigger /Style.Triggers /Style这样用户怎么拖动、怎么隐藏列Tag都不会变样式始终作用在正确的列上。如果你的项目里DataGridTextColumn数量很多也可以选择更简洁的方案直接在目标列上单独写ElementStyle把这个列的样式和别的列彻底隔离从源头上避免共用兜底样式带来的串扰。ElementStyle的作用范围只有本列不会跑到别的列上这是最稳妥的按列着色方案。另外单元格样式里如果DataTrigger的Binding写的是当前行的DataContext属性那就完全不受列顺序影响。比如我想让所有行中金额为负的单元格显示红色DataGridTextColumn Header金额 Binding{Binding Amount} DataGridTextColumn.ElementStyle Style TargetTypeTextBlock Style.Triggers DataTrigger Binding{Binding Amount} Value-1 Setter PropertyForeground ValueRed / /DataTrigger /Style.Triggers /Style /DataGridTextColumn.ElementStyle /DataGridTextColumn这里Value-1属于简化写法实际判断负数需要配合Converter或者写一个比较器但思路是明确的把样式条件绑定到数据本身不掺入任何列显示状态就永远不会串。6. 排查链路与根治方案从属性溯源到数据驱动着色前面四类根因讲完可能有人要问如果我已经有一个跑着的项目颜色已经串成一团了应该从哪里入手看我一般按下面这条链路走效率比瞎试高得多。第一步精确定位哪一格变色。把表格停在一个串色场景下鼠标点击目标单元格在Visual Studio里打开实时可视化树选中这棵界面树上对应的DataGridCell节点。重点看它的依赖属性面板里Background、Foreground、BorderBrush这几个属性的值来源。Visual Studio的属性面板和Snoop这类工具都能显示当前值是来自本地赋值、Style Setter、Style Trigger还是继承值。第二步追到资源源头。如果值来源显示来自某个Style Setter或者DynamicResource引用顺着这个引用往上找资源字典。先看元素自身的Resources再看所属控件的Resources然后是Window最后是Application。很多全表变色的案例走到Application这一层就抓到真凶了。第三步临时关闭虚拟化做二分定位。把DataGrid的VirtualizingStackPanel.IsVirtualizing设为False强制所有行都创建容器。如果这样操作后颜色串行消失那基本可以断定是容器回收复用带来的状态残留如果关闭虚拟化后颜色还是错那就是Style或绑定本身的问题。这一步能迅速把问题归到两大阵营之一。第四步做最小化复现Demo。把原项目的样式抽到一个只有DataGrid和几条数据的示例工程里逐个删除样式直到剩下最后一个能触发串色的样式。这个过程中你会发现很多相关代码其实跟问题无关最终留下的一定是根因所在。用我上面的经验来判断通常最后剩下的要么是全局隐式Style要么是RowStyle里的事件赋值要么是列判断触发器。排查完成之后根治方案可以按优先级这样选。首选方案是数据驱动着色。把颜色相关逻辑收敛到ViewModel层。ViewModel额外暴露几个Brush类型的属性例如RowBackgroundBrush、AmountForegroundBrush数据状态变化时触发PropertyChangedXAML里直接用Binding绑定。这个方案最大的优势是完全没有事件和资源查找的参与UI永远跟随数据走不会残留也不会跨行串色。我自己现在的项目已经全面切换到这个模式排查样式问题的成本降了一大半。如果你的团队里对ViewModel放Brush有顾虑退一步用RowStyle和DataTrigger方案。前提是触发条件必须绑定数据字段而不是列索引、列头之类的不稳定信息。行背景和单元格背景的覆盖关系提前说清楚需要整行变色就只写RowStyle不在CellStyle里再设置背景需要某列单独变色就把样式写在该列的ElementStyle或CellTemplate里不去碰全局的CellStyle。自定义的全局隐式样式务必收敛。我现在的习惯是App.xaml里只放项目级的字体、颜色键和控件主题资源而且所有Style都带x:Key按需引用。DataGrid相关的样式一律放在DataGrid.CellStyle或DataGrid.RowStyle里如果需要复用再提取成命名资源。隐式样式这种自动生效的能力在页面简单时看似省事在一个多窗口、多表格的项目里就是定时炸弹。最后严格禁止在LoadingRow这类容器事件里一手修改UI属性。如果你接手的老代码已经有这种写法别急着删先记住一个原则事件里改UI状态必须可逆。也就是说只要在LoadingRow里设置了某个属性就必须在对应的UnloadingRow里把它还原。行容器被回收进池子前应该让UI回到初始状态否则旧状态就会跟着容器一起被借给下一行。这也算是容器复用场景下最底线的兜底策略。最后一个排查小技巧如果颜色串行只在某种特定的滚动速度或操作顺序下出现可以把问题先录屏然后在录屏里逐帧看是哪个操作触发的那一行变色。WPF的问题很多时候不是线性出现的录屏能帮你把串行出现的瞬间和代码执行的动作对应上往往一眼就找到是哪个事件或触发器在捣乱。
返回列表