理解 WPF 中的视觉树和逻辑树






4.92/5 (64投票s)
对 WPF 元素树及其细微之处的详细研究
引言
本文讨论了 WPF 中视觉树和逻辑树的细微差别以及它们之间的区别。它还提供了一个小型应用程序,您可以使用该应用程序进一步研究此主题。如果您完全不熟悉视觉树和/或逻辑树的概念,我建议您首先阅读 SDK 文档中的此页面。
背景
Windows SDK 中关于视觉树和逻辑树的现有文档还有很多不足之处。自开始使用 WPF 以来,我一直不确定这两者究竟有什么区别。我知道逻辑树和视觉树都只包含视觉元素和控件,对吧?错了。我知道一个 Window
/Page
/Control
/等包含一个且仅一个逻辑树,对吧?错了。我知道我在做什么,对吧?错了。
事实证明,WPF 中的元素树相当复杂,需要对底层 WPF 类有详细的了解才能正确使用它们。以通用的方式遍历元素树;假设对其组成部分一无所知;并不像看起来那么简单。不幸的是,WPF 没有公开一个简化元素树遍历到“真正容易”程度的类。
此时您可能想知道是什么让遍历元素树如此复杂。好问题。答案有几个部分,将在以下各节中讨论。
视觉树
视觉树表示 UI 中所有渲染到输出设备(通常是屏幕)的元素。视觉树用于许多方面,例如渲染、事件路由、查找资源(如果元素没有逻辑父节点)等等。遍历视觉树可以很简单,只需使用 VisualTreeHelper 和一些简单的递归方法即可。
但是,有一个小问题使得这有点复杂。任何继承自 ContentElement
的内容都可以出现在用户界面中,但实际上并不在视觉树中。WPF 会“假装”这些元素在视觉树中,以促进一致的事件路由,但这只是一种错觉。VisualTreeHelper
不适用于 ContentElement
对象,因为 ContentElement
不继承自 Visual
或 Visual3D
。以下是 Reflector 中看到的 Framework 中所有继承自 ContentElement
的类:

以下是文档解释 ContentElement
不在视觉树中的含义:
内容元素(
ContentElement
的派生类)不是视觉树的一部分;它们不继承自Visual
并且没有视觉表示。为了出现在 UI 中,ContentElement
必须托管在一个是Visual
的内容主机中,通常是FrameworkElement
。您可以将内容主机概念化为内容的“浏览器”,并选择如何在主机控制的屏幕区域内显示该内容。一旦内容被托管,内容就可以参与某些通常与视觉树相关的树进程。通常,FrameworkElement
主机类包含实现代码,通过内容逻辑树的子节点将任何托管的ContentElement
添加到事件路由中,即使被托管的内容不是真正的视觉树的一部分。这是必需的,以便ContentElement
可以生成一个路由到除自身以外的任何元素的路由事件。
那么这一切意味着什么?这意味着您不能总是只使用 VisualTreeHelper
来遍历视觉树。如果您将 ContentElement
传递给 VisualTreeHelper
的 GetParent
或 GetChild
方法,将会抛出异常,因为 ContentElement
不继承自 Visual
或 Visual3D
。为了向上遍历视觉树,您需要检查沿途的每个元素,看看它是否继承自 Visual
或 Visual3D
,如果不是,那么您必须暂时向上遍历逻辑树,直到遇到另一个视觉对象。例如,这是遍历到视觉树根元素的一些代码:
DependencyObject FindVisualTreeRoot(DependencyObject initial)
{
DependencyObject current = initial;
DependencyObject result = initial;
while (current != null)
{
result = current;
if (current is Visual || current is Visual3D)
{
current = VisualTreeHelper.GetParent(current);
}
else
{
// If we're in Logical Land then we must walk
// up the logical tree until we find a
// Visual/Visual3D to get us back to Visual Land.
current = LogicalTreeHelper.GetParent(current);
}
}
return result;
}
此代码在必要时向上遍历逻辑树,如 else
子句所示。如果用户单击 TextBlock
内的 Run
元素,并且在您的代码中需要从该 Run
开始向上遍历视觉树,那么这将非常有用。由于 Run
类继承自 ContentElement
,因此 Run
并不是“真正”在视觉树中,所以我们需要向上遍历“逻辑之地”,直到遇到包含 Run
的 TextBlock
。到那时,我们将回到“视觉之地”,因为 TextBlock
不是 ContentElement
子类(即,它是视觉树的真实部分)。
逻辑树
逻辑树表示 UI 的基本结构。它与您在 XAML 中声明的元素非常匹配,并且排除了内部创建的大多数视觉元素,以帮助渲染您声明的元素。WPF 使用逻辑树来确定许多内容,包括依赖属性值继承、资源解析等等。
处理逻辑树不像处理视觉树那样清晰。首先,逻辑树可以包含任何类型的对象。这与视觉树不同,视觉树只包含 DependencyObject
子类的实例。在处理逻辑树时,您必须牢记树的叶节点(终结点)可以是任何类型。由于 LogicalTreeHelper 只处理 DependencyObject
子类,因此您在向下遍历树时需要小心类型检查对象。例如:
void WalkDownLogicalTree(object current)
{
DoSomethingWithObjectInLogicalTree(current);
// The logical tree can contain any type of object, not just
// instances of DependencyObject subclasses. LogicalTreeHelper
// only works with DependencyObject subclasses, so we must be
// sure that we do not pass it an object of the wrong type.
DependencyObject depObj = current as DependencyObject;
if (depObj != null)
foreach(object logicalChild in LogicalTreeHelper.GetChildren(depObj))
WalkDownLogicalTree(logicalChild);
}
给定的 Window
/Page
/Control
会有一个视觉树,但可以包含任意数量的逻辑树。这些逻辑树之间没有连接,所以您不能只使用 LogicalTreeHelper
在它们之间导航。在本文中,我将顶级控件的逻辑树称为“主逻辑树”,并将其中所有其他逻辑树称为“逻辑孤岛”。逻辑孤岛实际上就是普通的逻辑树,但我认为“孤岛”这个词有助于传达它们与主逻辑树未连接的事实。
所有这些怪异性都可以归结为一个词:模板。
控件和数据对象没有固有的视觉外观;相反,它们依赖模板来解释它们应该如何渲染。模板就像一个“饼干模具”,可以被“展开”以创建用于渲染事物的真实活动视觉元素。构成展开模板一部分的元素(以下称为“模板元素”)形成自己的逻辑树,该逻辑树与创建它们的对象的逻辑树断开连接。这些小的逻辑树就是我在本文中称为“逻辑孤岛”的内容。
如果您需要跳转到逻辑孤岛/树之间,则必须编写额外的代码。遍历逻辑树时,桥接这些逻辑孤岛涉及利用 FrameworkElement
或 FrameworkContentElement
的 TemplatedParent
属性。TemplatedParent
返回应用了模板的元素,因此包含一个逻辑孤岛。这是一个查找任何元素 TemplatedParent
的方法:
DependencyObject GetTemplatedParent(DependencyObject depObj)
{
FrameworkElement fe = depObj as FrameworkElement;
FrameworkContentElement fce = depObj as FrameworkContentElement;
DependencyObject result;
if (fe != null)
result = fe.TemplatedParent;
else if (fce != null)
result = fce.TemplatedParent;
else
result = null;
return result;
}
向下遍历逻辑树并从一棵树跳到另一棵树更加困难,因为没有 TemplatedChild
属性。您需要检查一个逻辑树末尾元素的视觉子元素,看看这些子元素(或它们的后代)是否属于另一个逻辑树。这段代码留给读者自行创建。
研究工具
本文附带一个小型控制台应用程序,允许您试验和研究元素树。它会打开一个 WPF Window
,并为用户单击的任何元素将视觉树或逻辑树写入控制台窗口。如何使用它的说明出现在 Window
中,所以让我们来看看它的外观以及它为我们提供了哪些信息。
启动应用程序时,它看起来像这样:

