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

高级 WPF TreeViews(第 2 部分)

starIconstarIconstarIconstarIconstarIcon

5.00/5 (11投票s)

2017年9月18日

CPOL

18分钟阅读

viewsIcon

41108

downloadIcon

2227

关于虚拟化 WPF TreeViews 的高级技巧与窍门列表

引言

在本文中,我们将探讨高级WPF TreeView实现的各个方面。我们将运用此处学到的经验,通过[10]实现高效的大型数据集Treeview应用程序。本研究需要理解第一部分[1]中解释的基础知识。

背景

本文的背景与[1]中的背景相似,只是我们专注于在WPF Treeview中高效处理大型数据结构。因此,本文将补全我们之前开始勾勒的图景。

目录

虚拟化

虚拟化一词在WPF中指的是一种控件设计模式,它确保能够高效地显示和处理大量数据项。这种模式适用于不同类型的控件,例如列表视图、树视图,甚至画布控件。这些控件通常基于ItemsControl,可以用来显示和操作大量项。虽然ItemsControl[2]本身就可以被虚拟化,但我们在这里将专注于虚拟化 Treeview[9]的方面。

任何虚拟化的UI解决方案通常都是为用户希望查看和交互大量项目的用例而构建的。被认为是“大量”的确切项目数量取决于应用程序的类型(桌面、移动或 Web)和可用硬件(仅举两个重要因素)。挑战在于,我们需要显示的项目数量远超我们能在一次显示中容纳的数量,更不用说一次性存储在内存中,甚至存储在磁盘上了。那么,我们如何才能着手处理这种大型数据集呢?

TreeView 虚拟化

经典的(Web)虚拟化方法是告知用户查看搜索结果所需的分页数量,并提供一种从第1页导航到第n页的方法。这种方法在WPF中对用户来说更为隐含和不那么明显,因为系统不会为集合中的所有项目创建对象,而只为实际可见的项目创建对象。这既可以用于控件(视图项),也可以用于视图模型中的项。

系统“仅仅”创建实际在用户屏幕上可见的视图项(这被称为UI 虚拟化)。因此,当用户调整视图时(例如滚动),新可见的视图项会被创建,而旧的、不再可见的视图项会被丢弃。

所有其他(不可见)的视图项仅在它们的总数和在整个集合中的位置方面有意义。更高级的解决方案(数据虚拟化 - 此处不详述)将仅在相应的视图项可见时创建视图模型。跟踪这些信息并不容易,但比生成所有项目以便发现您需要一台内存空间和处理能力无限大的计算机要好得多。

TreeView 的UI 虚拟化很重要,因为它为我们提供了另一种应对性能或空间问题的选择[6][9]。但同样重要的是要了解和理解虚拟化在某些方面会改变 Treeview 的行为。因此,让我们通过两个附加示例来实际理解这一点。

SortableObservableDictionary_VirtualizationProblems.zip 中的解决方案与上一篇文章[1]中的解决方案基本相同,只是我们现在使用MainWindow.xaml中的此XAML对 Treeview 进行虚拟化。

<Style x:Key="TreeViewStyle" TargetType={x:Type TreeView}>
    <Setter Property="TreeView.Background" Value="Transparent"/>
    <Setter Property="VirtualizingStackPanel.IsVirtualizing" Value="True"/>
    <Setter Property="VirtualizingStackPanel.VirtualizationMode" Value="Recycling"/>
    <Setter Property="TreeView.SnapsToDevicePixels" Value="True" />
    <Setter Property="TreeView.OverridesDefaultStyle" Value="True" />
    <Setter Property="ItemsControl.ItemsPanel">
        <Setter.Value>
            <ItemsPanelTemplate>
                <VirtualizingStackPanel IsItemsHost="True"/>
            </ItemsPanelTemplate>
        </Setter.Value>
    </Setter>
    <Setter Property="TreeView.Template">
        <Setter.Value>
            <ControlTemplate TargetType="TreeView">
                <ScrollViewer Focusable="False" CanContentScroll="True" Padding="3">
                    <ItemsPresenter HorizontalAlignment="Stretch"/>
                </ScrollViewer>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>
