延迟折叠控件的元素初始化






4.76/5 (10投票s)
如何通过延迟初始化折叠控件来提高 XAML 加载速度(性能)。
问题
许多与将控件从 XAML 呈现到屏幕相关的延迟,对于折叠的控件都会自动消除:大多数绑定永远不会被评估,测量和排列过程永远不会发生,当然渲染也不会发生。
对于折叠的控件,剩余的延迟与初始化有关:InitializeComponent()
在每个控件构造函数的顶部被调用,这将导致该控件的 XAML 被加载、解析并用于构建控件的可视树,无论该控件是否会被显示。更昂贵的是,InitializeComponent()
将控件在 XAML 中指定的事件处理程序连接到相应的事件,这会导致 Initialized
和 Loaded
事件的事件处理程序被调用——再次强调,是在尚未确定控件是否会显示之前。
本文档以及实现解决方案的配套项目的目标是找到一种方法来消除折叠控件情况下这些时间延迟。
问题有两个部分
- 指定延迟初始化:使用 XAML 指示哪些特定控件应延迟初始化。
- 实现延迟初始化:赋予控件类延迟初始化其类中控件的能力。
(这个顺序可能看起来不寻常。当添加新功能时,我们通常首先关注如何使其正常工作,然后才像事后诸葛亮一样关心如何开启和关闭它,并给出一些标准解决方案。但这个问题有一些独特的困难。您将在下面看到原因。)
指定延迟初始化
我们不希望我们的解决方案通过在所有地方延迟初始化而引起程序范围内的广泛更改。改变控件初始化的方式和时间是相当深入的事情,我们需要能够一次在一个地方启用此功能,以便我们能够评估结果。这意味着每个类需要默认像往常一样执行——立即调用 InitializeComponent
——但是我们需要某种属性或设置来告诉一些控件等待直到可见性被评估。
此外,我们需要将此能力添加到许多不同的控件类中,并且我们不想在不绝对必要的情况下向所有这些不同的类添加代码。
这些需求的结合使得附加行为听起来是完美的解决方案——附加行为既可以添加用于声明我们希望延迟初始化的属性,又可以在不直接向控件类添加代码的情况下更改控件的行为。
不幸的是,附加行为的解决方案——至少在其最纯粹的形式中——立即会遇到几个无法克服的障碍
- 对
InitializeComponent()
的调用硬编码在每个构造函数的开头,在对象关联的任何事件被引发之前,因此我们无论如何也无法通过巧妙的事件处理(附加行为的核心)来阻止InitializeComponent()
被调用。 - 在
InitializeComponent()
内部,一个名为_contentLoaded
的字段决定该方法体是否实际执行。这是一个私有的bool
,所以我们再次无法通过附加行为来劫持这个字段,以阻止InitializeComponent()
执行。
因此,我们已经知道解决方案至少需要对我们希望启用延迟初始化的类进行最小的代码更改——我们无法完全通过依赖事件处理的附加行为来完成。
好了,如果我们通过实际向每个启用的类添加代码来实现此功能(我们稍后会回来讨论它——最终它只需要一行代码),那么问题就变成了我们应该使用什么机制来指定我们希望延迟初始化的具体控件实例。
我们绝对希望能够使用 XAML 来指示延迟初始化哪些控件,因此剩下的选择是向每个类添加一个依赖属性,或者使用一个附加属性。
这时,我们遇到了另一个相当大的障碍:我们无法在控件构造函数的顶部评估附加属性以决定是立即初始化还是延迟初始化,因为在构造函数执行时,这些属性尚未被评估和分配!对于控件类定义中的常规依赖属性也是如此——它们的值直到构造函数执行完毕后才分配给控件。这似乎让我们完全陷入困境。
我解决这个问题的解决方案有效,但我不能声称它很漂亮,我欢迎改进它的建议。也就是说,我们不会将指示延迟初始化的属性附加到我们感兴趣的控件上,而是附加到该控件的父控件(或其他祖先)上——因为在我们感兴趣的子控件的构造函数被调用时,父控件的属性已经被评估和分配了。
为了指定我们希望延迟初始化的子控件,最明显的方法是将附加属性的值设置为我们希望影响的控件的名称(或名称列表)。但这也有一个问题:将我们想要影响的控件的名称设置为附加属性的值是无用的,因为——您猜对了——在构造函数执行时,子控件的“name
”属性尚未分配。所以,当我们想要用它的名字来决定是否延迟初始化时,我们想要影响的控件甚至不知道自己的名字。
情况变得更糟:子控件的可视树还没有构建(避免这样做是我们的一大目标),更不用说连接到整个逻辑树了,所以“Parent
”属性尚不可用。我们无法检查父控件是否设置了附加属性,因为无法知道我们预期的父控件是谁。
那么,如果我们无法找到父控件来检查属性,将附加属性设置在控件的父控件上有什么用呢?嗯,有一种方法。
具体来说,我们为父控件上的附加属性创建一个 OnChanged
处理程序,并使用它来设置一个 static
字段,指示正在构造的任何内容都希望延迟初始化。
等等:这听起来像是一个彻底的失败。我们刚刚永久地为附加属性元素之后构造的任何内容启用了延迟初始化,对吗?而这根本不是我们想要的。
我们通过将附加属性变成一个附加行为来纠正这一点:我们让附加属性的 OnChanged
处理程序为属性所附着的元素的“Initialized
”事件添加一个事件处理程序。此处理程序将 static
字段重置为指示我们不再希望延迟初始化。这会在被属性化元素的后代被构造时启用 static
“如果可能,执行延迟初始化”标志,并在之后将其关闭。这样,只有祖先元素的后代会受到影响。
影响属性化元素的延迟初始化能力的所有后代比什么都没有要好,但它仍然看起来有些零散——可能有一些子控件我们希望延迟初始化,而有一些我们不希望。我们还可以轻松地做一件事来收紧焦点:正在构造的子控件还没有名字,但它们已经有类型了。我们不只是将属性(我称之为“SkipType
”)设置为“True
”,表示希望对所有后代进行延迟初始化,而是将 SkipType
设置为我们想要影响的控件的 Type
——或者设置为 System.Object
,如果我们想影响每个能够延迟初始化的后代。如果我们认为父元素中可延迟控件类的数量将很少,这就为我们提供了足够精度的控制水平来开启和关闭此功能。
如果我们遇到一种情况,我们想为一种控件启用延迟初始化,但关闭为具有相同类的直接同级控件(不太可能),我们总是可以将我们想要延迟的控件包装在一个 0 像素宽的 Border 中,并在此元素上设置附加属性。
因此,现在,我们终于有了一种方法来指示控件我们是否希望它延迟初始化,直到它确定自身是否可见为止。
实现延迟初始化
使此功能工作实际上比指定我们希望它应用的控件更简单。或多或少,我们希望在控件构造函数的顶部执行以下逻辑:
如果控件不是我们想要延迟初始化的类型,我们只需在控件上调用 InitializeComponent()
。但是,如果控件是所需类,我们则为 IsVisibleChanged
设置一个事件处理程序,并从那里调用 InitializeComponent()
。 InitializeComponent()
反过来会解析 XAML,构建控件的可视树,并连接控件的事件处理程序。 IsVisibleChanged
处理程序要到控件的可见性发生更改时才会调用,而对于折叠的控件,这永远不会发生。这就是重点。
IsVisibleChanged
还需要做一件事:在控件被构造和调用 IsVisibleChanged
之间,框架会继续引发控件的 Initialized
和 Loaded
事件,即使控件没有可视树,也肯定没有被初始化或加载。幸运的是,这并不会导致这些事件的处理程序被过早调用:这些处理程序通常在控件的 XAML 中指定,当然,在调用 IsVisibleChanged
之前,这个 XAML 还没有被解析!
因此,我们避免了这些处理程序被框架错误且过早地调用。但我们仍然需要在 IsVisibleChanged
中调用它们,因为现在控件已经被实际初始化和加载了。
不幸的是,没有办法从 IsVisibleChanged
处理程序内部引发这些事件——它们只能从控件类内部引发,而我们确实希望我们将所有这些代码移入每个需要延迟初始化的类中。
我们通过将所有这些附加代码移动到一个名为 InitializationOptions
的 static
类来解决“代码放在哪里”的问题——我们之前提到的 static
字段也放在那里——我们通过调用 InitializationOptions.Initialize()
来完成 IsVisibleChanged
的连接和延迟初始化的决策,我们在控件类的构造函数中调用它,而不是调用 InitializeComponent
。
我们通过重载 InitializationOptions.Initialize()
来解决“如何引发 Loaded
和 Initialized
”的问题,使其可以根据需要接受两个附加参数——控件上 Initialized
和 Loaded
事件的处理程序。我们无法引发与这些处理程序关联的事件,但没有任何东西阻止 InitializationOptions.IsVisibleChanged
直接调用处理程序,在调用 InitializeComponent()
之后。
唯一额外的复杂之处在于 InitializationOptions
类需要一种方法来在调用 Initialize()
和调用 IsVisibleChanged()
之间跟踪这些处理程序。我们通过让 Initialize()
在控件本身上设置一对附加属性来实现这一点。
这就总结了如何将初始化推迟到控件实际显示,或者至少更改为除折叠之外的状态——我们需要为 Hidden 控件也调用 InitializeComponent()
,因为我们需要它们的尺寸信息。
有效性
本文档附带的示例程序有两种模式,可以通过在 MainWindow.xaml 中注释掉/取消注释掉不同的可视化树来选择。
在“功能测试模式”下,应用程序为单个自定义控件使用延迟初始化。这对于在调试器中单步执行代码和理解正在发生的事情很有用。
在“速度测试”模式下,应用程序使用延迟初始化来创建大量 Collapsed
、非平凡的自定义控件。
两种模式都会显示一个对话框,显示加载期间花费了多少毫秒,并且在这两种模式下,都可以注释掉指定延迟初始化的附加属性,从而可以比较加载时间。
在我特定的系统上多次计时运行的结果是,创建没有子元素的空窗口 StackPanel
花了 46 毫秒;使用标准初始化创建折叠控件块花费了 187 毫秒;使用延迟初始化创建折叠控件块花费了 62 毫秒。如果减去创建空窗口的时间(46 毫秒),则剩下 141 毫秒用于正常创建控件,以及 16 毫秒用于使用延迟初始化创建控件。
换句话说,使用延迟初始化创建这个折叠控件块比传统初始化快了约九倍。
这个示例中没有示例控件类的 Initialized
和 Loaded
事件处理程序中的任何代码。如果控件在 Initialized
或 Loaded
的处理程序中执行了任何工作,所有这些工作都将使用标准初始化完成,而没有任何工作会使用延迟初始化完成。换句话说,速度差异将从 9:1 扩大到任意大的程度,具体取决于两个事件处理程序中完成的工作。
限制
尽管这个系统很酷,但有几种情况它无法处理:
- 使用相对源绑定
Visibility
。RelativeSource
需要一个逻辑树作为参照——而使用延迟初始化的控件直到可见性被评估后才会被插入到逻辑树中。所以这行不通。 - 在控件外部、在控件可见之前从 XAML 设置的属性的“
OnChanged
”处理程序。设置属性是可以的,但直到控件显示,它都不会解析其 XAML,也不会连接其事件处理程序。如果您需要此功能,请在控件的构造函数中设置事件处理程序——这样它们就不会依赖于何时加载控件的 XAML。 - 在构造函数中执行依赖于可视化树的操作,而我们没有构建可视化树,因为我们没有调用
InitializeComponent
。任何此类代码都需要移到Initialized
或Loaded
的处理程序中。 - 非自定义控件。由于您需要向受影响的控件类添加代码(一行),因此此技术仅适用于自定义控件类,而不适用于预定义的、框架提供的类。
示例程序
- 延迟初始化工作原理的大部分内容都在 InitializationOptions.cs 中。
- 使用延迟初始化的示例控件的代码在 SimpleControl.xaml 和 SimpleControl.cs 中。
- 要更改示例程序的运行模式,请阅读 MainWindow.xaml 中的注释并按照说明进行操作。您可以启用或禁用延迟初始化来运行程序,也可以使用足够多的延迟初始化控件来产生明显的速度差异。
如何在您自己的代码中使用延迟初始化
如果您想修改一个类以便它可以使用延迟初始化,请执行以下操作:
- 将我示例项目中的 InitializationOptions.cs 文件添加到您的项目中。
- 将构造函数中的行替换为
InitializeComponent();
替换为行
InitializationOptions.Initialize(this [,My Control_Initialized][,My Control_Loaded]);
其中
MyControl_Initialized
和MyControl_Loaded
是可选参数,分别指定控件的Initialized
和Loaded
事件的处理程序。 - 在创建控件的 XAML 中,在控件的祖先上——而不是控件本身——设置以下属性:
initopt:InitializationOptions.SkipType="{x:Type sys:Object}"
默认情况下,这将为具有此属性的控件的所有支持延迟初始化的后代启用延迟初始化。如果您只想为某个类的控件启用延迟初始化,请将 sys:Object
替换为该类。
历史
- 2011 年 6 月 27 日:初始版本