使用 ViewModel 模式简化 WPF TreeView






4.95/5 (395投票s)
回顾如何使用 ViewModel 来抽象 WPF TreeView 控件的复杂性。
引言
本文探讨了如何使用 ViewModel 模式来简化 WPF 中 TreeView
控件的使用。在此过程中,我们将分析人们为何经常在 WPF TreeView
上遇到困难,什么是 ViewModel,以及两个演示应用程序,它们展示了如何将 TreeView
与 ViewModel 结合使用。其中一个演示展示了如何创建可搜索的 TreeView
,另一个演示了如何实现延迟加载(也称为按需加载)。
TreeView 背景
WPF 中的 TreeView
控件背负了一个不应有的坏名声。许多人尝试使用它,却发现它非常困难。问题在于,人们经常尝试以与编写 Windows Forms TreeView
控件相同的方式来使用它。为了充分利用 WPF TreeView
的丰富功能,您不能使用与 Windows Forms 相同的编程技术。这是 WPF 需要您转变思维方式以适当地利用该平台的又一个例子。我们已经不在堪萨斯州了,托托。
在 Windows Forms 中,使用 TreeView
控件非常容易,因为它非常简单。这种简单性源于 Windows Forms TreeView
完全不灵活,不支持 UI 虚拟化,无法进行任何视觉自定义,并且由于不支持数据绑定,因此要求您将数据存储在其节点中。WinForms TreeView
“足以胜任政府工作”。
相比之下,WPF TreeView
极其灵活,天然支持 UI 虚拟化(即 TreeViewItem
是按需创建的),允许进行完全的视觉自定义,并完全支持数据绑定。这些出色的功能是有代价的。它们使控件比 WinForms TreeView
更复杂。一旦您学会了如何正确使用 WPF TreeView
,这些复杂性就会消失,并且可以轻松地利用该控件的全部强大功能。
如果您想了解 WPF TreeView
如何自定义的一些示例,请查看本文和本文。
ViewModel 背景
早在 2005 年,John Gossman 就撰写了关于他当时微软团队在创建 Expression Blend(当时称为“Sparkle”)时使用的Model-View-ViewModel 模式的博文。它与 Martin Fowler 的Presentation Model 模式非常相似,只是它通过 WPF 丰富的 UI 绑定填补了 Presentation Model 和 View 之间的空白。在 Dan Crevier 撰写了他精彩的DataModel-View-ViewModel 系列博文后,(D)MVVM 模式开始流行起来。
(Data)Model-View-ViewModel 模式类似于经典的 Model-View-Presenter,不同之处在于您有一个专门为 View 定制的模型,称为 ViewModel。ViewModel 包含使 UI 开发变得容易所需的所有 UI 特定接口和属性。View 绑定到 ViewModel,并执行命令以请求其执行操作。ViewModel 反过来与 Model 通信,并指示 Model 根据用户交互进行更新。
这使得创建应用程序的用户界面 (UI) 更加容易。越容易给应用程序添加 UI,技术能力不强的视觉设计师就越容易在 Blend 中创建漂亮的 UI。此外,UI 与应用程序功能的耦合程度越低,功能就越容易测试。谁不想拥有漂亮的 UI 和一套干净有效的单元测试呢?
到底是什么让 TreeView 如此困难?
TreeView
实际上非常容易使用,前提是您使用正确的方式。使用正确的方式,矛盾的是,意味着根本不直接使用它!当然,您仍然需要直接设置属性和调用 TreeView
上的个别方法。这是不可避免的,这样做也没有错。但是,如果您发现自己深入控件的内部细节,那么您可能没有采取最佳方法。如果您的 TreeView
进行了数据绑定,并且您发现自己需要以编程方式遍历项目,那么您就没有正确做事。如果您发现自己正在挂钩 ItemContainerGenerator
的 StatusChanged
事件,以便在 TreeViewItem
的子项最终创建时访问它们,那么您就完全走错了!相信我;不必如此丑陋和困难。有更好的方法!
将 WPF TreeView
视为 WinForms TreeView
的根本问题是,如我之前提到的,它们是截然不同的控件。WPF TreeView
允许您通过数据绑定生成其项目。这意味着它会为您创建 TreeViewItem
。由于 TreeViewItem
是由控件而不是由您生成的,因此不能保证当您需要时,数据对象对应的 TreeViewItem
已经存在。您必须询问 TreeView
的 ItemContainerGenerator
是否已为您生成了 TreeViewItem
。如果尚未生成,则必须挂钩其 StatusChanged
事件,以便在创建其子元素时收到通知。
乐趣不止于此!如果您想获取一个深埋在树中的 TreeViewItem
,您必须询问该项目的父项/拥有 TreeViewItem
,而不是 TreeView
控件,其 ItemContainerGenerator
是否已生成该项。但是,如果其父项尚未创建,您该如何获取该父 TreeViewItem
的引用呢?如果父项的父项也尚未生成怎么办?依此类推。这可能**相当**令人痛苦。
如您所见,WPF TreeView
是一个复杂的野兽。如果您试图以错误的方式使用它,它将不会容易。幸运的是,如果您以正确的方式使用它,它就轻而易举。那么,让我们看看如何正确使用它……
ViewModel 解决方案
WPF 之所以伟大,是因为它几乎强制要求您将应用程序的数据与 UI 分离开来。上一节中列出的所有问题都源于试图违背趋势,将 UI 视为一个后备存储。一旦您不再将 TreeView
视为一个用于**存放**数据的对象,而将其视为一个用于**显示**数据的对象,一切就会顺利进行。这就是 ViewModel 的概念的用武之地。
与其编写遍历 TreeView
项目的代码,不如创建一个 ViewModel,TreeView
将绑定到该 ViewModel,然后编写操作 ViewModel 的代码。这不仅可以让您忽略 TreeView
的复杂性,还可以让您编写易于单元测试的代码。编写对 TreeView
运行时行为具有密切依赖关系的类的有意义的单元测试几乎是不可能的,但编写对这类与此类无关的类一无所知的类的单元测试却很容易。
现在,是时候看看如何实现这些概念了。
演示解决方案
本文附带两个演示应用程序,可在页面顶部下载。该解决方案包含两个项目。BusinessLib 类库项目包含简单的域类,用作纯数据传输对象。它还包含一个 Database
类,该类实例化并返回这些数据传输对象。另一个项目,TreeViewWithViewModelDemo,包含示例应用程序。这些应用程序使用 *BusinessLib* 程序集返回的对象,并将它们包装在 ViewModel 中,然后再将它们显示在 TreeView
中。
这是解决方案的 Solution Explorer 的截图
演示 1 – 家庭树和文本搜索
我们将要检查的第一个演示应用程序使用家庭树填充 TreeView
。它提供了搜索功能,用户可以在 UI 的底部使用。您可以在下面的截图中看到此演示。
当用户输入一些搜索文本并按 Enter 键,或单击“查找”按钮时,第一个匹配项将显示。继续搜索将循环遍历每个匹配项。所有这些逻辑都在 ViewModel 中。在深入了解 ViewModel 如何工作之前,让我们先检查周围的代码。这是 TextSearchDemoControl
的代码隐藏。
public partial class TextSearchDemoControl : UserControl
{
readonly FamilyTreeViewModel _familyTree;
public TextSearchDemoControl()
{
InitializeComponent();
// Get raw family tree data from a database.
Person rootPerson = Database.GetFamilyTree();
// Create UI-friendly wrappers around the
// raw data objects (i.e. the view-model).
_familyTree = new FamilyTreeViewModel(rootPerson);
// Let the UI bind to the view-model.
base.DataContext = _familyTree;
}
void searchTextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
_familyTree.SearchCommand.Execute(null);
}
}
构造函数显示了如何将原始数据对象转换为 ViewModel,然后将其设置为 UserControl
的 DataContext
。位于 *BusinessLib* 程序集中的 Person
类非常简单。
/// <summary>
/// A simple data transfer object (DTO) that contains raw data about a person.
/// </summary>
public class Person
{
readonly List<Person> _children = new List<Person>();
public IList<Person> Children
{
get { return _children; }
}
public string Name { get; set; }
}
PersonViewModel
由于 Person
类是应用程序数据访问层返回的类,因此它绝对不适合 UI 使用。每个 Person
对象最终都会被 PersonViewModel
类的实例包装,使其能够具有扩展语义,例如展开和选择。上面看到的 FamilyTreeViewModel
类启动了将 Person
对象包装到 PersonViewModel
对象中的过程,如该类的构造函数所示。
public FamilyTreeViewModel(Person rootPerson)
{
_rootPerson = new PersonViewModel(rootPerson);
_firstGeneration = new ReadOnlyCollection<PersonViewModel>(
new PersonViewModel[]
{
_rootPerson
});
_searchCommand = new SearchFamilyTreeCommand(this);
}
私有 PersonViewModel
构造函数递归地遍历家庭树,将每个 Person
对象包装在 PersonViewModel
中。下面的构造函数可见。
public PersonViewModel(Person person)
: this(person, null)
{
}
private PersonViewModel(Person person, PersonViewModel parent)
{
_person = person;
_parent = parent;
_children = new ReadOnlyCollection<PersonViewModel>(
(from child in _person.Children
select new PersonViewModel(child, this))
.ToList<PersonViewModel>());
}
PersonViewModel
有两类成员:与表示相关的成员,以及与 Person
状态相关的成员。表示属性是 TreeViewItem
将绑定的属性,状态属性是 TreeViewItem
的内容将绑定的属性。表示属性之一 IsSelected
如下所示。
/// <summary>
/// Gets/sets whether the TreeViewItem
/// associated with this object is selected.
/// </summary>
public bool IsSelected
{
get { return _isSelected; }
set
{
if (value != _isSelected)
{
_isSelected = value;
this.OnPropertyChanged("IsSelected");
}
}
}
此属性与“人”无关,而只是一个用于将 View 与 ViewModel 同步的状态。请注意,属性的 setter 调用了 OnPropertyChanged
方法,该方法最终会引发对象的 PropertyChanged
事件。该事件是 INotifyPropertyChanged
接口的唯一成员。INotifyPropertyChanged
是一个 UI 特定接口,这就是为什么 PersonViewModel
类实现它,而不是 Person
类。
PersonViewModel
上一个更有趣的表示成员示例是 IsExpanded
属性。此属性可轻松解决确保数据对象对应的 TreeViewItem
在需要时展开的问题。请记住,当直接针对 TreeView
本身编程时,这些类型的麻烦可能非常棘手且难以处理。
/// <summary>
/// Gets/sets whether the TreeViewItem
/// associated with this object is expanded.
/// </summary>
public bool IsExpanded
{
get { return _isExpanded; }
set
{
if (value != _isExpanded)
{
_isExpanded = value;
this.OnPropertyChanged("IsExpanded");
}
// Expand all the way up to the root.
if (_isExpanded && _parent != null)
_parent.IsExpanded = true;
}
}
正如我之前提到的,PersonViewModel
还具有与其基础 Person
对象状态相关的属性。这是一个例子。
public string Name
{
get { return _person.Name; }
}
用户界面
绑定到 PersonViewModel
树的 TreeView
的 XAML 非常直接。请注意,TreeViewItem
和 PersonViewModel
对象之间的连接在于控件的 ItemContainerStyle
。
<TreeView ItemsSource="{Binding FirstGeneration}">
<TreeView.ItemContainerStyle>
<!--
This Style binds a TreeViewItem to a PersonViewModel.
-->
<Style TargetType="{x:Type TreeViewItem}">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
<Setter Property="FontWeight" Value="Normal" />
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="FontWeight" Value="Bold" />
</Trigger>
</Style.Triggers>
</Style>
</TreeView.ItemContainerStyle>
<TreeView.ItemTemplate>
<HierarchicalDataTemplate ItemsSource="{Binding Children}">
<TextBlock Text="{Binding Name}" />
</HierarchicalDataTemplate>
</TreeView.ItemTemplate>
</TreeView>
此演示 UI 的另一部分是搜索区域。该区域为用户提供了一个 TextBox
,用于输入搜索字符串,以及一个“查找”按钮,用于对家庭树执行搜索。这是搜索区域的 XAML。
<StackPanel
HorizontalAlignment="Center"
Margin="4"
Orientation="Horizontal"
>
<TextBlock Text="Search for:" />
<TextBox
x:Name="searchTextBox"
KeyDown="searchTextBox_KeyDown"
Margin="6,0"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
Width="150"
/>
<Button
Command="{Binding SearchCommand}"
Content="_Find"
Padding="8,0"
/>
</StackPanel>
现在,让我们看看 FamilyTreeViewModel
中支持此用户界面的代码。
FamilyTreeViewModel
搜索功能封装在 FamilyTreeViewModel
类中。包含搜索文本的 TextBox
绑定到 SearchText
属性,该属性声明如下。
/// <summary>
/// Gets/sets a fragment of the name to search for.
/// </summary>
public string SearchText
{
get { return _searchText; }
set
{
if (value == _searchText)
return;
_searchText = value;
_matchingPeopleEnumerator = null;
}
}
当用户单击“查找”按钮时,将执行 FamilyTreeViewModel
的 SearchCommand
。该命令类嵌套在 FamilyTreeViewModel
中,但将其暴露给 View 的属性是 public
的。下面显示了该代码。
/// <summary>
/// Returns the command used to execute a search in the family tree.
/// </summary>
public ICommand SearchCommand
{
get { return _searchCommand; }
}
private class SearchFamilyTreeCommand : ICommand
{
readonly FamilyTreeViewModel _familyTree;
public SearchFamilyTreeCommand(FamilyTreeViewModel familyTree)
{
_familyTree = familyTree;
}
public bool CanExecute(object parameter)
{
return true;
}
event EventHandler ICommand.CanExecuteChanged
{
// I intentionally left these empty because
// this command never raises the event, and
// not using the WeakEvent pattern here can
// cause memory leaks. WeakEvent pattern is
// not simple to implement, so why bother.
add { }
remove { }
}
public void Execute(object parameter)
{
_familyTree.PerformSearch();
}
}
如果您熟悉我的 WPF 技术和理念,您可能会惊讶地发现我在这里没有使用路由命令。出于多种原因,我通常更喜欢路由命令,但在这种情况下,使用普通的 ICommand
实现更清晰、更简单。请注意,一定要阅读 CanExecuteChanged
事件声明中的注释。
搜索逻辑与 TreeView
或 TreeViewItem
没有任何依赖关系。它只是遍历 ViewModel 对象并设置 ViewModel 属性。直接尝试使用 TreeView
API 编写此代码将更加困难且容易出错。这是我的搜索逻辑。
IEnumerator<PersonViewModel> _matchingPeopleEnumerator;
string _searchText = String.Empty;
void PerformSearch()
{
if (_matchingPeopleEnumerator == null || !_matchingPeopleEnumerator.MoveNext())
this.VerifyMatchingPeopleEnumerator();
var person = _matchingPeopleEnumerator.Current;
if (person == null)
return;
// Ensure that this person is in view.
if (person.Parent != null)
person.Parent.IsExpanded = true;
person.IsSelected = true;
}
void VerifyMatchingPeopleEnumerator()
{
var matches = this.FindMatches(_searchText, _rootPerson);
_matchingPeopleEnumerator = matches.GetEnumerator();
if (!_matchingPeopleEnumerator.MoveNext())
{
MessageBox.Show(
"No matching names were found.",
"Try Again",
MessageBoxButton.OK,
MessageBoxImage.Information
);
}
}
IEnumerable<PersonViewModel> FindMatches(string searchText, PersonViewModel person)
{
if (person.NameContainsText(searchText))
yield return person;
foreach (PersonViewModel child in person.Children)
foreach (PersonViewModel match in this.FindMatches(searchText, child))
yield return match;
}
演示 2 – 按需加载的地理分布
下一个演示应用程序使用一个国家/地区的各种地点信息来填充 TreeView
。它处理三种不同类型的对象:Region
、State
和 City
。每种类型都有一个相应的表示类,TreeViewItem
s 将绑定到这些类。
每个表示类都派生自 TreeViewItemViewModel
基类,该基类提供了前一个演示的 PersonViewModel
类中看到的表示特定功能。此外,此演示中的项目是延迟加载的,这意味着程序不会在用户尝试查看项目时才获取项目的子项并将其添加到对象图中。您可以在下面的截图中看到此演示。
如上所述,这里有三个独立的数据类,每个数据类都有一个关联的表示类。所有这些表示类都派生自 TreeViewItemViewModel
,由该接口描述。
interface ITreeViewItemViewModel : INotifyPropertyChanged
{
ObservableCollection<TreeViewItemViewModel> Children { get; }
bool HasDummyChild { get; }
bool IsExpanded { get; set; }
bool IsSelected { get; set; }
TreeViewItemViewModel Parent { get; }
}
LoadOnDemandDemoControl
的代码隐藏如下所示。
public partial class LoadOnDemandDemoControl : UserControl
{
public LoadOnDemandDemoControl()
{
InitializeComponent();
Region[] regions = Database.GetRegions();
CountryViewModel viewModel = new CountryViewModel(regions);
base.DataContext = viewModel;
}
}
该构造函数只是从 *BusinessLib* 程序集加载一些数据对象,用 UI 友好的包装器创建它们,然后让 View 绑定到这些包装器。View 的 DataContext
设置为该类的实例。
/// <summary>
/// The ViewModel for the LoadOnDemand demo. This simply
/// exposes a read-only collection of regions.
/// </summary>
public class CountryViewModel
{
readonly ReadOnlyCollection<RegionViewModel> _regions;
public CountryViewModel(Region[] regions)
{
_regions = new ReadOnlyCollection<RegionViewModel>(
(from region in regions
select new RegionViewModel(region))
.ToList());
}
public ReadOnlyCollection<RegionViewModel> Regions
{
get { return _regions; }
}
}
有趣的代码在 TreeViewItemViewModel
中。它主要是前一个演示的 PersonViewModel
中看到的表示逻辑的副本,但有一个有趣的转折。TreeViewItemViewModel
内置了对子项按需加载的支持。该逻辑存在于类的构造函数和 IsExpanded
属性的 setter 中。下面可以看到 TreeViewItemViewModel
的按需加载逻辑。
protected TreeViewItemViewModel(TreeViewItemViewModel parent, bool lazyLoadChildren)
{
_parent = parent;
_children = new ObservableCollection<TreeViewItemViewModel>();
if (lazyLoadChildren)
_children.Add(DummyChild);
}
/// <summary>
/// Gets/sets whether the TreeViewItem
/// associated with this object is expanded.
/// </summary>
public bool IsExpanded
{
get { return _isExpanded; }
set
{
if (value != _isExpanded)
{
_isExpanded = value;
this.OnPropertyChanged("IsExpanded");
}
// Expand all the way up to the root.
if (_isExpanded && _parent != null)
_parent.IsExpanded = true;
// Lazy load the child items, if necessary.
if (this.HasDummyChild)
{
this.Children.Remove(DummyChild);
this.LoadChildren();
}
}
}
/// <summary>
/// Returns true if this object's Children have not yet been populated.
/// </summary>
public bool HasDummyChild
{
get { return this.Children.Count == 1 && this.Children[0] == DummyChild; }
}
/// <summary>
/// Invoked when the child items need to be loaded on demand.
/// Subclasses can override this to populate the Children collection.
/// </summary>
protected virtual void LoadChildren()
{
}
实际加载对象子项的工作留给子类来处理。它们覆盖 LoadChildren
方法以提供加载子项的类型特定实现。下面看到的 RegionViewModel
类会覆盖此方法来加载 State
对象并创建 StateViewModel
包装器对象。
public class RegionViewModel : TreeViewItemViewModel
{
readonly Region _region;
public RegionViewModel(Region region)
: base(null, true)
{
_region = region;
}
public string RegionName
{
get { return _region.RegionName; }
}
protected override void LoadChildren()
{
foreach (State state in Database.GetStates(_region))
base.Children.Add(new StateViewModel(state, this));
}
}
此演示的用户界面仅包含一个 TreeView
,该 TreeView
使用以下 XAML 配置。
<TreeView ItemsSource="{Binding Regions}">
<TreeView.ItemContainerStyle>
<!--
This Style binds a TreeViewItem to a TreeViewItemViewModel.
-->
<Style TargetType="{x:Type TreeViewItem}">
<Setter Property="IsExpanded" Value="{Binding IsExpanded, Mode=TwoWay}" />
<Setter Property="IsSelected" Value="{Binding IsSelected, Mode=TwoWay}" />
<Setter Property="FontWeight" Value="Normal" />
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="FontWeight" Value="Bold" />
</Trigger>
</Style.Triggers>
</Style>
</TreeView.ItemContainerStyle>
<TreeView.Resources>
<HierarchicalDataTemplate
DataType="{x:Type local:RegionViewModel}"
ItemsSource="{Binding Children}"
>
<StackPanel Orientation="Horizontal">
<Image Width="16" Height="16"
Margin="3,0" Source="Images\Region.png" />
<TextBlock Text="{Binding RegionName}" />
</StackPanel>
</HierarchicalDataTemplate>
<HierarchicalDataTemplate
DataType="{x:Type local:StateViewModel}"
ItemsSource="{Binding Children}"
>
<StackPanel Orientation="Horizontal">
<Image Width="16" Height="16"
Margin="3,0" Source="Images\State.png" />
<TextBlock Text="{Binding StateName}" />
</StackPanel>
</HierarchicalDataTemplate>
<DataTemplate DataType="{x:Type local:CityViewModel}">
<StackPanel Orientation="Horizontal">
<Image Width="16" Height="16"
Margin="3,0" Source="Images\City.png" />
<TextBlock Text="{Binding CityName}" />
</StackPanel>
</DataTemplate>
</TreeView.Resources>
</TreeView>
结论
如果您曾经在 WPF TreeView
上挣扎过,那么本文也许阐明了使用该控件的一种替代方法。一旦您开始顺流而行,停止逆流而上,WPF 就会让您的生活变得非常轻松。困难的部分在于放下您辛辛苦苦获得的知识和技能,并采用截然不同的方法来解决相同的问题。
特别感谢
我要感谢Sacha Barber 鼓励我写这篇文章。在开发演示应用程序时,他还给了我宝贵的反馈和要求。如果不是他,我可能永远不会写这篇文章。
修订历史
- 2008 年 5 月 22 日 – 创建了文章。