用 ListBox 替换 TreeView






4.83/5 (18投票s)
TreeView 无法充分支持数百万个节点。通过 ListBox 进行模拟可能有所帮助。
引言
TreeView
控件在处理数千个节点时的性能不佳。如果单个节点包含数千个子节点,它也不会进行 UI 虚拟化。因此,我使用支持虚拟化的 ListBox
来模拟 TreeView
。
背景
我正在开发一个名为 Media Assistant 的开源项目,该项目管理您的所有媒体(音乐和电影)文件,并提供来自 IMDb 的信息以及来自 TasteKid 的推荐和其他许多功能。但是,当我向应用程序添加了数千部电影后,它收集了大约 24,000 个艺术家/流派和其他信息。我在 TreeView
中显示 Media Library。我发现 TreeView
无法处理如此巨大的数据。加载和展开节点需要很长时间。尤其当单个节点有数千个子节点时。我在网上搜索了这个问题,发现了一个支持虚拟化的自定义 TreeView 控件。但性能仍然不令人满意。因此,我需要某种数据虚拟化和 UI 虚拟化来解决我的问题。我找到了一个关于 数据虚拟化 的精彩文章。但文章中解释的数据虚拟化仅适用于扁平数据,而不适用于层次数据。所以,我最终找到了一个解决方案,即使用 ListBox
,我可以在其中进行数据虚拟化,而 ListBox
默认支持 UI 虚拟化。这是解决我问题的完整方案。
如何使用 ListBox 模拟 TreeView 控件
为了解释我如何实现一个看起来像 TreeView
并支持数据虚拟化的 ListBox
,我将问题分为两部分。第一部分将解释如何对 ListBox
进行模板化,使其看起来像 TreeView
,第二部分将解释我如何将层次数据转换为扁平列表,以便实现数据虚拟化。
模板化 ListBox 以使其看起来像 TreeView
<ListBox Name="Tree" DockPanel.Dock="Top"
ItemsSource="{Binding DataSource.OrderedLibraryItems}"
Width="230" HorizontalAlignment="Left"
BorderThickness="0"
Background="Transparent"
VirtualizingStackPanel.IsVirtualizing="True"
VirtualizingStackPanel.VirtualizationMode="Standard"
ScrollViewer.IsDeferredScrollingEnabled="True"
ItemTemplate="{StaticResource ListLibraryItemTemplate}"
SelectionMode="Single"
MouseDoubleClick="HandleMouseDoubleClick"
/>
默认情况下,ListBox
使用 VirtualizingStackPanel
。我使用了延迟滚动,这样在滚动时,ListBox
不会被更新。在 ItemsSource
中,我绑定了扁平列表,这是我层次数据的扁平表示。ListLibraryItemTemplate
代表列表项,使其看起来像 TreeView
的节点。
<DataTemplate x:Key="ListLibraryItemTemplate" >
<StackPanel x:Name="listItemPanel" Orientation="Horizontal"
Margin="{Binding Converter={StaticResource LibraryItemMarginConverter}}">
<ToggleButton x:Name="expandCollapseButton"
IsChecked="{Binding IsExpanded, Mode=TwoWay}"
Visibility="{Binding HasChildren,
Converter={StaticResource HasChildreenVisibilityConverter}}"
Command="{Binding RelativeSource={RelativeSource
Mode=FindAncestor,AncestorType={x:Type UserControl}},
Path=DataContext.ToggleExpandCollapseCommand}"
Style="{StaticResource ExpandCollapseToggleStyle}"/>
<Border Name="iconBorder" Width="20" Height="20">
<ContentControl x:Name="icon"/>
</Border>
<TextBlock TextAlignment="Left" HorizontalAlignment="Center"
VerticalAlignment="Center" Text="{Binding Title}"
ToolTip="{Binding Title}" Width="150"
TextTrimming="CharacterEllipsis"></TextBlock>
</StackPanel>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding Type}"
Value="{x:Static Constants:LibraryItemType.MovieLibrary}">
<Setter TargetName="icon" Property="Content"
Value="{StaticResource MovieLibraryImage}"/>
<Setter TargetName="iconBorder"
Property="Width" Value="25"/>
<Setter TargetName="iconBorder"
Property="Height" Value="25"/>
</DataTrigger>
<DataTrigger Binding="{Binding Type}"
Value="{x:Static Constants:LibraryItemType.UnreadMovieLibrary}">
<Setter TargetName="icon" Property="Content"
Value="{StaticResource NewImage}"/>
</DataTrigger>
//some other triggers
</DataTemplate.Triggers>
</DataTemplate>
在数据模板中,我使用了一个切换按钮来显示展开/折叠状态(+/-),一个图标来表示库项,以及一个 TextBlock
来表示我的库项的文本。
为了让切换按钮看起来像展开/折叠按钮,我使用了以下样式
<Style x:Key="ExpandCollapseToggleStyle" TargetType="{x:Type ToggleButton}">
<Setter Property="Focusable" Value="False"/>
<Setter Property="Width" Value="19"/>
<Setter Property="Height" Value="13"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ToggleButton}">
<Border Width="19" Height="13" Background="Transparent">
<Border SnapsToDevicePixels="true" Width="9" Height="9"
Background="{DynamicResource {x:Static SystemColors.WindowBrushKey}}"
BorderBrush="{DynamicResource {x:Static SystemColors.GrayTextBrushKey}}"
BorderThickness="1">
<Path Margin="1,1,1,1" x:Name="ExpandPath"
Fill="{DynamicResource {x:Static SystemColors.WindowTextBrushKey}}"
Data="M 0 2 L 0 3 L 2 3 L 2 5 L 3 5 L 3 3 L 5 3 L 5 2 L 3 2 L 3 0 L 2 0 L 2 2 Z"/>
</Border>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter Property="Data" TargetName="ExpandPath"
Value="M 0 2 L 0 3 L 5 3 L 5 2 Z"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
我使用了我的库项的 IsExpanded
属性来更改切换按钮的展开/折叠状态。我在我的数据结构中引入了一个属性来表示它是展开还是折叠。
为了在 ListBoxItem
上显示缩进,使其看起来像树节点,我使用了 LibraryItemMarginConverter
将库项的 Lavel
属性转换为用于缩进的边距。
public class LibraryItemMarginConverter:IValueConverter
{
public object Convert(object value, Type targetType,
object parameter, CultureInfo culture)
{
var libraryItem = (LibraryItem)value;
return new Thickness(libraryItem.Lavel * 10, 1, 0, 1);
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
我在我的 LibraryItem
中引入了一个 Lavel
属性。
如果节点没有子节点,则树视图不显示任何展开/折叠按钮。为了模拟这一点,我使用了 HasChildreenVisibilityConverter
转换器,它将 HasChildren
布尔属性转换为可见性。
public class HasChildreenVisibilityConverter:IValueConverter
{
public object Convert(object value, Type targetType,
object parameter, CultureInfo culture)
{
if (value == null)
return Visibility.Visible;
var hasChildren = (bool)value;
return hasChildren ? Visibility.Visible : Visibility.Hidden;
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
我在我的 LibraryItem
中引入了一个 HasChildren
属性。我可以在需要时找出库项是否有子项。但当您使用 Entity Framework 时,这会非常昂贵。因为每次访问导航属性时,都需要花费大量时间来处理。我使用了一个数据触发器来更改我的库项的图标。这就是将 ListBox
模板化以使其看起来像 TreeView
的全部内容。
将层次数据转换为扁平列表以支持数据虚拟化
要了解数据虚拟化,请阅读文章 WPF 数据虚拟化。
var orderedLibraryItems = new VirtualizingCollection<LibraryItem>(new LibraryItemProvider(rootItems));
VirtualizingCollection
接受 IItemsProvider
的实现,该接口有两个方法:FetchCount
和 FetchRange
。我的 IItemsProvider
实现是 LibraryItemProvider
,它以根项作为构造函数参数。VirtualizingCollection
首先访问 FetchCount
方法来确定集合中有多少项可用。
计算计数
private void CalculateCount()
{
_count = 0;
foreach (var rootItem in _rootItems)
{
_count++;
rootItem.Lavel = 0;
_startIndexDictionary.Add(_count - 1, rootItem);
GetChildrenCount(rootItem, 0);
}
}
private void GetChildrenCount(LibraryItem item, int label)
{
if (!item.IsExpanded)
{
return;
}
if (LibraryItemType.CanHaveChildren(item.Type) == false)
{
return;
}
if (LibraryItemType.IsLastParent(item.Type) == false)
{
foreach (var child in item.OrderedChildren)
{
_count++;
child.Lavel = label + 1;
_startIndexDictionary.Add(_count - 1, child);
GetChildrenCount(child, label + 1);
}
}
else
{
_count += item.Children.Count;
}
}
我使用递归方法来计算列表中可见项的数量。如果项已展开并且有子项,那么我将通过增加 Lavel
(我用来像 TreeView
节点一样进行缩进)来继续递归。同时,我有一个字典,其中索引是键,该索引位置的项是值。此字典将在 FetchRange
方法中使用,以查找要为虚拟化 StackPanel
提供服务的数据范围。
FetchRange
public IList<LibraryItem> FetchRange(int startIndex, int count)
{
lock (DatabaseManager.LockObject)
{
var skippedItems = 0;
var items = new List<LibraryItem>();
foreach (var kv in _startIndexDictionary.OrderBy(kv => kv.Key))
{
var endeIndex = kv.Key;
if (kv.Value.IsExpanded)
{
endeIndex += kv.Value.Children.Count;
}
if (endeIndex < startIndex)
{
skippedItems = endeIndex + 1;
continue;
}
if (skippedItems < startIndex)
{
skippedItems++;
}
else
{
items.Add(kv.Value);
if (items.Count == count)
return items;
}
if (kv.Value.IsExpanded && LibraryItemType.IsLastParent(kv.Value.Type))
{
foreach (var item in kv.Value.OrderedChildren)
{
if (skippedItems < startIndex)
{
skippedItems++;
}
else
{
item.Lavel = kv.Value.Lavel + 1;
items.Add(item);
if (items.Count == count)
return items;
}
}
}
}
return items;
}
}
在 FetchRange
方法中,我跳过了项目数量以将位置移动到 startIndex
。我使用我构建的字典来执行此遍历,使代码运行得更快。一旦我到达 startIndex
的项,我将尝试返回 count
参数表示的项目数量。virtualizingCollection
类使用 100 的页面大小,这可以更改以获取项目。
关注点
在开发 Media Assistant 时,我发现许多地方性能是主要问题,并且需要以不同的方式使用标准的 WPF 控件来解决问题。我将尝试发布我为提高应用程序性能所做的所有工作。