你有没有想过 WPF 在幕后做了什么来将你的控件放在屏幕上?


WPF 文档可能非常简约且令人困惑,尤其是在涉及布局和呈现如何真正工作以及您需要在代码中做什么以充分利用它时。如果您对技术细节感兴趣,本文适合您。


WPF 控件继承

在我们进入布局的细节之前(即,测量一个控件需要多少屏幕空间并将其放置在屏幕上),我们需要简要讨论要布局的对象。在本文中,我将它们称为control,它可以是任何继承自FrameworkElementlike Controlor TextBoxor ...

Object -> DispatcherObject -> DependencyObject -> Visual -> UIElement -> FrameworkElement -> Control -> TextBoxBase -> TextBox

以下类对于布局讨论很重要:

  • DispatcherObject: 非常简单,只有Dispatcher控制 WPF UI 线程执行的属性。还管理在Dispatcher 哪些序列控件中进行测量、排列和渲染。
  • DependencyObject: 一个可以拥有 WPF 依赖属性的对象
  • Visual: 是一个abstract持有将控件渲染(绘制)到屏幕所需的属性的类
  • UIElement: 包含决定此控件是否需要布局和呈现的属性。一些布局相关的属性:DesiredSizeIsArrangeValidIsMeasureValid,RenderSize
  • FrameworkElement: 包含逻辑树 ( ParentLogicalChildren) 和可视树信息 ( VisualChildrenCountGetVisualChild()) 以及一些尺寸信息 ( ActualHeightHeightVerticalAlignmentMarginPadding)。

概述 WPF 布局过程

WPF 使用两个线程,一个用于呈现,一个用于管理 UI。您编写的 WPF 代码在 UI 线程上运行。渲染线程在场景后面运行,用渲染指令填充 GPU(图形处理单元)。几乎没有关于渲染线程的任何文档,但幸运的是我们不需要知道它的任何细节。

WPF 对我们所有的代码只使用一个线程有一个巨大的优势,那就是没有多线程问题,即两个线程试图同时更改相同的数据。

UI 代码的执行不是线性的,而是分三个阶段运行,不断重复:

阶段1

在 Window 生命周期的开始,调用其构造函数或执行 XAML 的代码会创建一个由 组成的树Visuals,其中 a Window 可能是用户可以在窗口中看到的所有其他内容的根。施工完成后,第二阶段开始。稍后,由于属性已更改、单击鼠标、窗口大小更改以及许多其他原因引发的某些事件,阶段 1 可能会再次运行。在第 2 阶段开始之前完成所有第 1 阶段代码的优点是,如果不同的代码部分需要再次对控件进行布局,则该布局只会执行一次,而不是在每次请求时立即执行。

阶段2

一旦 WPF 完成了希望在阶段 1 中运行的所有操作,它就会通过遍历树并要求树中的每个子节点测量自身来测量所有控件。

第三阶段

  1. 一旦子项被测量,他们就会被安排,即每个父项都会告诉他们的子项他们应该在哪里画画以及他们有多少空间。
  2. 如有必要,一旦安排好子项,它就会得到一个DrawingContext,它可以用它来编写绘图指令(= 渲染)。

XWPFTemplate Word 渲染生成PFD工具类_wpf

 

UI 线程由Dispatcher. 根据Dispatcher优先级选择活动。第 1 阶段的活动具有最高优先级 ( Normal)。即使在阶段 1 中运行的方法将 a 标记Visual 为布局,这也不会立即发生。相反,它Visual 被分配MeasureQueue ContextLayoutManager. 一旦优先Dispatcher 处理了所有活动Normal ,它就会告诉它ContextLayoutManager 在(阶段2)中Visuals 处理。MeasureQueue 处理完所有这些后,它会Dispatcher 告诉它ContextLayoutManager 处理(阶段 3,)Visuals 中的所有。ArrangeQueue DispatcherPriority.Render

从您自己的代码开始布局

WPF 通常知道何时需要再次布局控件,例如,如果用户更改了Window 该控件所在的大小。但有时,只有您的代码知道您的控件需要重新布局,例如,因为用户单击了按钮或计时器已打勾。您的控件可能仍然具有相同的大小,因此严格来说不需要新的测量和排列,只需要渲染,唉,WPF 不允许您只请求渲染。