<TreeView Grid.Row="1" DataContext="{Binding GitHub}"
  ItemsSource="{Binding Root}"
  behav:TreeViewSelectionChangedBehavior.ChangedCommand="{Binding SelectedItemChangedCommand}"
  
  VirtualizingStackPanel.IsVirtualizing="True"
  VirtualizingStackPanel.VirtualizationMode="Recycling"
  Style="{StaticResource TreeViewStyle}"
>...

现在,当我们下载并尝试SortableObservableDictionary_VirtualizationProblems.zip解决方案时,我们会发现重命名/排序行为已损坏,因为当前选中的(已重命名)项不再保留在 Treeview 的视口中。

1) 选择其中一个顶级项,使用文本框重命名它,然后单击重命名(按钮)。
2) 重命名的项已更新并重新定位到当前滚动区域之外。
3) 只有手动向下滚动到重命名的项才能向用户显示其当前位置。

现在的行为是,已更改的项仍然移动到正确的位置(因为可观察集合仍然知道它属于哪里),但 UI 不再跟随其新位置。这是因为“显示到视图”行为(TreeViewItemBehaviour)仅适用于实际存在的视图项(控件)。但是,一个虚拟化的视图项只有在它显示在当前显示区域或接近该区域时才存在。

WPF 框架能够通过提供可用于强制基于视图模型创建视图项的函数[4]来解决此场景。我们需要用于实例化缺失视图项的方法是

TreeViewItem item.ItemContainerGenerator.ContainerFromIndex(index)

然后,当上述方法使项“活”起来时,我们就可以将其显示到视图中。但该函数深埋在 UI 的深处。那么,如何在保持 MVVM 的同时让它正常工作呢?让我们回顾一下SortableObservableDictionary_Virtualized.zip解决方案中的重命名过程,以了解如何协调这一点。

重命名过程仍然从GitHubViewModel类中的ICommand重命名选定项命令属性开始。

if (p is string == false)
    return;

var param = p as string;

if (SelectedItem != null)
    SelectedItem.Name = param;

if (CurrentlySelectedItem.Equals(default(KeyValuePair<string, GitHubItemViewModel>)) == false)
{
    CurrentlySelectedItem.Value.IsItemSelected = false;

    _rootItem.Children.Remove(CurrentlySelectedItem.Key);

    var newItem = (GitHubItemViewModel)CurrentlySelectedItem.Value.Clone();
    newItem.Name = param;

    _rootItem.Children.Add(param, newItem);

    object[] path = new object[2];
    path[0] = _Root.First();
    path[1] = _rootItem.Children.Where(kv => kv.Key == param).First();

    this.SelectPathItem = path;

    newItem.IsItemSelected = true;
}

这里值得注意的是从:object[] path = new object[2];行开始的最新添加。此添加创建了一个包含2个项的数组,其中包含我们想要浏览的所有路径项。由于我们的示例树只有2个级别,因此路径的深度始终为2。path[1]后面的语句看起来很奇怪,因为我们确实需要获取绑定到 TreeViewItem 的KeyValuePair(而不是 Key 或 Value) - 如果您采用第一部分[1]中 Dr. WPF 的解决方案,则需要为此支付一点复杂性代价。

因此,GitHubViewModel类中的SelectPathItem属性最终包含(有点傻的)2个项的完整路径,指向我们希望显示到视图的项。

public object[] SelectPathItem
{
    get
    {
        return _SelectPathItem;
    }

    set
    {
        if (value != _SelectPathItem)
        {
            _SelectPathItem = value;
            this.NotifyPropertyChanged(() => SelectPathItem);
        }
    }
}

MainWindow.xaml文件包含一个绑定到BringVirtualTreeViewItemIntoViewBehavior的属性。

<i:Interaction.Behaviors>
    <behav:BringVirtualTreeViewItemIntoViewBehavior SelectedItem="{Binding SelectPathItem}" />
</i:Interaction.Behaviors>

因此,查看BringVirtualTreeViewItemIntoViewBehavior行为的代码,我们现在可以看到SelectPathItem属性的变化如何触发OnSelectedItemChanged方法的执行,而该方法又包含了一种生成所需 UI 项的方法,以防在我们需要显示它们时它们不可用。

