65.9K
CodeProject 正在变化。 阅读更多。
Home

调整 TreeView - 第一部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (14投票s)

2010年2月25日

CPOL

16分钟阅读

viewsIcon

64757

downloadIcon

1792

优化的 ViewModel 类提高了 TreeView 的更新和效率。

引言

最近,在一个较大的项目作为一部分,我偶然深入研究了 WPF 的 TreeView 控件。我遵循了我认为是标准的开发流程:阅读了一些 WPF 书籍中的章节,然后上网寻找代码。随着我越来越深入地了解我的特定需求和实现,我遇到了一些其他人也在苦苦挣扎的棘手问题。其中一些问题只需要一个“顿悟”的时刻或在线提示就可以解决,而另一些问题则让我迷失了几天。最终,代码变得越来越小、越来越简单,我决定将我学到的东西回馈给社区会很有用。这是两篇介绍我如何按照我想要的方式更新、排序和惰性加载 TreeView 的文章中的第一篇。本系列的第二部分可以在 这里 找到。

问题

我的应用程序是一个用于分析磁盘驱动器空间利用率的工具。我使用 HierarchicalDataTemplateTreeView 来显示目录结构,还有两个额外的需求:

  • 对文件夹进行颜色编码,以便“热点”一目了然:红色代表较大的“热”使用区域;蓝色代表“冷”或相对较小的区域,等等。
  • 提供排序和过滤工具,以便轻松定位问题区域。在下面的截图中,顶层 (Drive1) 按字母顺序排序,而第二层 (DirEe116) 按大小排序。
screenshot

另外,虽然这实际上是一个隐式的用户需求,但我需要树能够处理更新,例如添加新目录、删除目录、更改大小等。我查看的许多 TreeView 示例都只是显示,我最终花了大量时间才让更新生效。我还应该指出,我没有考虑使用第三方组件或 GUI 生成器 - WPF TreeView 就是我想要的。

WPF TreeView 控件

当我开始搜索可以借鉴的代码时,我在 Code Project 上发现了一些有用的目录树示例。Sacha Barber 的 简单的 WPF Explorer Tree 是一个很好的、直接的例子,说明了我需要做什么。然后我偶然发现了 Josh Smith 的精彩文章 通过使用 ViewModel 模式简化 WPF TreeView。Josh 的工作非常有帮助,我在这里做的很多事情都源于他的方法。

在许多 WPF 文章中一开始就强调的一点是使用模型-视图-视图模型 (MVVM) 模式的重要性。我使用各种商业框架和库的经验告诉我,最好只在应用程序的特定层与它们进行交互,如下所示:

screenshot

"视图"是 WPF 框架,并被视为一个黑匣子。模型是应用程序域数据模型和逻辑,不应该依赖于 (或了解) WPF。"视图/模型"是一个适配器层,用于连接其他两个。这种方法使应用程序侧更干净、更专注,并提供了至少一些将应用程序移植到不同框架的灵活性。

TreeNode 类

本文的其余部分将介绍 TreeNode 类,即 WPF TreeViewItems (TVI) 的 ViewModel 适配器。换句话说,所有通过 HierarchicalDataTemplate 绑定到 ItemsSource 的对象都派生自 TreeNodeTreeNode 有六项职责:

  1. 作为显示在 TreeView 中的所有域对象的 abstract 基类。
  2. 通过维护子节点集合和父节点的引用来表示树层次结构。此子节点集合服务于两个目的:绑定到它的 HierarchicalDataTemplate,以及各种树遍历算法。
  3. 提供“IsExpanded”属性,该属性绑定到相应的 TVI 属性 (有时也包括选择,但我将其移到了一个单独的类)。
  4. IsExpanded 属性以及派生类中的任何属性提供 INotifyPropertyChanged 的实现。
  5. 提供删除单个节点或整个子树的方法。
  6. 实现延迟或“惰性”加载,以便在用户请求之前不创建节点的子节点。

我实际上没有单独的应用程序数据模型 (除了 TreeNode 的子类),所以对我来说,最方便的是直接将树层次结构存储在 ViewModel 中。如果我从现有的应用程序数据结构中获取层次结构,那么我可能需要一个不同的 ViewModel。以下各节将逐步介绍 TreeNode 类的实现。

树层次结构:父节点和子节点