UIElement您的代码可以调用以强制布局和呈现的方法列表:

  • InvalidateMeasure(): 添加Visual MeasurementQueue 设置其MeasureDirty 标志。测量稍后执行(阶段 2)。如果然后DesiredSize 那个 Visual 的 改变,那个 VisualArrange()将在所有Visuals 被测量之后被调用。
  • InvalidateArrange(): 添加Visual ArrangeQueue 设置其ArrangeDirty 标志。安排稍后执行(阶段 3)。如果然后更改,RenderSize 则立即调用作为其代码的一部分。请注意,使用 时,如果控件的大小没有改变,则不会执行渲染,即使控件的内容也可能发生了变化。Visual UIElement.Arrange()OnRender() InvalidateArrange()
  • InvalidateVisual():人们会期望您的代码可以告诉 WPF 只需要呈现您的 Visual,而不需要布局。唉,WPF 不允许你这样做。OnRender()只能从内部调用UIElement.Arrange()。因此InvalidateVisual()必须添加Visual ArrangeQueue设置RenderingInvalidated 标志。

DependencyProperty 和布局

WPF 使用自己的属性系统。定义 WPF 属性时,可以指示更改该属性的值是否需要对该FrameworkElement. 例如,Width a 的属性FrameworkElement 定义如下:



C#



复制代码



public static readonly DependencyProperty WidthProperty =
  DependencyProperty.Register(
    "Width",
    typeof(double),
    typeof(FrameworkElement),
    new FrameworkPropertyMetadata(
      Double.NaN,
      FrameworkPropertyMetadataOptions.AffectsMeasure,
      new PropertyChangedCallback(OnTransformDirty)),
      new ValidateValueCallback(IsWidthHeightValid));

对于我们的讨论,有趣的是FrameworkPropertyMetadataOptions.AffectsMeasure,它表示如果属性值发生变化,则FrameworkElement 需要进行测量。这FrameworkElement 会自动添加到MeasureQueue 及其MeasureDirty 标志集。

实际上有五种不同的FrameworkPropertyMetadataOptions.AffectsXxx标志:

  1. AffectsArrange
  2. AffectsMeasure
  3. AffectsParentArrange
  4. AffectsParentMeasure
  5. AffectsRender

有趣的是,属性值更改不仅可以强制对其所属的控件进行新布局,还可以强制对控件的Parent. 另一个有趣的点是,属性值的变化可以表明只需要渲染,而这些InvalidateXxx()方法是不可能的。

谁调用 UIElement.Measure() 和 UIElement.Arrange()?

WPF 容器类似于Window 或是Grid 其他 WPF 控件的父级。调用它Measure()Arrange()孩子的是父母,因为只有父母知道有多少屏幕空间可供孩子使用。因此,在父母之前衡量或安排孩子是没有意义的。当需要布局时,WPF 必须从树的根部开始,例如,Window 从那里遍历整个树,或者,如果树的一部分需要布局,则从该部分树的根部开始。为布局找到正确的控件是ContextLayoutManager.

上下文布局管理器

ContextLayoutManager 在写这篇文章的时候才发现,这意味着我下面的描述可能不是 100% 准确的。另一方面,ContextLayoutManager 它在幕后完成它的工作,我们并不需要知道所有的细节。

有一个 UI 线程可能需要执行的所有活动的Dispatcher 队列,例如对鼠标单击或计时器滴答声或打开窗口或进行布局或……这些活动按优先级排序,原因之一是确保所有正常的 UI 活动在布局开始之前完成。Dispatcher 还拥有一个ContextLayoutManager,它基本上维护了所有Visuals 需要测量的队列和所有需要Visuals 排列的队列。一旦到了测量的时间(=阶段 2,所有更高优先级的操作都已完成,并且树的至少一部分需要布局),ContextLayoutManager 搜索Visual 最接近需要测量的根并调用它Measure(),然后调用所有Measure()它的孩子,他们为所有孩子做同样的事情等等。每Visual 一个被测量的都将自己从MeasureQueue. 一旦该子树被完全测量,一些Visuals 可能会留在 中MeasureQueue,因为它们属于另一个子树并ContextLayoutManager 开始处理该树,直到MeasureQueue 为空。ArrangeQueue (=Phase3)再次发生同样的事情。

如果大小没有改变并且通过设置ArrangeDirty 标志明确要求不安排,则可能只执行测量,但不安排。

如果之前已经执行了测量并且不需要新的测量,则可能会发生仅执行安排而不执行测量的情况。

第 2 阶段:测量



C#



复制代码



YourControl.Measure()
UIElement.Measure()

  FrameworkElement.MeasurementCore()

    virtual FrameworkElement.MeasureOverride()
    override YourControl.MeasureOverride()