for (int i = 0; i < newNode.Length; i++)
{
    var node = newNode[i];

    var newParent = currentParent.ItemContainerGenerator.ContainerFromItem(node) as TreeViewItem;
    if (newParent == null)
    {
        currentParent.ApplyTemplate();
        var itemsPresenter = (ItemsPresenter)currentParent.Template.FindName("ItemsHost", currentParent);
        if (itemsPresenter != null)
            itemsPresenter.ApplyTemplate();
        else
            currentParent.UpdateLayout();

        var virtualizingPanel = GetItemsHost(currentParent) as VirtualizingPanel;

        CallEnsureGenerator(virtualizingPanel);
        var index = currentParent.Items.IndexOf(node);
        if (index < 0)
            throw new InvalidOperationException("Node '" + node + "' cannot be fount in container");

        virtualizingPanel.BringIndexIntoViewPublic(index);
        newParent = currentParent.ItemContainerGenerator.ContainerFromIndex(index) as TreeViewItem;
    }

    if (newParent == null)
        throw new InvalidOperationException("Tree view item cannot be found.");

    if (node == newNode[newNode.Length-1])
    {
        newParent.IsSelected = true;
        newParent.BringIntoView();
        break;
    }

    if (i < newNode.Length-1)
        newParent.IsExpanded = true;

    currentParent = newParent;
}

上面的示例有点傻,因为我们有一个只有2个级别的树,却使用了虚拟 Treeview :-) - 尽管如此,这项练习还是值得的,因为它教给了我们一个概念,我们可以自信地将其用于更深的树,正如我们将在下一个示例LazyLoading_VirtualizedTreeViewDemo解决方案中看到的。

 

还值得注意的是,路径项数组并不是可以将请求的项显示到虚拟 Treeview 视图中的唯一结构。例如,替代解决方案可以是一个支持本文第一部分[1]中讨论的Parent属性的选定项。

一个绑定的行为可以构建自己的路径项数组,因为通过Parent属性可以浏览到根(如框架2-6所示)。然后它可以生成所有路径项(框架6-12的右侧),并像现在一样运行。

参考[9]获取有关虚拟化 Treeview 和优化 Treeview 性能的更多背景信息。

重要的是要理解,虚拟化是以更复杂或有时甚至缺失的功能为代价的。因此,我们必须有意识地决定我们的性能问题是否严重到我们必须进行虚拟化,还是我们宁愿使用具有高级功能和 UI 体验的普通树。

TreeView 控件的延迟加载

我们知道可以通过虚拟化 Treeview来改进计算机的资源使用。在这种情况下,一个常用的策略是延迟加载项[8],这基本上意味着项仅在用户显式请求时才加载。要做到这一点,每个项最初都显示一个展开器。用户可以通过单击其展开器来发现一个项是否确实有子项。

那么,如果该项碰巧没有子项(见上面的序列),行为就是移除展开器,或者切换展开器以显示先前不可见的子项。

即使给定的项没有子项可供查看,展开器也会显示。这是因为确定是否有子项可能是一项非常耗时的任务,因为:

  • 要评估的子项数量非常多,或者
  • 数据源对于每个项的此查询响应速度很慢(文件系统可能很慢,或者数据库服务器连接不太快等)。

上述行为被称为延迟加载,因为计算机需要额外的提示才能实际确定是否有子项,然后才实际显示它。最大的好处是,不可见的视图模型和视图项不会浪费本地内存空间和显示性能。

因此,让我们来看看LazyLoading_VirtualizedTreeViewDemo解决方案中实际实现的细节。该解决方案的虚拟化示例 Treeview 定义在FolderBrowserLib项目中。其视图模型如下所示:

TreeView 中的每个项都基于TreeViewItemViewModel,并且每次构造 Treeview 项时,它还会构造其基类,而基类又默认构造一个所谓的虚拟子项

也就是说,ComputerViewModel类(为例)的构造函数调用其在TreeViewItemViewModel中的基类构造函数,该构造函数又调用其ResetChildren方法,该方法默认引用一个虚拟子项实例。