TreeNode 的第一部分代表了树结构:

// Alias for a generic list of TreeNodes
public class TreeNodeCol : ObservableCollection { }

public abstract class TreeNode : INotifyPropertyChanged, IEditableObject
{
   // my parent in the tree, or null if this is a root node
   private readonly TreeNode parent;
   public TreeNode Parent { get { return parent; } }

   // List of my children.
   private TreeNodeCol children;
   public virtual TreeNodeCol Children { get { return children; } }
   public int ChildrenCount { get { return Children.Count; } } //helper

   // Property that the HierarchicalDataTemplate ItemsSource binds to
   private ListCollectionView view;
   public  ListCollectionView ChildrenView { get { return view; } }

这对于 ViewModel 类来说相当典型。ObservableCollection 是集合的推荐选择,而集合是绑定目标。INotifyPropertyChanged 是必需的,以便 UI 可以更新。

在这个设计中,我做的一件事是将惰性加载的支持移到了一个子类;这就是为什么 Children 属性是虚拟的。此外,我在此类中显式创建了 CollectionView,原因有三个:

  • 以便以后在底层集合更改时重新创建它
  • 为单个项集合设置不同的排序和过滤
  • 向 WPF 提供提示,使其知道需要更新 TreeViewIEditableObject 出于相同的原因是必需的。

这些问题将在下文详细探讨。

在我查看的大多数 TreeView 示例中,以及在我早期的实现中,子集合都是在构造函数中分配的。看起来 ItemSourceChildren 的绑定锁定集合,以至于任何稍后尝试重新分配子集合引用的尝试都会显示旧结果或抛出异常。

一旦我开始显式创建 ListCollectionView,原因就变得更清楚了。WPF 集合视图持有对子集合的引用,因此您不能只在其下方交换另一个集合。这是非常不幸的,因为为每个 TreeNode 创建子集合的效率非常低。如果您想象一个有 200 个节点的树,可能只有 10 到 20 个被展开,而许多其他节点只是没有子节点的叶子。因此,大约百分之九十的子集合对象被分配但从未使用过。

但后来我意识到,ItemsSource 绑定的对象 (在此例中为 ChildrenView) 只是另一个属性,如果您通知 WPF,您可以更改它。这对您来说可能很明显,但对我来说却是个真正的启示!这意味着 TreeNode 可以有一个共享的空列表,所有节点都共享直到并除非它们需要自己的子集合,如下所示:

   // Empty list and view shared by all nodes that don't have children
   private static readonly TreeNodeCol emptyChildren;
   private static readonly ListCollectionView emptyView;
   static TreeNode()
   {
      emptyChildren = new TreeNodeCol();
      emptyView     = new ListCollectionView(emptyChildren);
   }

延迟子集合的构造直到需要它需要一些额外的逻辑,我将其放入两个“Condition”函数中。ConditionalSentinalChildren 将我们置于初始状态,使用共享的空列表;大多数树节点将保持此状态。

   // Initialize or reinitialize the children list and view
   public virtual void ConditionSentinalChildren(bool notify)
   {
      children = emptyChildren;
      view     = emptyView;
      if (notify)
         RaisePropertyChanged("ChildrenView");
   }

如果节点确实有子节点,我们调用 ConditionActualChildren 在其尚未创建时创建其唯一的子集合。

   // Prepare the children collection and view because we now need
   // to add the children
   // expanding = true  - user requested expand - load the children
   //           = false - programmatic expand only
   protected virtual void ConditionActualChildren(bool expanding)
   {
      if (children == emptyChildren)
      {
         children = new TreeNodeCol();
         view     = new ListCollectionView(children);
         OnCreateView();
         RaisePropertyChanged("ChildrenView");
      }
   }

这些函数是虚拟的,因为处理惰性加载的子类将需要进行一些修复。它们从构造函数中调用,如下所示:

   // Create a node and hook it up to its parent
   protected TreeNode(TreeNode parent)
   {
      ConditionSentinalChildren(false);

      // hook up tree structure such that I know my parent, and
      // I'm a child of my parent.
      this.parent = parent;
      if (parent != null)
      {
         parent.ConditionActualChildren(false);
         parent.children.Add(this);
      }
   }

这只是连接了树结构。ConditionActualChildren 在惰性加载期间也会被调用,以确保特定节点有一个唯一的集合来添加其子节点。但我在这里不处理惰性加载:所有这些逻辑只是为了避免分配不必要的集合,这是一件好事。

此外,我想指出我在构造函数中调用了一个虚拟函数。这被认为是不好的做法,因为我们在对象完全构建之前在运行时选择一个方法。例如,Visual Studio 代码分析器 (那个喋喋不休的家伙) 会报告 2214 警告并说“修复它”。在这种情况下它是安全的,因为重写的函数与 TreeNode 密切协调,所以我保持了这种方式。

排序和 UI 更新:IEditableObject 和 ListCollectionView

数据绑定是一个非常好的想法。您定义了数据与 UI 系统之间的关系,绑定引擎会在两者之间传播更改。但有时它不会按预期工作,要么抛出异常,要么不更新,这就是我遇到的问题。

在我实现了可用的 TreeNode 后,我使用 System.IO 调用来查询硬盘目录结构,从而填充了 TreeViewDirectoryInfo.GetDirectories() 会礼貌地按字母顺序返回目录,所以我没有意识到我的树并没有真正排序。一旦我实现了重命名、删除或添加目录的测试功能 (例如,模拟来自 FileWatcher 的更新通知),这个问题就变得清晰了。

在搜索在线资源和重读我的 WPF 书籍后,我意识到我需要告诉 ListCollectionView 要排序的内容。我尝试的第一种方法是在 XAML 中创建 SortDescripton,并使用 CollectionViewSource 将它们挂接到视图。要做到这一点,ItemsSource 首先绑定到视图,然后视图绑定到子集合。但是每个树节点下都有不同的集合,所以这种方法会失败 (请参阅 Bea Stollnitz 的文章 这里 以及响应号五下的讨论,其中详细解释了这一点)。

有一段时间,我使用 CollectionView.GetDefaultView 来获取 WPF 创建的视图以附加排序描述。一旦我接管了 CollectionView 的创建和重新创建,我就不得不改变我的排序和过滤方式。这些操作都在 CollectionView 上完成,并且每次它更改时都必须重新执行。所以我定义了一个名为 OnCreateView 的虚拟通知函数。

   // Signal that we are creating the children list and view for this node
   //   - override to set up any needed sorting, grouping, or filtering
   protected virtual void OnCreateView() { }

TreeNode 调用 OnCreateView 来通知其派生类何时重新创建 CollectionView。派生类重写 OnCreateView 以挂接适当的排序和过滤,如下所示:

   protected override void OnCreateView()
   {
      ChildrenView.SortDescriptions.Add( new SortDescription( ... ) );
   }

好吧,这被证明是必要的,但不足以解决问题:仍然有几次树没有排序。在我搜索博客时,我读到了一些关于 TreeView 没有响应模型更改而更新的抱怨。这令人沮丧,因为我认为数据绑定的全部意义就是将这个更新问题从我们手中解脱出来。事实证明,TreeView 需要的是更多的通知。

我在 Dr. WPF 的帖子 ItemsControl: 'E' 代表可编辑集合 (他是一位真正的医生吗?) 中找到了解释。除了实现所有书籍都提到的 INotifyPropertyChanged 之外,您还必须实现 IEditableObject,而后者却没有提到。如果您的对象实际上不是事务性的,您只需要用 IEditableObject 标记您的类并提供空实现,就像我在 TreeNode 中那样。

public abstract class TreeNode : INotifyPropertyChanged, IEditableObject
   . . .
   public void BeginEdit() { }
   public void CancelEdit() { }
   public void EndEdit() { }

然后,当一个 TreeNode 对象报告属性更改时,它还会通知包含它的父级的视图。

     PropertyChanged(this, new PropertyChangedEventArgs(prop));
     if (parent != null)
     {
        parent.view.EditItem(this);
        parent.view.CommitEdit();
     }

即使进行了这些更改,我也必须在 TreeNode 展开时调用 view.Refresh() 来触发排序,但这是我需要的唯一一次刷新调用。

好消息是树现在可以更新了,但坏消息是我现在有了一个绑定错误!WPF 报告了关于 Horizontal 和 Vertical Content Alignment 的绑定源的 Data Error 4,这让我感到困惑,因为我从未引用过这些属性。但我现在不能轻易放弃,所以我将这个内容 (在两个地方) 添加到 XAML 中来解决这个问题:

    <!-- Following style needed to avoid binding error during sort -->
    <Style TargetType="{x:Type TreeViewItem}">
        <Setter Property="HorizontalContentAlignment" Value="Left" />
        <Setter Property="VerticalContentAlignment" Value="Center" />
    </Style>

说真的,有时候我觉得 WPF 只是在玩弄我——它真的有这么难吗?但有了这些更改,TreeView 始终保持着非常好的更新状态。它排序,它删除,它切割和切块。我了解到这在某种程度上滥用了 IEditableObject,并且 WPF 的未来版本可能会提供更好的解决方案。但在此期间,这正是我想要的解决方法。

IsExpanded、INotifyPropertyChanged 和 Delete

到目前为止,我已经描述了 TreeNode 如何实现第一个和第二个需求 (abstract 基类和层次结构),并顺带介绍了视图管理和更新。接下来的两个需求的代码,IsExpanded 属性和 INotifyPropertyChanged 实现,几乎直接从 Josh Smith 的代码中提取,除了延迟加载的更改;请参阅源代码和下一节。

我发现第五个需求 (删除) 最好用两个函数处理,一个删除节点 (自身删除),另一个删除节点的子节点。这两个函数都调用这个递归辅助函数:

   private void DeleteSubtree(bool destruct)
   {
      if (destruct)
         OnDestruct();     // derived class cleanup, if any

      foreach (TreeNode kid in Children)
         kid.DeleteSubtree(true);

      view = null;
      children = null;
   }

OnDestruct 是另一个虚拟通知函数,它让派生类有机会释放资源。另外,foreach 循环是使用 Children 集合执行树遍历的一个例子。

延迟加载

在许多情况下,将树 (或列表) 控件填充所有可能的数据是不切实际的,因此使用基于需求的方法。这成为一个 ViewModel (或 WPF 适配器) 问题,因为如果 TreeViewItem 找不到任何子节点,WPF 将不会显示其 '+' 扩展器,因此用户无法访问下一级。

这是一个如此常见的需求,以至于 TreeView 不支持它真是令人惊讶。我怀疑 Microsoft 可能会在未来添加此功能,但在此期间,有两种常用的方法来实现延迟加载:

  • 将惰性加载逻辑和属性放入派生自 TreeView 的新类中。
  • 向子集合添加一个虚拟占位符对象,以欺骗 TreeView 始终显示扩展器,然后稍后删除占位符。

对我来说,对 TreeView 进行子类化似乎是过度,所以我采用了第二种方法。同时,我不愿意在 TreeNode 类中添加更多代码,因为它已经足够复杂了。因此,我最终将惰性加载功能放入了一个名为 TreeNodeLLTreeNode 子类中。

这个解决方案有几个有趣的方面 (参见下面的列表)。首先,我将 TreeNodeLL 声明为 TreeNode 的内部类,因为它需要直接访问层次结构状态变量。另外,TreePlaceholderChild,占位符对象的类,是 TreeNodeLL 的内部类。这只是为了限制其可见性,但由于 TreeNode 中的子集合是强类型的,因此占位符类必须派生自它。

// Deferred Loading support
// - Adds a a dummy or placeholder object during construction
//   ChildrenView will include the placeholder
//
public abstract class TreeNodeLL : TreeNode
{
   // Dummy child added to the sentinel children list
   private class TreePlaceholderChild : TreeNodeLL { }
   private TreeNodeLL() { }

   private static readonly TreeNodeCol placeholderList;
   private static readonly ListCollectionView placeholderView;
   static TreeNodeLL()
   {
      placeholderList = new TreeNodeCol();
      placeholderList.Add(new TreePlaceholderChild());
      placeholderView = new ListCollectionView(placeholderList);
   }

