Silverlight 中的分层数据模板






4.64/5 (12投票s)
分层数据模板是组织和绑定Windows Presentation Foundation (WPF)和Silverlight中可用数据的强大方法。
引言
分层数据模板是组织和绑定Windows Presentation Foundation (WPF)和Silverlight中可用数据的强大方法。本文将通过一个“用户和组”示例应用程序,探讨分层数据模板的一些功能。在本例中,我们将使用Silverlight DataGrid和TreeView控件,以及值转换器。
当您阅读完本文并掌握了示例代码后,您应该对分层数据模板的工作原理、Silverlight中的数据绑定工作原理以及一些用于应用程序的创意用法有了扎实的了解,同时还能了解如何对应用程序进行一些面向性能的调整,以实现大型数据集的延迟加载。
背景
有许多业务需求涉及以分层格式显示数据。传统上,Web UI设计需要通过代码管理层级结构,然后递归遍历以构建树。当时没有可用的分层“数据集”。
分层数据模板允许您绑定分层和自引用的数据。模板能够递归地遍历树,并允许更清晰地分离数据和表示实现。通过了解分层数据模板的工作原理,您还可以通过在正确的时间仅在需要显示时加载数据来利用一些性能优势。
在本例中,我们采用了一种常见的模式:组织组内的用户。组形成一个“组”对象的层级结构,例如,北美可能有一个东区组,该区域内可能有一个管理员组,依此类推。属于任何组的用户自动属于该组的父级,以及该父级的父级,依此类推。
这种模式的独特之处在于,一个组可以有两个类型的子项:另一个组,或一个用户。我们如何在分层数据结构中表示两种不同类型的数据,并在我们的应用程序中利用模板?此外,如果我们的应用程序用户想要钻取到特定组,然后查看其中的用户,而不必等待整个组织加载怎么办?想象一下系统中存在 10,000 个用户,您需要等待加载所有这些详细信息,而不是能够仅加载在层级结构的给定级别内显示的那些用户。
入门:域和传输
我喜欢遵循的一种构建面向服务代码的方法是为我的域实体提供传输类。域实体可能是一个复杂的类,包含几个子类和一个大型对象图。例如,从概念上讲,我们可以设想有一个 `User` 类,其中包含用户名、电子邮件、名字、姓氏、社会安全号码、多个地址、Web 地址、个人博客、Twitter 用户名以及许多其他我们不需要在显示摘要信息时使用的数据点。将所有这些信息传输到 Silverlight 应用程序会浪费网络带宽和浏览器内存。
因此,我创建了包含对象较小、已“展开”版本的传输对象。为了方便地将域对象转换为传输对象,我通常会提供一个构造函数,如下所示
...
public UserTransport(UserEntity user)
{
ID = user.ID;
Username = user.Username;
}
...
为了简化此示例,我避免在 Web 应用程序端构建类和服务体系结构,而是直接在 Silverlight 应用程序内部创建了一个“测试数据库”。这样更容易设置和运行。我已添加了一些 `Debug` 语句,以便您可以看到某些“服务”是如何被调用的。
您可以通过单击此处 了解我如何抽象 WCF 服务调用。本质上,我将模拟用于调用服务调用的“辅助对象”。
传输
在我们的示例中,我们有两个传输:一个组和一个用户。让我们看看这些类
public class UserTransport
{
public string Username { get; set; }
public string Email { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
}
您可以看到 `UserTransport` 是一个简单、轻量级的类,其中包含基本的用户信息。更复杂的类是 `GroupTransport`,因为它包含有关组层级结构的信息。该类如下所示
public class GroupTransport
{
public class GroupUserAddedEventArgs : EventArgs
{
public List<UserTransport> Users { get; set; }
public GroupUserAddedEventArgs(List<UserTransport> users)
{
Users = users;
}
}
public List<GroupTransport> Unroll()
{
List<GroupTransport> retVal = new List<GroupTransport> {this};
foreach(GroupTransport child in Children)
{
retVal.AddRange(child.Unroll());
}
return retVal;
}
public string GroupName { get; set; }
public event EventHandler<GroupUserAddedEventArgs> UsersAdded;
private readonly List<GroupTransport> _groups = new List<GroupTransport>();
public List<GroupTransport> Children
{
get { return _groups; }
set
{
_groups.AddRange(value);
foreach (GroupTransport group in value)
{
group.UsersAdded += _GroupUsersAdded;
}
}
}
private void _GroupUsersAdded(object sender, GroupUserAddedEventArgs e)
{
if (e != null && e.Users.Count > 0)
{
_users.AddRange(e.Users);
if (UsersAdded != null)
{
UsersAdded(this, e);
}
}
}
private readonly List<UserTransport> _users = new List<UserTransport>();
public void AddUsers(List<UserTransport> users)
{
_users.AddRange(users);
if (UsersAdded != null)
{
UsersAdded(this, new GroupUserAddedEventArgs(users));
}
}
public List<UserTransport> Users
{
get { return _users; }
}
}
需要注意的是,由于我跳过了 Web 应用程序端(客户端/服务器模型中的“服务器”),因此该类内置了一些业务功能,而这些功能通常只存在于服务器端——Silverlight只会“看到”属性,而不会看到用于填充它们的 方法。
关键属性是组的名称 (`GroupName`)、属于该组的用户 (`Users`) 以及属于该组的子组 (`Children`)。
其余方法用于填充层级结构。用户添加事件允许较低层级中添加的用户传播到层级结构的顶部。“展开”事件将层级结构展平为列表,以便于在列表中搜索组。
数据生成
为了模拟实际的数据库,我添加了 `MockDB` 类。它基本上创建了一个组/用户层级结构。我包含了一个常用名字和姓氏列表,然后从列表中生成随机“用户”。
数据库使用单例模式,以确保在应用程序的单次运行期间,任何访问都始终检索相同的数据集。
服务模拟
在我们的应用程序中,我们想模拟两次调用。设想一个调用,它只返回 `GroupTransport` 数据,而不带任何用户。这使我们能够充实树的主层级结构。但是,由于用户列表很长,我们只希望在展开树中的组时检索该组的用户列表。
我们的服务终结点的第一个元素是 `ServiceArgs` 类。它用于封装来自异步调用的返回数据
public class ServiceArgs<T> : EventArgs where T: class
{
public T Entity { get; set; }
public ServiceArgs(T entity)
{
Entity = entity;
}
}
- 提示: — 扩展 `ServiceArgs` 概念的一种方法是也添加一个 `Exception` 属性。这样,您可以完全封装您的调用,并始终检查返回的实体或异常,并适当地进行处理。
`UserService` 类模拟了服务调用的终结点
public class UserService
{
public event EventHandler<ServiceArgs<GroupTransport>> GroupLoaded;
public event EventHandler<ServiceArgs<List<usertransport>>> UserLoaded;
public void GetGroup()
{
Debug.WriteLine("GetGroup() invoked.");
DispatcherTimer timer = new DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(new Random().Next(1500) + 500)
};
timer.Tick += _TimerTick;
timer.Start();
}
void _TimerTick(object sender, EventArgs e)
{
((DispatcherTimer)sender).Stop();
if (GroupLoaded != null)
{
GroupLoaded(this, new ServiceArgs<grouptransport>(MockDB.GetGroupTree()));
}
}
public void GetUsersForGroup(string groupName)
{
Debug.WriteLine(string.Format("GetUsersForGroup() invoked for {0}.",groupName));
Thread.Sleep(new Random().Next(50) + 10);
GroupTransport group = (from gt in MockDB.GetGroupTree().Unroll()
where gt.GroupName == groupName
select gt).SingleOrDefault();
if (UserLoaded != null)
{
UserLoaded(this, new ServiceArgs<list<usertransport>>(group.Users));
}
}
}</usertransport>
`GetGroup` 用于调用组层级结构的调用,当调用完成时,`GroupLoaded` 会使用根 `GroupTransport` 触发。请注意,我们使用调度计时器来稍微延迟此操作,以模拟通过网络进行的调用。另请注意,在调试模式下运行它时,您的输出窗口中会出现 `Debug` 消息。
`GetUsersForGroup` 将启动“异步”调用以获取特定组的用户列表。我们还打印了一条消息,这对于查看延迟加载如何与分层数据模板配合使用很重要。请注意,我模拟了一个短暂的延迟,然后使用我的“作弊”函数 `Unroll` 来查找我想要的组,然后使用具有该组用户的 `UserLoaded` 事件。
一点点技巧
现在开始变得有趣了。我们想绑定一个分层数据模板,但问题是我们正在处理两种类型的数据(用户和组)。我们如何解决这个问题?很简单:我们将创建一个专用于树的复合对象,其中包含要显示Common数据以及对原始对象的引用。因为我们绑定到树,所以我称之为 `TreeNode`
public class TreeNode
{
public object DataContext { get; set; }
public string Name { get; set; }
public bool IsUser { get; set; }
private bool _usersLoaded;
private ObservableCollection<treenode> _children =
new ObservableCollection<treenode>();
public ObservableCollection<treenode> Children
{
get
{
if (!_usersLoaded)
{
_usersLoaded = true;
UserService service = new UserService();
service.UserLoaded += _ServiceUserLoaded;
service.GetUsersForGroup(Name);
}
return _children;
}
set
{
_children = value;
}
}
public TreeNode(GroupTransport group)
{
DataContext = group;
Name = group.GroupName;
_usersLoaded = false;
IsUser = false;
foreach(GroupTransport child in group.Children)
{
_children.Add(new TreeNode(child));
}
}
public TreeNode(UserTransport user)
{
DataContext = user;
Name = user.Username;
_usersLoaded = true;
IsUser = true;
}
void _ServiceUserLoaded(object sender,
ServiceArgs<List<UserTransport>> e)
{
e.Entity.Sort((u1,u2)=>u1.Username.CompareTo(u2.Username));
foreach(UserTransport user in e.Entity)
{
TreeNode newNode = new TreeNode(user);
_children.Add(newNode);
}
}
}
树节点的基本功能包括一个名称(它将映射到组的名称,或用户的用户名)和一个指示节点是否包含用户的标志(如果不包含,则为组)。现在需要注意的是子项集合。请注意我们如何使用 `ObservableCollection`。这是一种特殊类型的集合,当列表的内容发生更改时,它会自动通知任何绑定到它的控件。这对我们能够延迟加载用户信息并让树视图感知更改非常重要。
有两个构造函数。一个接受 `UserTransport`,另一个接受 `GroupTransport`。相关字段会移至节点,原始对象存储在 `DataContext` 中,对于组,其他树节点会递归添加到子项中。
请密切关注 `_usersLoaded` 标志。对于 `GroupTransport`,我们将其默认为 false。关键在于子项的 getter。当调用 getter 时,会检查此标志。如果设置为 false,则会调用用户服务。当服务调用返回时,每个用户都会转换为 `TreeNode` 并添加到集合中。
这是分层数据模板的关键。在模板中,您指定使用哪个属性来查找模板的下一个“级别”或子项。我们将在稍后连接它时看到这一点。需要注意的是,该属性直到需要时才会被访问,这是基于“Level + 1”模式的。
换句话说,当我们位于根节点时,树视图控件将请求根节点的子项(Level + 1)。这将触发对获取根节点用户的调用。然而,任何子组尚未被访问,因此它们的子项仅包含其他组(没有子项)。展开根节点将显示子组,这些子组反过来会触发对其子项的调用,并导致这些组调用检索其用户的操作。在大多数情况下,除非连接速度极慢,否则当您展开子组时,这些用户将已加载,但如果未加载,它们将随着加载而缓慢出现,如同魔法一般。
当您在调试模式下运行应用程序时,更容易看到和理解此行为。打开输出窗口并观察提示。然后,慢慢展开树。您将看到子组何时触发事件以检索用户。虽然在我们使用模拟数据库的情况下我们始终拥有它们,但您可以想象在实际的基于服务的应用程序中,用户仅在需要时才会被调用,而永远不会为未访问或未查看的节点检索。
TreeView
在理解了我们的树节点后,您现在可以处理树视图控件本身的 XAML 了
<UserControl x:Class="UserGroups.Controls.UserGroups"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Controls="clr-namespace:System.Windows.Controls;assembly=System.Windows.Controls"
xmlns:Data="clr-namespace:System.Windows;assembly=System.Windows.Controls"
xmlns:Converters="clr-namespace:UserGroups.Converters"
>
<UserControl.Resources>
<Converters:UserGroupConverter x:Key="TreeIcon"/>
<Data:HierarchicalDataTemplate x:Key="UserGroupTemplate"
ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal"
Height="Auto" Width="Auto" Grid.Row="0">
<Image Source="{Binding IsUser,Converter={StaticResource TreeIcon}}"/>
<TextBlock Text="{Binding Name}"/>
</StackPanel>
</Data:HierarchicalDataTemplate>
<Style TargetType="Controls:TreeView" x:Key="UserGroupStyle">
<Setter Property="ItemTemplate"
Value="{StaticResource UserGroupTemplate}"/>
<Setter Property="BorderThickness" Value="1"/>
</Style>
<Style TargetType="TextBlock" x:Key="LoadingStyle">
<Setter Property="FontSize" Value="10"/>
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="Margin" Value="3"/>
</Style>
</UserControl.Resources>
<Controls:TreeView x:Name="UserGroupsTree"
Style="{StaticResource UserGroupStyle}">
<Controls:TreeViewItem>
<Controls:TreeViewItem.HeaderTemplate>
<DataTemplate>
<TextBlock Text="Loading..."
Style="{StaticResource LoadingStyle}"></TextBlock>
</DataTemplate>
</Controls:TreeViewItem.HeaderTemplate>
</Controls:TreeViewItem>
</Controls:TreeView>
</UserControl>
`HierarchicalDataTemplate` 指向 `Children` 属性作为其项目源。这是控件解析对象图并递归遍历层级结构的方式。模板本身是一个 `StackPanel`,带有一个图标,显示节点的类型,然后是节点的名称。
对于图标,我们使用了一个值转换器。我们正在显示一个 `Image`,因此值转换器必须返回适用于 `Source` 属性的内容。我们将根据 `IsUser` 值返回一个实际的 `BitmapImage`。转换器如下所示
public class UserGroupConverter : IValueConverter
{
private static readonly BitmapImage _user =
new BitmapImage(new Uri("../Resources/user.png", UriKind.Relative));
private static readonly BitmapImage _group =
new BitmapImage(new Uri("../Resources/groups.png", UriKind.Relative));
public object Convert(object value, Type targetType,
object parameter, CultureInfo culture)
{
return (bool) value ? _user : _group;
}
public object ConvertBack(object value, Type targetType,
object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
请注意,我们如何仅加载两次位图。我们没有重新引用图像。`IsUser` 属性会传入,然后我们返回用户或组位图以嵌入到图像中。另请注意,我在 `ConvertBack` 方法中抛出了 `NotSupportedException`,而不是默认的 `NotImplemented` 异常。这告诉任何使用我应用程序的人,我不打算支持该功能,这并不是一个尚未完成的代码问题。
控件的代码隐藏非常简单
public partial class UserGroups
{
public event EventHandler<ServiceArgs<TreeNode>> SelectionChanged;
public UserGroups()
{
InitializeComponent();
UserService service = new UserService();
service.GroupLoaded += _ServiceGroupLoaded;
service.GetGroup();
}
void _ServiceGroupLoaded(object sender, ServiceArgs<Transport.GroupTransport> e)
{
UserGroupsTree.Items.Clear();
UserGroupsTree.ItemsSource = new List<TreeNode> {new TreeNode(e.Entity)};
UserGroupsTree.SelectedItemChanged += _UserGroupsTreeSelectedItemChanged;
}
void _UserGroupsTreeSelectedItemChanged(object sender,
System.Windows.RoutedPropertyChangedEventArgs<object> e)
{
if (SelectionChanged != null)
{
SelectionChanged(this, new ServiceArgs<TreeNode>((TreeNode)e.NewValue));
}
}
}
在构造函数中,我们设置了一个调用来获取根组。树视图已经有一个硬编码的项目,它只是显示一个友好的“正在加载...”消息。检索到组后,会清除此项,并通过设置 `ItemsSource` 属性将组绑定到数据(请记住,如果您有任何现有内容,则必须先将其清除!)。
现在,当用户展开树时,`TreeNode` 将按需处理用户检索。我们还响应节点被选中,通过发送带有选定 `TreeNode` 对象的事件来响应。这使得其他控件无需了解我们控件的内部工作原理即可进行响应。
在主页中,您会发现另一项方便的功能。当选中一个节点时,我们会触发一个主页正在监听的事件。因为我们将原始对象存储在名为 `DataContext` 的属性中,所以我们可以轻松地检索它并做出响应。如果节点是用户,我们只需将用户绑定到一个显示其姓名、用户名和电子邮件的控件。如果节点是组,我们将用户绑定到数据网格,并显示该组用户的精美网格。这非常强大,因为原始对象在控件之间共享,并且无需进行额外的调用或往返即可显示它们。
后续步骤
显然,您还可以为该应用程序做更多的事情,例如清理 UI、分离关注点、甚至添加一些错误处理。一个好的下一步是采用模拟数据库并将其迁移到您的 Web 应用程序中,然后设置一些服务将其传输到 Silverlight。然后,您可以使用 Fiddler 等实用程序来查看服务何时/如何被访问。
希望这为您提供了对 Silverlight 中分层数据模板使用以及服务和控件之间进行一些基本通信的有用见解。