最大化控制台窗口并将 WPF Window
移到其上方后,我按住 Ctrl 并单击 Window
中间的 Button
(但不是单击显示文本)。此时,应用程序转储了单击的 ButtonChrome
元素的逻辑树,看起来像这样:

请注意,[YOU CLICKED HERE] 文本出现在控制台窗口中代表 Button
的那一行,而不是我实际单击的 ButtonChrome
元素的行。这是因为此逻辑树不关心也不包含 ButtonChrome
元素,它只是 Button
默认控件模板创建的渲染产物。
由于 Button
是一个 ContentControl
,它的视觉树中还包含一个 ContentPresenter
。ContentPresenter
是托管和显示 Button
中内容的内容,即 string "Clear the console window"
。该 string
通过展开一个显示文本的简单数据模板来渲染,该模板使用 TextBlock
元素。
如果我按住 Ctrl 并单击文本本身,即单击 TextBlock
元素,控制台窗口会显示此内容:

请注意,逻辑树现在有很大不同。它比以前小了很多;根是 ButtonChrome
而不是 Button
;终结点是 ContentPresenter
而不是 string
。发生此变化的原因是我们现在正在查看一个逻辑孤岛。这个逻辑孤岛是为了显示 Button
内容而创建的模板元素的逻辑树。
如果我们按住 Ctrl 并右键单击 ButtonChrome
(或任何其他地方),控制台窗口将显示整个视觉树并指示我们单击了哪个元素。它看起来像这样:

显然,这里看到的视觉树比之前看到的逻辑树大得多。有趣的是,视觉树包含了之前检查的 Button
所涉及的所有视觉元素。它不关心元素是否来自模板,这使得它更容易理解。
结论
乍一看,WPF 中的元素树似乎非常直观。然而,经过仔细检查,就会发现它们并不那么简单。对于大多数 WPF 编程任务,熟悉这些细节并不重要,但对于一些更高级的场景,这些细节就变得至关重要。希望本文能帮助您了解这些晦涩的细节。
修订历史
- 2007 年 11 月 30 日 - 创建了文章
- 2007 年 12 月 5 日 - 在 CodeProject 网站升级损坏了文章链接后修复了链接