深入了解WPF布局和渲染

目录

WPF控件继承

概述WPF布局过程

从您自己的代码开始布局

DependencyProperty和布局

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

上下文布局管理器

第2阶段:测量

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

不同尺寸属性的含义

未来的文章


WPF控件继承

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

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

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

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

概述WPF布局过程

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

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

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

阶段1

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

阶段2

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

阶段3

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

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

从您自己的代码开始布局

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

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

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

DependencyProperty和布局

WPF使用自己的属性系统。定义WPF属性时,可以指示更改该属性的值是否需要对该FrameworkElement进行新的布局。 例如,FrameworkElementWidth属性定义如下:

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

上下文布局管理器

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

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

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

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

2阶段:测量

YourControl.Measure()
UIElement.Measure()

  FrameworkElement.MeasurementCore()

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

父级调用YourControl.Measure(),实际上是UIElement.Measure()的调用,随后调用FrameworkElement.MeasureCore(),随后调用FrameworkElement.MeasureOverride(),它被YourControl.MeasureOverride()覆盖,你的控件有代码测量自己。

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

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

UIElement

FrameworkElement

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 vailableSize中减去MarginFrameworkElement确保vailableSize小于等于您的控件的MaxSizeMaxSize不是真正的WPF属性。我用它作为MaxWidthMaxHeight的简写。FrameworkElement确保vailableSize大于等于您的控件的MinSize
  4. 要对控件进行自己的测量,请在控件中覆盖MeasureOverride()
  5. 一旦您的代码返回desiredSizeFrameworkElement Margin添加到desiredSizeFrameworkElement确保desiredSize大于等于MinSize和小于等于MaxSize。如果WidthHeight被定义,它们会否决MinSize并且desiredSize成为任何WidthHeight规则。
  6. 最后,当desiredSize不是数字或无限时UIELement抛出异常。

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

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

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

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

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()覆盖,你的控件有创建渲染指令的代码。

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。如果您的控件已折叠,则返回Arrange()
  2. 理论上,一个控件应该总是在安排之前进行测量。但是当Arrange()通知之前没有正确调用Measure()时,即MeasureDirty仍然设置,Arrange()立即调用Measure()
  3. 如果可用空间未更改且isArrangeDirty未设置,则返回Arrange()
  4. arrangeSize也可能因为裁剪而被调整,这在伪代码中没有显示。arrangeSize也可能因为LayoutTransform,而被调整,这在伪代码中没有显示。
  5. Arrange(Rect finalRect)接收Rect,其中包含坐标XYSize,而ArrangeOverride(Size finalSize)仅接收finalRect.Size。当子控件安排自己时,它不知道自己在其Parent内部的XY坐标。要自己安排和呈现控件,请在控件中覆盖ArrangeOverride() 
  6. 一旦您的控件完成排列和编写渲染指令,UIElement.Arrange()继续进行一些裁剪和布局转换计算。
  7. 如果渲染大小已更改或RenderingInvalidated设置了标志,则UIElement.Arrange()调用UIElement.OnRender()在您的控件中被覆盖。在那里,您放置渲染指令。

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

不同尺寸属性的含义

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

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

未来的文章

我正在写一篇新文章,解释如何从后面的代码编写自己的用户控件,托管其他控件并直接写入屏幕,当我意识到解释WPF中的布局和渲染如何工作时需要它自己的文章,这是一篇你目前正在阅读的文章。但是,这篇文章并没有解释如何在你的控件中编写布局和渲染的代码,为此你必须等待我的下一篇文章。接下来是第三个解释如何测试和调试您的控件,这是相当复杂的。

WPF开发人员必读:WPF控件测试台

其他一些评价很高的WPF文章:

https://www.codeproject.com/Articles/5324971/Deep-Dive-into-WPF-Layouting-and-Rendering

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值