protected virtual void ResetChildren(bool lazyLoadChildren)
{
    _children.Clear();

    if (lazyLoadChildren == true)
        _children.Add(DummyChild);
}

我们可以在TreeViewItemViewModel部分看到,一个虚拟子项只是一个所有人默认引用的静态对象实例。

static readonly IFolderBrowserViewModel DummyChild = new TreeViewItemViewModel();

这使得过程非常简单高效。而且,确定一个给定父项下方是真实子项还是只有一个虚拟子项,仅需一次比较,正如我们在HasDummyChild方法中看到的。

 

public virtual bool HasDummyChild
{
    get
    {
        if (this.Children != null)
        {
            if (this.Children.Count == 1)
            {
                if (this.Children[0] == DummyChild)
                    return true;
            }
        }

        return false;
    }
}

好的 - 现在我们知道了展开器默认是如何出现的,是时候理解当用户单击它时会发生什么了。为了实现对此的响应,我们在FolderBrowserTreeView的 XAML 中有一个附加行为TreeViewItemExpanded

<Style TargetType="{x:Type TreeViewItem}" BasedOn="{StaticResource {x:Type TreeViewItem}}">
    <Setter Property="behav:TreeViewItemExpanded.Command" Value="{Binding Path=Data.ExpandCommand, Source={StaticResource DataContextProxy}}" />
    <Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
    <Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
    <Setter Property="VerticalAlignment" Value="Center" />
    <Setter Property="VerticalContentAlignment" Value="Center" />
</Style>

TreeViewItemExpanded行为通过众所周知的 BindingProxy [1] 将项子项被展开的事实转换为DemoViewModel类中绑定的ExpandCommand,而DemoViewModel类恰好是托管 Tree 的根和其下方所有项的类。

_ExpandCommand = new RelayCommand<object>(async (p) =>
{
    var folder = p as IFolderBrowserViewModel;

    if (folder == null)
        return;

    await folder.LoadChildrenAsync();
});

展开命令确保我们具有正确的参数,并调用IFolderBrowserViewModel接口的LoadChildrenAsync方法,该接口适用于所有项视图模型:ComputerViewModelDriveViewModelFolderViewModel

public async new Task<int> LoadChildrenAsync()
{
    this.Children.Clear();
    var folderVMItems = await FolderViewModel.LoadSubFolderAsync(_Model.Path, this);

    foreach (var item in folderVMItems)
        base.Children.Add(item);

    return Children.Count;
}

因此,简单来说,延迟加载 Treeview 项就是每次要求显示真实父项时引用一个默认子项。只有当用户单击该父项的展开器时,我们才会真正评估该父项下方是否有子项。

既然我们可以通过延迟加载节省处理成本,接下来我们将重点关注浏览 Tree 结构,而这又是一个需要我们尽可能快速高效地加载 Treeview 项的用例,最好是,而不冻结用户界面。

高效加载和浏览节点

前面的章节研究了一些关于虚拟化和延迟加载的问题(让我们在处理大型结构化数据集时保持理智)。在本节中,我们将研究用户希望在显示的项结构中浏览到特定路径的用例的优化方法。如果我们要浏览到结构深处的某个位置,并且该位置沿途需要加载许多子项,那么此任务可能会产生特别大的 UI 开销。让我们考虑以下数据结构,我们希望通过 Tree 从根浏览到项Child 5

+-> Root
    +- Child 1
       |
       +- Child 2
          |
          +- Child 3
             |
             +-> Child 4
                 |
                 +-> Child 5
  浏览到项child 5的简单方法是:
  1. 确定一个当前父项(例如,根),该项已加载或正在加载。
    • 确保当前父项IsExpanded=true
    • 加载当前父项的所有子项。
    • IsSelected=true设置为当前子项(例如,Child 1),以将其显示到视图中。
    • 当前父项设置为当前子项(例如,Child 1),然后
  2. 逐个加载和展开子项,直到路径完成。

上述方法被广泛使用,并且只要视图比子项信息进来得更快,就没有太多缺点。但是,当您例如浏览文件系统,并且它恰好是在快速计算机和 SSD上,而图形子系统较慢时,情况就会发生变化。在这种情况下,您可能会看到UI 冻结一小段时间(例如一两秒)。您可能会想,为什么会这样,因为您已经进行了所有这些漂亮的async任务库编程,以确保 UI 与您的浏览算法无关,对吧?