一个父调用YourControl.Measure(),它实际上是一个稍后调用的UIElement.Measure()调用FrameworkElement.MeasureCore() ,稍后调用FrameworkElement.MeasureOverride()它被您YourControl.MeasureOverride()的控件所覆盖的代码测量自身。

注意:以下是伪代码,它试图只显示要领。实际的代码要复杂得多。伪代码包括来自不同类的代码,例如当 UI 代码调用MeasureCore()FrameworkElement.MeasurementCore().

你可以在这里找到实际的源代码:

界面元素

框架元素

C#



收缩▲   复制代码



void UIElement.Measure(avialableSize){
  //1)
  if (IsNaN(availableSize) throw Exception
  if (Visibility==Collapsed) return;
  if (avialableSize==previousAvialableSize && !isMeasureDirty) return;

  //2)
  ArrangeDirty = true;
  desiredSize = MeasureCore(availableSize);

    virtual Size UIElement.MeasureCore(Size availableSize) {}
    override Size FrameworkElement.MeasureCore(availableSize){
       //3)
       frameworkAvailableSize = availableSize - Margin;
       if (frameworkAvailableSize>UIElement.MaxSize)
         frameworkAvailableSize = UIElement.MaxSize;
       if (frameworkAvailableSize<UIElement.MinSize)
         frameworkAvailableSize = UIElement.MinSize;
       desiredSize = MeasureOverride(frameworkAvailableSize);

        virtual Size FrameworkElement.MeasureOverride(Size availableSize){}
        override Size YourControl.MeasureOverride(Size constraint){
            //4) here you write the code measuring the control
            return desiredSize;
         }

       //5)
       desiredSize += Margin
       if (desiredSize<UIElement.MinSize)
         desiredSize = UIElement.MinSize;
       if (desiredSize>UIElement.MaxSize)
         desiredSize = UIElement.MaxSize;
       return desiredSize;
    }

  //6)
  if (IsNaN(desiredSize) throw Exception
  if (IsPositiveInfinity(desiredSize) throw Exception
  MeasureDirty = false;     
}

伪代码可能看起来有点混乱。重点是:

  1. 如果您的控件已折叠或自上次Measure()调用以来可用空间未更改,则代码将立即返回。如果可用空间未更改,则Measure()不会强制稍后运行。Arrange()
  2. 如果可用大小已更改,Arranged()则稍后将被执行,即使desiredSize 没有更改!
  3.  FrameworkElement 从 a 中减去 Margin vailableSizeFrameworkElement 确保 availableSize 小于等于您的控件的不是MaxSize. MaxSize 真正的 WPF 属性。我用它作为MaxWidth and的简写MaxHeightFrameworkElement 确保 availableSize 大于等于您的控件的MinSize
  4. 要对控件进行自己的测量,请在控件中覆盖MeasureOverride() 
  5. 一旦您的代码返回desiredSizeFrameworkElement 将 Margin 添加到desiredSizeFrameworkElement确保desiredSize大于等于MinSize 和小于等于MaxSize。如果Width 或被Height 定义,它们会否决MinSize desiredSize成为任何东西WidthHeight 指令。
  6. 最后,当不是数字或无限UIELement 时抛出异常。desiredSize

这里有趣的是输入availableSize 可以是无限的,但输出desiredSize 不能是无限的。无限大小作为输入是有意义的,例如当父级是ScrollViewer. 在 aScrollViewer中,每个子项都可以使用尽可能多的空间。基本上,当父项给子项无限的空间时,它会问子项想要多少空间,没有任何限制。

如果子项不知道应该使用多少空间,它可以 return constraint,但也可以 return new Size(0,0)。在安排期间,父项可能不在乎子项要求多少空间,而是给它比要求更多的空间。一个例子是当子项的Alignment 设置为 时Stretch,在这种情况下,父项会提供所有可用空间,即使子项要求的空间也更少。

请注意,FrameworkElement 它负责Margin,MaxSize MinSize,但对Borderand 没有任何作用Padding,这意味着您必须Padding在代码中处理,如果您的控件应该支持它。

第三阶段:安排(包括渲染)



C#



复制代码



YourControl.Arrange()
UIElement.Arrange()

  FrameworkElement.ArrangeCore()

    virtual FrameworkElement.ArrangeOverride()
    override YourControl.ArrangeOverride()

  virtual UIElement.OnRender()
  override YourControl.OnRender ()

一个父调用YourControl.Arrange(),它实际上是一个稍后调用的UIElement.Arrange()调用FrameworkElement.ArrangeCore(),稍后调用FrameworkElement.ArrangeOverride()它被您YourControl.ArrangeOverride()的控件所覆盖的代码测量自身。

如果控件RenderSize 已更改或其RenderingInvalidated 标志已设置,则UIElement还会调用UIElement.OnRender() YourControl.OnRender()的控件具有创建渲染指令的代码的位置。



C#



收缩▲   复制代码



void UIElement.Arrange(Rect finalRect) {
  //1)
  if (IsNaN(finalRect) throw Exception
  if (IsPositiveInfinity(finalRect) throw Exception
  if (Visibility==Collapsed) return;
  //2)
    if (MeasureDirty){
    UIElement.Measure(PreviousConstraint)
  }
  //3)
  if (finalRect==previousFinalRect && !isArrangeDirty) return;
  UIElement.ArrangeCore(finalRect);

    virtual void UIElement.ArrangeCore(Rect finalRect){}
    override void FrameworkElement.ArrangeCore(Rect finalRect){
      //4)
      Size arrangeSize = finalRect.Size;
      arrangeSize = Math.Max(arrangeSize - Margin, 0);
      if (Alignment!= Stretch) arrangeSize = desiredSize;
      if (arrangeSize>MaxSize) arrangeSize = MaxSize;
      RenderSize = ArrangeOverride(arrangeSize);

        virtual Size UIElement.ArrangeOverride(Size finalSize){}
        override Size YourControl.ArrangeOverride(Size arrangeBounds) {
           //5) here, you write the code measuring the control
         }

      //6) this is followed by some complicated code doing clipping and LayoutTransform
    }
   
  ArrangeDirty = false;
  //7)
  if ((sizeChanged || RenderingInvalidated || firstArrange){
    DrawingContext dc = RenderOpen();
     OnRender(dc);

    virtual void UIElement.OnRender(DrawingContext drawingContext)
    override void YourControl.OnRender(DrawingContext drawingContext) {
      //9) here you write the code rendering the control
    }
  }
  1. 对于测量,如果父级提供无限空间就可以了。但是,如果父级通过无限空间进行排列,Exception 则会抛出 an。如果您的控件已折叠,则Arrange()返回。
  2. 理论上,一个控件应该总是在安排之前进行测量。但是当Arrange()通知Measure()之前没有正确调用时,即MeasureDirty 仍然设置,立即Arrange()调用Measure()
  3. 如果可用空间未更改isArrangeDirty 且未设置,则Arrange()返回。
  4. arrangeSize 也可能因为裁剪而被调整,这在伪代码中没有显示。arrangeSize也可能因为LayoutTransform,而被调整,这在伪代码中没有显示。
  5. Arrange(Rect finalRect)接收 a Rect,其中包含坐标和 a X,而仅接收。当子级安排自己时,它不知道自己的和在其内部的坐标。要自己安排和呈现控件,请在控件中覆盖。YSizeArrangeOverride(Size finalSize) finalRect.SizeXYParentArrangeOverride()
  6. 一旦您的控件完成排列和编写渲染指令,UIElement.Arrange()继续进行一些裁剪和布局转换计算。
  7. 如果渲染大小已更改或RenderingInvalidated 设置了标志,则UIElement.Arrange()调用UIElement.OnRender()在您的控件中被覆盖。在那里,您放置渲染指令。

请注意,如果对齐设置为拉伸,则父级会为子级提供所有可用空间,而不仅仅是desiredSize子级所需的空间。

不同尺寸属性的含义

当我开始使用 WPF 时,我经常错误地认为它Control.Width会告诉我屏幕上控件的宽度是多少,这根本不是真的:

  • Width: 可用于建议控件应具有的宽度的属性,可以在 XAML 中设置,默认为double.Nan(即未使用)。
  • Height: 可用于建议控件应具有的高度的属性,可以在 XAML 中设置,默认为double.Nan(即未使用)。
  • DesiredSize:在加边距结束时您的控件请求的大小将MeasureOverride(), 被添加,并且 Width/Height、MinWidth/MinHeight 和 MaxWidth/MaxHeight 被强制执行(如果已定义)。 DesiredSize 被父母用来安排。
  • RenderSize:父级为您提供的渲染控制大小。RenderSize 不同,DesiredSize 因为 a)DesiredSize 有,Margin RenderSize 没有 b) 父级可能决定给子级一个不同于要求的大小。
  • ActualWidth: 实际上是RenderSize.Width
  • ActualHeight: 实际上是RenderSize.Height