   public override TreeNodeCol Children
   { get { return children == placeholderList ? emptyChildren : children; } }

在绑定后交换子集合的能力在这里也非常重要。而不是一直添加和删除占位符对象,static 构造函数设置了一个占位符列表和视图,然后所有没有子节点的树节点都共享它们。

您还可以看到 TreeNodeLL 重写了 Children 属性以返回一个空列表而不是占位符列表。这确保了对没有子节点的节点的树遍历会得到一个空列表。

引用占位符集合并在需要时创建新集合的逻辑位于两个函数重写 ConditionSentinalChildrenConditionActualChildren 中。这些函数与 TreeNode 中的相应函数非常相似。

TreeNode.TreeNodeLL 的这个实现满足了我们的第六个也是最后一个需求,但像这样派生自内部类有点丑陋,所以我添加了这个语法糖,使其更自然:

public abstract class TreeNodeLazy : TreeNode.TreeNodeLL
{
   protected TreeNodeLazy(TreeNode parent)
      : base(parent) { }
}

TreeNodeTreeNodeLL 是根据设计紧密耦合的类。我更希望 TreeNodeLL 是一个获得 TreeNode "friend" 访问权限的独立类,但 C# 不支持 friend 访问。另外,您总是可以将惰性加载逻辑合并回 TreeNode 中,以将其减少到单个类。

但是,通过两个类,您可以选择是通过哪个类,TreeNodeTreeNodeLazy,来继承以启用延迟加载。而且,这提供了在同一个树中混合两种策略的有趣可能性,我将其留作 (嗯) 一个给读者的练习。

一条死胡同

如前所述,当我第一次在 TreeView 中显示目录层次结构时,我以为我完成了。后来,当我开始研究如何让它响应底层文件系统的更改而更新时,我遇到了一个障碍。这在我发现 IEditableObject 或如何操作视图之前。那时,对 TreeView 的添加总是显示在列表的末尾而不是按排序位置,而其他一些更新根本不起作用。

我不记得我的确切推理,但出于绝望,我最终决定层次数据绑定有问题 (当时已经很晚了)。该死的,如果树不会更新,我就要彻底地处理它。所以我改用了简单数据绑定,并引入了一个派生自 TreeNode 的新类来管理 TreeViewItems 中的 Items 集合。

起初看起来很容易:在我的 TreeNode 和 WPF 的 TreeViewItem 之间维护一个链接,并将我的子项复制到 Item 中,与 ItemsSource 不同,您可以从代码中操作它。这涉及使用 ItemContainerGenerator 来查找 TreeViewItem,并且由于 WPF 会在奇怪的时候创建它们,您必须监听状态更改事件。这样做的一个好处是,除了对 TreeView 有更深入的了解之外,占位符可以是一个添加到 Items 中的简单对象;我不必用它来污染子集合。

但这只是杯水车薪。即使多次调用 Refresh,排序仍然不正确,而且我的头也很痛,所以我回到了 Josh 文章中的这个明智的建议:

如果您发现自己正在挂接 ItemContainerGeneratorStatusChanged 事件以便在 TreeViewItem 的子项最终创建时访问它们,那么您就走错了方向!相信我;不必如此丑陋和困难。有更好的方法!

阿门,兄弟,我吸取了教训。实际上,我第一次读到这句话时根本不理解 Josh 在说什么。现在,不幸的是,我懂了。但这个失败的实验促使我回到基本,让一个精简的示例工作,并写一篇关于它的文章。我最终得到的、经过大量演变的 TreeNode 类确实有点丑陋且不直观,但毕竟,适配器类的全部目的是隔离这种类型的底层工作。这两个 TreeNode 类最终大约有 250 行代码,足以满足我们所有的六项要求。

下次

在《调整 TreeView - 第二部分》中,我将展示如何以 TreeNode 为基础构建一个小示例应用程序。我已将此示例项目包含在此处,以便您在上下文中查看 TreeNode。该示例实现了我在问题部分描述的排序,以及延迟加载和 CRUD 操作。

我犯的最大错误之一是没有尽早实现四项 CRUD 操作——Create (创建)、Read (读取)、Update (更新) 和 Delete (删除)。这些基本操作以及排序将立即暴露您在实现中遇到的任何问题。对我来说,让它们正常工作并不容易,但完成它们为构建奠定了坚实的基础。

我真心希望这些对其他 WPF 程序员有用。没有 CodeProject 这样出色的在线代码存储库,我甚至无法在 WPF 上入门。我在准备这篇文章的过程中解决了一些实现问题,因此为 CodeProject 写作实际上改进了我的设计。请告诉我您的想法,特别是如果您有更好的解决方案。

历史

  • 2010年2月24日 - 初始版本
© . All rights reserved.