这里的问题是,如果您立即附加每个新项在浏览到其深层目标的过程中操作绑定属性,那么 UI 线程就无法独立于算法。这种实现可能会导致大量的 UI 开销,因为 UI 线程会不断地重新渲染包含新信息但甚至不可见区域,因为更新可能以比100 ms更快的速率进入。

使用进度条在这里无济于事,因为进度视图会像 UI 的其余部分一样冻结

关于发生过多更新的有趣阅读是 Ian Griffiths 的博客文章[7]。Ian 建议我们可以使用Rx (Reactive Extensions)来限制WPF UI可见的更新。虽然我们的情况与 Ian 的帖子相关,但我们也可以在不使用 Rx 的情况下对其进行优化。

第一个优化是,我们只选择最后一个子项,而不是在浏览路径时选择每个新创建的子项。这将提高性能,因为“显示到视图”行为只会触发一次。

下一个优化是,在前进过程中不展开每个项,而是从最后一个子项开始,反向展开到根。这将节省不必要的 UI 更新,因为滚动区域只会改变一次,而不是每次展开一个项时都会改变。

上述浏览算法中的更改不一定会解决 UI 冻结问题,只会缩短冻结时间。那么,在浏览路径并将展开的项附加到根下(当完整的(分离的)子结构可用时)呢?事实证明,离线浏览(在隐藏状态下)是显示无冻结动画进度的最佳方式。这里有一些情况需要考虑:

  1. 在 Treeview 加载(构造)时浏览到路径(例如:对话框打开并显示一个已打开路径的 Treeview)。
    • 解决方案:仅在初始浏览完成后,在加载时将视图模型附加到其视图,或者
  2. 在 Treeview 已加载(已构造并可见)时浏览到路径。
    • 解决方案:仅在完全可用时附加完整的(分离的)子结构。

第一种选择即使我们使用了上述天真的浏览算法也能工作。但是,如果 Treeview 已经可见,并且我们想在不分离整个 Tree(并保存和恢复状态)的情况下浏览到另一个深层位置,它可能会导致 UI 冻结。

第二种选择也可以在 Treeview 加载时解决冻结问题。因此,让我们看看LazyLoading_VirtualizedTreeViewDemo.zip示例解决方案,以了解这是如何实现的。

上面的屏幕截图显示了LazyLoading_VirtualizedTreeViewDemo.zip示例解决方案。左侧屏幕截图显示了默认情况,右侧屏幕截图显示了单击浏览按钮后的结果。我在这里使用 SQL Server 路径,因为它足够深,可以导致一些开销,因为系统必须对其进行浏览 - 但您可以使用系统上的任何其他路径,只需单击鼠标按钮即可进行浏览。我在下面使用无尽的进度条,因为我认为这是一个很好的测试指示器,因为它会在 UI 线程被阻塞很短一段时间时冻结其动画。

单击浏览按钮会调用DemoViewModel类中的BrowseCommand

var path = p as string;
this.BrowserStatus = "Browsing...";
var selItem = await _ComputerInstance.BrowsePath(path);

this.SelectPathItem = selItem;

if (selItem == null)
{
    this.BrowserStatus = "Target does not exist or cannot be located (make sure access is granted).";
    SelectedItem = null;
}
else
{
    this.BrowserStatus = "Ready.";
    SelectedItem = selItem[selItem.Length - 1];
}

此命令执行两项操作:它完成ComputerViewModel对象下子结构所请求的路径,并设置SelectPathItem属性以将新项显示到视图中,正如在“虚拟化”部分中前面解释的那样。

var exists = await PathModel.DirectoryPathExistsAsync(inputPath);

if (exists == false)
    return null;

var folders = PathModel.GetDirectories(inputPath);

var drive = this.FindChildByName(folders[0]);

return await NavigatePathAsync(drive, folders);

