多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

WPF核心继承链:DependencyObject、Visual、UIElement职责与底层原理

WPF核心继承链:DependencyObject、Visual、UIElement职责与底层原理 C#的WPF界面框架里DependencyObject、Visual、UIElement这三个类很多人一开始学的时候容易搞混脑子里只有一个模糊的概念“好像都是UI相关的基类”。但如果写自定义控件、做渲染优化、或者调试一些奇怪的UI问题搞不清它们各自的职责就很容易踩坑。这篇文章我就把它们三个从底层拆开聊顺便把依赖属性、渲染管线、布局系统这些关联内容也串起来希望帮你把这条继承链彻底理顺。1. 三个类的前世今生与整体定位1.1 从继承链看懂WPF的两条主线WPF的类继承体系其实有两条大主线一条是偏向数据与逻辑的一条是偏向显示与交互的。我们常说的这三个类正好覆盖了显示与交互主线的三层核心。先说DependencyObject它是WPF依赖属性系统的根。WPF里几乎所有能参与数据绑定、能套用样式、能响应动画的属性都建立在依赖属性机制之上。你去看那些UI控件的属性比如Button的Content、Canvas的Left、TextBlock的Text本质上都是依赖属性。而DependencyObject就是这个系统的地基。Visual直接继承自DispatcherObject而DispatcherObject又是DependencyObject的子类。Visual的职责非常纯粹渲染。WPF里每一个在屏幕上画出来的东西最终都会对应到一个Visual对象。它负责维护渲染数据、配合渲染线程把内容画到屏幕上、还负责命中测试和坐标变换。换句话说Visual是WPF渲染体系的基座。UIElement又继承自Visual。在Visual的渲染能力基础上UIElement加上了布局、输入、焦点、事件路由这些交互能力。到了这一层才算是真正的“UI元素”。像Button、TextBox这些控件往下追几层一定能追到UIElement。它们的继承关系是这样的Object - DispatcherObject - DependencyObject - Visual - UIElement - FrameworkElement如果你经常看自定义控件的资料会发现里面还有Validation、Automation等分支但核心绘制链路就是上面这条。搞清楚这条链很多问题就迎刃而解了。1.2 三个类各自管什么打个不太严谨但很好理解的比方DependencyObject是后台的数据部门只管属性值怎么存、怎么变、怎么通知别人Visual是画画的那个部门只管像素怎么呈现、画面怎么组合UIElement是前台服务部门负责接收鼠标键盘消息、安排每个元素的位置、处理焦点逻辑。DependencyObject不关心你长什么样不关心你显示在哪里Visual关心显示但不管用户点了哪里UIElement才把这两者串起来让一个东西既能被画出来又能被用户交互。这个分层设计是有讲究的。如果把所有能力堆到一个类里那整个框架的耦合度会非常高性能和灵活性都会受影响。WPF选择拆成多层也有它自己的考虑。1.3 为什么需要这样拆早期WinForms的做法是控件自己做绘制、自己做事件处理、自己做数据存储这导致控件类的代码非常臃肿。WPF想支持更丰富的样式、更灵活的数据绑定、更复杂的组合渲染就必须把职责拆开。DependencyObject单独抽出来才能做到属性系统复用。WPF里几乎所有类型都依赖属性系统不只是UI元素动画、样式、数据绑定这些非可视对象也需要依赖属性支持。如果依赖属性只放在UIElement这一层那动画系统、数据绑定系统就没法用了。Visual单独抽出来是为了渲染层面的优化。WPF的渲染采用保留模式也就是说你设置了一个视觉效果后渲染系统会一直“记住”它而不是每次重绘时都通知你重画。Visual就是承载这份“记忆”的容器它保存渲染数据、处理合成、协调屏幕刷新不需要关心按钮被点击后要触发哪个事件处理器。UIElement在两者之上负责事件、布局、焦点这些最终呈现在用户面前的东西。这样一层层剥开每个类做自己最擅长的事整个框架才能做到既灵活又高效。2. DependencyObject依赖属性系统的根基2.1 为什么它叫“依赖”属性一般面向对象编程里对象属性就是类的字段赋值就存用的时候取。WPF的依赖属性不走这条路它不把值直接存到对象自己的字段里而是通过一个全局的注册表加上一套值解析规则来管理。这套机制的关键是“依赖”这个词。一个属性值可能不来自你自己给它赋的值而是来自样式、模板、数据绑定、动画、甚至父级元素传递下来的默认值。属性值的来源不固定它“依赖”于周边的环境和状态所以叫依赖属性。举个例子你没给Button设置过FontSize属性但按钮文字显示的却是14px这个值可能来自Style、可能来自父级容器继承也可能来自主题默认值。这些值最终通过依赖属性系统算出来再作用到Button上。依赖属性的核心入口是DependencyProperty.Register方法。看过WPF控件源码的你一定见过类似这样的代码public static readonly DependencyProperty BackgroundProperty DependencyProperty.Register( Background, typeof(Brush), typeof(Control), new FrameworkPropertyMetadata(null, FrameworkPropertyMetadataOptions.AffectsRender));其中第四个参数是元数据描述了当属性变化时会对系统产生什么影响。这里AffectsRender标记的意思是属性变了需要重新渲染这块UI。正是这些元数据标记让WPF在属性变更时可以精准地触发对应阶段的处理而不是盲目地全部刷新。2.2 依赖属性值的存储和查找过程DependencyObject内部没有为每一个依赖属性都分配一个字段而是维护了一个“有效值条目”的数组。当属性值被表达式比如Binding表达式控制时会额外创建表达式对象并记录在表达式中。这样做的目的很明确节省内存。一个控件可能有上百个属性如果每个控件实例都给这些属性分配字段那一个窗口里几千个控件就够内存喝一壶了。依赖属性把不需要显式覆盖的属性值统一存在全局表里实例对象只存自己改过的值内存占用会显著减少。当读取一个依赖属性的值时DependencyObject会按照一套优先级来解析实际值。这套优先级从高到低大概是属性动画值动画正在运行时本地赋值显式给这个属性设置的代码或XAML值模板值来自ControlTemplate的内部设置隐式样式/显式样式的Setter主题样式属性继承比如从父级继承的DataContext、FontSize依赖属性默认值这些优先级有严格的顺序理解了它就能解释很多稀奇古怪的现象。比如为什么你在后台代码给某个属性赋了值但运行时显示的却是另一个值很可能是因为动画或样式优先级更高。2.3 属性继承机制和DataContext属性继承是DependencyObject中一个很实用的特性。某些依赖属性是有Inherits标记的当一个父元素设置了这类属性如果没有被子元素显式覆盖子元素会自动继承父元素的值。最典型的就是DataContext和FontSize。一个Window设置了FontSize14那窗口里所有没有显式设置FontSize的控件都会显示14号字体。这个继承沿可视化树或逻辑树传播并不像普通面向对象里类的继承那样走类层级。这个机制也带来一些坑。比如你在一个模板里设置了一个控件如果这个模板内元素没有接收到DataContext大概率是因为模板外的DataContext没有正确流进来或者是断开了逻辑树的连接。排查此类问题首先要看继承链是否完整。2.4 依赖属性变更通知和绑定依赖属性的另一个核心能力是变更通知。普通CLR属性变更时没人通知你但依赖属性在值变化时会触发PropertyChangedCallback、会往绑定系统发通知、会让绑定到它身上的其他属性跟着更新。这个机制是数据绑定的基础。你在XAML里写{Binding Name}本质上是给一个依赖属性绑定了一个表达式表达式内部监听数据源的属性变更事件。如果数据源实现了INotifyPropertyChanged那数据一变目标属性就会通过依赖属性的变更通知机制收到消息并更新。这里有个容易忽略的点绑定表达式产生的属性值属于“本地值”层级但表达式本身是特殊的。如果你再显式给同一个依赖属性赋一个本地值绑定就被覆盖了。这在实际开发中经常让人头疼。想保持绑定又想动态修改值需要搞清改的是数据源的值而不是直接覆盖目标属性。2.5 使用依赖属性来解决的实际问题我写过不少自定义控件几乎每个自定义控件都涉及创建自有依赖属性。为什么要用依赖属性而不是普通属性支持绑定。用户自定义控件如果没有依赖属性使用者就不能用{Binding}去绑你的属性支持样式。别人写Style时Setter只能针对依赖属性支持动效。动画框架只能操作依赖属性支持继承和默认值管理。属性继承、默认值这一套都是依赖属性的能力。创建自定义依赖属性的标准套路大致如下public class MyCustomControl : Control { public static readonly DependencyProperty LabelProperty DependencyProperty.Register( nameof(Label), typeof(string), typeof(MyCustomControl), new PropertyMetadata(string.Empty, OnLabelChanged)); public string Label { get (string)GetValue(LabelProperty); set SetValue(LabelProperty, value); } private static void OnLabelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { // 属性变化时的处理逻辑 } }需要注意属性包装器里不能加额外逻辑应该把逻辑放进回调中。原因在于依赖属性系统绕过了包装器直接调用SetValue。如果你在包装器setter里做了其他事情在某些情况下这段逻辑不会被触发踩过这个坑的人应该深有体会。2.6 线程要求与Freezable依赖属性还有一个硬性规则只能由创建它的线程访问。因为WPF里的DispatcherObject绑定了线程亲和性跨线程访问会抛出异常。虽然也有Dispatcher.Invoke这样的绕行办法但根本原则还是尽量在UI线程操作。再说Freezable。很多依赖属性的值类型是Brush、Transform这类Freezable的子类。Freezable的特殊之处是冻结后可以在跨线程场景中共享性能更好因为冻结后不会再有变化通知。如果你的画笔、变换不会修改尽量调用Freeze()方法对性能有实际帮助。这个习惯养成后对大列表渲染、后台线程场景都会有好处。3. Visual渲染管线的幕后担当3.1 WPF渲染模式和Visual的关系很多人一开始接触WPF时都会习惯性地认为UI是一块画布每帧重绘画面。但实际上WPF走的是保留模式渲染。你可以理解为你告诉渲染系统“这里有一个红色的圆、一个蓝色的矩形”渲染系统会记住这个场景然后自己负责把场景画出来。你只需要在内容变化时通知它更新不用操心重绘逻辑。Visual就是那个“记住场景”的对象。WPF的每个可视化元素最终都会成为一个Visual并挂在可视化树Visual Tree上。渲染系统通过遍历这棵树把每个Visual的绘制指令组织起来交给GPU完成最终的合成。所以Visual本身并不直接执行GDI或DirectX绘图命令它保存的是绘图指令的“描述”。这些描述由托管层记录然后在渲染线程上转换并被底层图形引擎执行。3.2 Visual的层次化结构Visual可以包含子Visual子Visual还可以包含孙Visual这样形成一个树状结构。这个树叫可视化树。可视化树和逻辑树不是同一个东西。逻辑树是按业务逻辑划分的树比如一个ListBox里包含ListBoxItem可视化树则是最终参与渲染的元素树同一逻辑元素可能会展开出很多额外的Visual。举个例子一个Button在逻辑树上就是一个Button节点。但它的可视化树里除了Button本身还会有Border、ContentPresenter、TextBlock等视觉子节点。这些节点负责按模板绘制按钮的外观。理解这一点对调试样式问题很有帮助。那种“我明明设置了某属性但画面上没变化”的问题十有八九是影响到了逻辑对象但实际渲染由模板里的另一个可视化节点承接没有把效果落实下去。组合Visual还有个意义可以控制局部渲染。通过调整一个Visual的Offset、Transform、Clip、Opacity可以影响它的整个子树。这种能力被广泛用于动画比如让一个控件平滑地旋转、缩放或淡入淡出而不必重新生成内容。3.3 DrawingContext 和 DrawingVisualVisual本身是没有开放绘图API的。你要是想自己画点什么通常用DrawingVisual配合DrawingContext来实现。DrawingVisual继承自Visual可以用它承载自定义绘制内容。通过调用RenderOpen拿到DrawingContext然后往里画就完事了。实际用起来大概是这样的var drawingVisual new DrawingVisual(); using (var dc drawingVisual.RenderOpen()) { dc.DrawRectangle(Brushes.Red, null, new Rect(0, 0, 100, 100)); dc.DrawEllipse(Brushes.Blue, null, new Point(50, 50), 30, 30); }这段代码会生成一个包含红色矩形和蓝色椭圆的视觉内容。DrawingVisual非常适合做一些高性能的自定义图形场景比如画图表、做编辑器背景网格、实现截图框选等功能。一个经常被忽略的用法是和VisualBrush配合把DrawingVisual的视觉内容当作画笔填充到其他元素上。这样可以实现很多“截图”式的效果。但这里也要记住VisualBrush引用一个活着的Visual时会持续追踪它的变化成本不低。能冻结的尽量冻结不需要动态变化的场景果断Freeze。3.4 命中测试机制Visual还承担了另一个很重要的职责命中测试也就是判断某个坐标点命中了哪个可视化元素。常用的是VisualTreeHelper.HitTest方法。默认情况下命中测试是沿着可视化树向下递归的找到最原始的那个Visual。也有更高级的回调形式可以在命中多个元素时收集整个路径。命中测试有一个很常见的坑如果Visual的Background是null那么这块区域不会响应命中测试。只有背景是画刷哪怕是透明画刷这块矩形区域才会计算命中。很多初学者遇到“为什么我的控件点不到”的问题答案往往就是这句给它一个透明的背景。myBorder.Background Brushes.Transparent; // 或任意非null画刷在自定义绘制的内容中如果你用DrawingVisual画了几个图形但测试命中时得到的却总是最外层的容器多半是因为图形的几何区域没有正确参与到命中测试里。可以对绘图指令调用dc.DrawGeometry或用GeometryHitTestParameters并确认绘制内容确实被命中测试考虑。3.5 变换与坐标系统Visual还有一套坐标变换体系。每个Visual都可以通过VisualTransform设置二维仿射变换比如平移、旋转、缩放、倾斜。这套变换会影响它自己的绘制内容也会连带影响它的所有子Visual。布局时用到的很多坐标值都是相对坐标在考虑鼠标坐标转换、命中测试结果时必须把变换链算进去。WPF提供了TransformToAncestor和TransformToDescendant这样的方法可以在Visual树中转换坐标。例如需要把当前控件的坐标转换到Window坐标时var transform myElement.TransformToAncestor(visualRoot); var pointInWindow transform.Transform(new Point(0, 0));这个方法在拖拽、悬浮提示、弹窗定位等场景里非常常用。它的底层逻辑就是沿着Visual树累计变换矩阵一直到达目标Visual。3.6 UiElement和渲染层级的桥接Visual这一层虽然管渲染但不处理输入、不参与布局、没有真正意义上的“元素”概念。UIElement在Visual之上补齐了这些缺口。一个UIElement必然包含一个Visual作为它的渲染载体但UIElement并不能被简化为“就是一个Visual”它还包含了很多面向交互行为的机制比如给布局系统提供测量和安排的空间、把输入事件转化成为路由事件、参与焦点管理等等。可以说从Visual到UIElement是从“图形”跨向“交互”的关键一步。4. UIElement让界面响应用户的指挥家4.1 布局系统的两个阶段UIElement最重要的职责之一是布局。它把“我该画在哪里、占多大空间”这件事拆成了Measure和Arrange两个阶段。Measure阶段父级会问子元素“给你这么多可用空间你想要多大”子元素通过Measure方法计算它的期望尺寸。Arrange阶段父级最终确定子元素的位置和尺寸调用Arrange方法给出最终矩形。这两个阶段不是随便执行一下就完事。如果一个元素尺寸变化了它会影响父级、父级又可能影响兄弟节点形成一个连锁反应。框架会通过脏标记系统记住哪些元素需要重新布局只在下一帧统一处理避免频繁无节制的重排。这也是为什么不要在Measure和Arrange里做重量级操作的原因一旦布局被频繁触发性能会急剧下降。自定义控件时重写MeasureOverride和ArrangeOverride是常规操作。以自定义一个简单的流式排列面板为例public class SimpleWrapPanel : Panel { protected override Size MeasureOverride(Size availableSize) { double x 0, y 0, rowHeight 0; foreach (UIElement child in Children) { child.Measure(availableSize); if (x child.DesiredSize.Width availableSize.Width x 0) { x 0; y rowHeight; rowHeight 0; } x child.DesiredSize.Width; rowHeight Math.Max(rowHeight, child.DesiredSize.Height); } return new Size(availableSize.Width, y rowHeight); } protected override Size ArrangeOverride(Size finalSize) { double x 0, y 0, rowHeight 0; foreach (UIElement child in Children) { if (x child.DesiredSize.Width finalSize.Width x 0) { x 0; y rowHeight; rowHeight 0; } child.Arrange(new Rect(x, y, child.DesiredSize.Width, child.DesiredSize.Height)); x child.DesiredSize.Width; rowHeight Math.Max(rowHeight, child.DesiredSize.Height); } return finalSize; } }在MeasureOverride里一定要先调用child.Measure否则后面拿DesiredSize就是无效值。在ArrangeOverride里调用child.Arrange时传入Rect的Width和Height不能是无穷大或负数否则会抛出异常。这些细节点其实就是实战中最容易犯的错。4.2 输入事件与路由事件UIElement把底层的鼠标、键盘、触摸输入抽象成了路由事件。WPF没有采用传统的.NET事件直通方式而是引入“路由”的概念让事件可以在可视化树中传播。路由事件分为三类冒泡、隧道和直接。冒泡是指事件先从源头元素发出逐级向上层元素传播隧道则是相反方向从窗口顶层一路向下钻到源头元素。按键类事件通常走隧道PreviewKeyDown先到外层随后通过冒泡阶段KeyDown再处理一次。鼠标点击基本走冒泡路线。这就解释了为什么外层容器经常能捕获内部按钮的点击事件。如果你在Grid的MouseLeftButtonUp里加了处理逻辑无论用户点是哪个子元素只要事件能冒泡上来Grid就能收到。利用路由事件做统一处理也是一个常用模式。myPanel.AddHandler(Button.ClickEvent, new RoutedEventHandler(OnChildButtonClick));AddHandler可以监听整个子树中某个控件的事件同时还能通过handledEventsToo参数决定是否接收已经被标记为已处理的事件。这个参数很实用有时你想要“不管别人处没处理我都要响应”时这个参数正是关键。4.3 RoutedEvent 与命令系统路由事件还往往配合命令。MenuItem、Button这类控件会把点击转换成语义化的命令比如复制、粘贴、保存。命令机制的事件路由和普通路由事件一脉相承通过CanExecute和Execute将交互与业务逻辑解耦。写业务代码时我建议尽量使用命令而不是直接绑定点击事件处理器。命令天然支持禁用状态管理MVVM模式下也很容易对接ViewModel大大减少了“按钮灰了不能点”这类逻辑的重复劳动。4.4 焦点系统UIElement还负责焦点系统。桌面上至少有一部分人遇到过这样的问题做完一个窗口Tab循环不管用按钮焦点乱跳。焦点系统的核心逻辑是这样的只有一个元素可以成为键盘焦点用户按下Tab时框架根据当前焦点位置和Tab索引决定焦点下一个移动到哪里。IsTabStop属性决定一个控件是否可以被Tab键选中TabIndex决定Tab顺序Focusable决定这个元素是否可以获得焦点。设置三个属性的逻辑不想让某个Button被Tab选中设IsTabStopFalse自定义控件想接收键盘输入必须保障FocusableTrue某些交互面板即使不显示内容也想接收键盘事件同样需要Focusable。实际项目里弹窗打开时焦点默认落在哪里也很讲究。可以在Loaded事件里调用FocusManager设置初始焦点或者直接对目标控件调用Focus()但要注意窗口尚未完全激活时Focus调用可能失败。稳妥的做法是在Dispatcher.BeginInvoke里延后执行Focus。4.5 UIElement与合成、动画的关联UIElement除了布局和输入还有合成层的概念。Opacity、Visibility、Clip、RenderTransform这些属性最终都会转化成合成层的参数传给Visual。合成器可以独立于布局系统高效处理透明、裁剪和变换。动画系统在UIElement上尤其突出。你可以在依赖属性上做渐变动画比如让Opacity从0平滑变成1或者让RenderTransform的Angle从0转到90。这些动画都是由WPF的时钟驱动在UI线程的每一帧里更新属性值属于高性能的声明式开发方式。var animation new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)); myElement.BeginAnimation(UIElement.OpacityProperty, animation);这里动画直接影响依赖属性所以哪怕你之前把Opacity设置成某个值动画运行时那个值会被动画覆盖。动画结束后根据FillBehavior设置要么恢复原值要么保持动画结尾值。这块要是不理解有时就会发现“为什么我无论如何给透明度赋值控件还是不可见”这就是动画残留的锅。4.6 UIElement到FrameworkElement在WPF标准控件库中几乎不直接使用UIElement作为用户控件的基类更多是使用继承自UIElement的FrameworkElement。FrameworkElement在UIElement之上补充了数据绑定、样式、模板、资源查找、逻辑树等能力。但在自定义控件时选择继承层级是个常见考题想做纯绘制的图形对象没有交互需求选FrameworkElement或直接使用DrawingVisual。想做严格意义上的控件带模板、样式支持选Control。想做容器面板父类应该是Panel。想做一个轻量的UI元素不想要模板、样式等重型功能可以继承FrameworkElement。大部分情况下真正的业务控件在继承层级时最好考虑清楚是否用到模板、是否用到数据绑定、是否用到逻辑资源。用得越少继承越浅越轻量。5. 实战经验常见问题、排查思路与性能避坑5.1 依赖属性相关的高频坑先列几个我实际开发中反复遇到的依赖属性问题。第一个是大意覆盖Binding。水很深当你在后台代码直接对绑定了数据源的依赖属性赋值绑定就会断掉。如果之后数据源发生变化界面也不会再更新。要动态更新时应更新数据源属性或使用SetCurrentValue这样可以保持绑定关系。第二个是PropertyChangedCallback里操作依赖属性导致递归。尤其小心在回调里设置同一个属性的值或者设置与当前属性有联动关系的属性稍不注意就是个死循环加性能暴跌。Callback里应该尽量做轻量处理避免触发新的布局请求。第三个是自定义依赖属性类型为复杂集合时容易踩空。集合类型的依赖属性默认值在多个实例间共享一个实例改动了集合另一些实例就“同步”变化了。注册属性时要用PropertyMetadata注册一个新的集合实例作为默认值而不是共享一个静态List。5.2 Visual和渲染相关的排查建议渲染这块常见的问题是背景与命中测试的事前面已经提过。再补充几个。如果界面出现“东西看不见但占空间”的情况检查Opacity是否被动画变成0、Visibility是否被某个Style覆盖。我遇到过一次控件死活不显示最后发现是一个全局Style把Visibility绑到了某个开关属性上运行时开关为False怎么设都白搭。如果有自定义绘制但画面闪烁优先检查是否在OnRender里做了过重的工作。OnRender的调用频率可以很高特别是在滚动、鼠标移动等场景。能缓存DrawingVisual结果的话就缓存。不要模糊地认为“我只改了一次数据应该只重画一次”渲染线程和布局线程的触发条件比你想象中更敏感。用VisualBrush做“痕迹”效果时也请留个心眼。VisualBrush默认会把目标Visual的实时表现抓成画面。如果这个Source是一个很大的UI子树性能开销会实实在在地上去。能改成静态位图、能冻结就冻结。想动态又不卡再加缓存策略。5.3 布局系统性能杀手布局是WPF里最贵的操作之一。高昂的代价主要体现在元素很多、层级很深、频繁触发测量的情况下。几种常见布局性能问题在属性变化回调里直接改另一个元素的Width、Height。改尺寸会使父级重新Measure波及面广。大量使用自定义面板并重度重写MeasureOverride/ArrangeOverride导致每次布局都做高频计算。频繁切换Visibility。Visibility从Collapsed到Visible会触发完整布局如果用Opacity做显隐切换只是视觉隐藏成本更低但也要注意它不可命中、不占布局位。按场景取舍。把ListView模板里的所有子项都设成复杂控件数据量大时布局时间倍增。考虑虚拟化机制不要给不必要的控件加复杂模板。还有一个被忽略的点RenderTransform与LayoutTransform的区别。RenderTransform只是视觉变化的变换不参与布局性能好LayoutTransform会影响布局改了可能引起整棵子树重新布局。如果只是做动画、拖动尽量用RenderTransform。5.4 常见错误速查表我整理了一张小表方便你排查时对照现象可能原因处理方法属性值改了但界面不变绑定断链/值被动画覆盖/触发源不对检查属性优先级避免手动覆盖绑定点击控件没反应Background为null导致没有命中区域设置透明背景或非null画刷自定义面板布局时崩溃Measure/Arrange传了无穷大或负数检查可用尺寸限制约束边界界面频繁重排卡顿属性变化触发了不必要的布局给依赖属性元数据加AffectsMeasure标记时要克制自定义控件不能被Tab选中Focusable为False或IsTabStop被设错检查控件Focusable和TabIndex配置元素在屏幕上闪一下OnRender里做了大量绘制缓存渲染结果避免频繁调用OnRender5.5 调试可视化树和性能的实用技巧排查WPF问题可视化树调试能力是基础。在Visual Studio的调试会话中可以直接打开“实时可视化树”和“实时属性资源管理器”能直观看到元素在可视化树中的位置以及命中元素。这比断点调试、Console输出要直观得多。遇到性能问题时也可以用性能分析工具去看布局和渲染的时间分布。通常关注这几个指标渲染线程时间、布局次数、命中测试耗时。如果渲染线程占用高优先检查是否有大范围UI反复重绘如果布局次数特别多检查是否有属性触发了不必要的Measure。分享一个我当时排查一个列表卡顿的经验一个包含几百行自定义模板的ListBox滑动时掉帧明显。后来发现模板里每个条目都用了复杂的DropShadowEffect并且每条都在数据变化时重新布局。处理方法是把阴影效果改成预渲染的图片同时把会经常变化的属性标记为AffectsRender而不是AffectsMeasure。修复后滚动流畅了很多。5.6 自定义控件选型的经验判断最后说下自定义控件到底应该继承谁。如果你要打造一个有模板、有样式、能被主题换肤的控件继承Control并把外观放在ControlTemplate中逻辑写在类里。这符合WPF最主流的控件扩展方式。如果你只需要轻量的组合展示效果并且要支持绑定继承FrameworkElement并重写OnRender即可。适合做图标、简单装饰等。如果你要接管子元素的摆放实现自定义布局继承Panel并重写MeasureOverride和ArrangeOverride。如果你只是想在某个区域画出一堆图形并且希望性能尽可能好考虑DrawingVisual的机制。它没有布局开销没有事件路由简单直接。类层级的选择本质上是对能力与开销的一次思考。依赖属性带来了绑定和样式但注册本身是有开销的Visual带来了渲染和控制能力但和业务逻辑的隔离度更重要UIElement带来的交互能力也会因为复杂度影响性能。设计控件时多考虑需求偏向哪边再选哪一层会更清楚。
返回列表