BrowsePath方法执行一些基本健全性检查,例如:

  • 检查我们要浏览的目标在文件系统中是否存在。
  • 通过PathModel类生成路径项字符串数组。
  • 以驱动器作为子结构中要确认的第一个项开始导航过程。

然后,当这些基本问题得到解决和处理后,将执行NavigatePathAsync方法以在视图模型项中执行实际的浏览。

private async Task<IFolderBrowserViewModel[]> NavigatePathAsync(
    IFolderBrowserViewModel parent
   ,string[] folders
   ,int iMatchIdx = 0)
{
    IFolderBrowserViewModel[] pathFolders = new IFolderBrowserViewModel[folders.Count() + 1];

    pathFolders[0] = this;
    pathFolders[1] = parent;

    // These may need to be connected below when we find that the structure
    // was not completely available as we came along expanding each item here...
    IFolderBrowserViewModel dummyParent = null;
    List<IFolderBrowserViewModel> dummyChildren = null;

    int iNext = iMatchIdx + 1;
    for (; iNext < folders.Count(); iNext++)
    {
        if (dummyChildren == null && dummyParent == null)
        {
            if (parent.HasDummyChild == true)
            {
                dummyParent = parent;
                dummyChildren = await parent.LoadChildrenListAsync();
                var nextChild = dummyChildren.SingleOrDefault(item => folders[iNext] == item.Name);

                if (nextChild != null)
                {
                    pathFolders[iNext + 1] = nextChild;
                    parent = nextChild;
                }
            }
            else
            {
                var nextChild = parent.FindChildByName(folders[iNext]);

                if (nextChild != null)
                {
                    pathFolders[iNext + 1] = nextChild;
                    parent = nextChild;
                }
            }
        }
        else
        {
            var children = await parent.LoadChildrenAsync();
            var nextChild = parent.FindChildByName(folders[iNext]);

            if (nextChild != null)
            {
                pathFolders[iNext + 1] = nextChild;
                parent = nextChild;
            }
        }
    }

    if (dummyParent != null && dummyChildren != null)
        dummyParent.AddChildren(dummyChildren);

    return pathFolders;
}

这段代码的工作方式就像一个拉链,它沿着可用的视图模型项结构进行浏览,并将项放入pathFolders数组中,只要它们已经在那里。当结构似乎不完整时,代码会执行标记为parent.HasDummyChild == true的语句。这时,我们将继续使用以下项来完成:

 
  1. dummyParent是轨迹结束的节点,
  2. dummyChildren是我们继续离线浏览的节点。

页面左侧的动画试图可视化这个算法。红色项是dummyParent - 而该项下方的项是dummyChildren,直到构造完其下的所有项后才会显示。

代码在离线模式下完成dummyChildren节点下的缺失结构 - 也就是说,Treeview 并未察觉到它,因为它在dummyParent下方仍然只看到虚拟子项。直到dummyParentdummyChildren两个项在上面的列表中最后一个if语句中连接起来,情况才会改变。

摘要

本文为处理 MVVM/WPF 中大型结构化数据集和 Treeview 提供了基础,应该是有用的。通常,这类数据集已经存储在某个可用的检索系统中(例如:文件系统、数据库、Rest API 服务等)。因此,数据模型几乎是肯定的,但是一个在 WPF 中高效浏览这些结构的视图模型是一个挑战,因为一次性加载所有项目效率不高。

我们回顾了延迟加载虚拟化高效浏览,以提供一套基本的解决方案来应对这类挑战。现在显而易见的是,开发一个虚拟化 Treeview 与非虚拟化 Treeview 完全是两回事。

当项目数量几乎无限时,虚拟化 Treeview 似乎是最佳选择。通常,没有明确的项目数量上限,并且浏览仍然需要快速。另一方面,对于像Visual Studio中的项目资源管理器这样的经典应用程序,非虚拟化 Treeview 是更好的选择。

决定是否虚拟化对 UI 的组织方式有巨大影响。但它几乎总是会影响其他任务,如与项交互、持久化数据或搜索项,因为这些任务可能需要与项目数量成比例的方法,这意味着通常需要更多的工作。但是,对于非虚拟化 Treeview 或少量项目,则不需要这种开销。

参考文献

© . All rights reserved.