Иерархические шаблоны данных в Silverlight

ОГЛАВЛЕНИЕ

Данная статья изучает некоторые свойства иерархических шаблонов данных с помощью пробного приложения "Пользователи и группы". В примере используются управляющие элементы DataGrid(сетка данных) и TreeView(список с древовидным отображением) Silverlight вместе с преобразователями значения.

•    Скачать исходный код - 332 Кб

Введение

Иерархические шаблоны данных являются мощным способом организации и привязки данных, имеющимся в основе представления Windows (WPF) и Silverlight. Прочитав данную статью и пример кода, вы получите глубокие знания о том, как работают иерархические шаблоны данных, как работает привязка данных в Silverlight, и о некоторых изобретательных способах их применения в приложениях, а также как вносить ряд ориентированных на производительность изменений в приложение, чтобы обеспечить отложенную загрузку больших наборов данных.

Подготовка

Есть много бизнес-требований, предполагающих отображение данных в иерархическом формате. Обычно конструкция пользовательского интерфейса интернета требовала управления иерархией посредством кода и рекурсивного выполнения итераций для построения дерева. Иерархические наборы данных отсутствовали.

Иерархический шаблон данных позволяет привязать данные, являющиеся иерархическими и автореферентными. Шаблон понимает, как рекурсивно обойти дерево, и обеспечивает более четкое разделение реализации данных и представления. Понимание принципа работы иерархического шаблона данных позволяет выгодно использовать преимущества производительности, путем загрузки данных в нужное время и только когда они нужны для отображения.

В данном примере берется типичная схема: пользователи в организационных группах. Группы формируют иерархию объектов "группы": например, Северная Америка может иметь группу для Восточного региона, и внутри нее группу для администраторов, и так далее. Пользователь, принадлежащий любой группе, автоматически принадлежит родителям группы, и родителям другой группы, и так далее.

Уникальность этой схемы заключается в том, что группа может иметь два типа потомков: другая группа или пользователь. Как можно представить два разных типа данных в иерархической структуре данных и выгодно использовать шаблон в приложении? Более того, что если человек, использующий приложение, хочет добраться до конкретной группы и изучить пользователь в ней, не дожидаясь загрузки всей организации? Представьте наличие 10.000 пользователей в системе и ожидание загрузки всех этих деталей в сравнении с возможностью просто загрузить пользователей, отображаемых в конкретном уровне иерархии.

Начало работы: домен и транспорты

При построении сервис-ориентированного кода предоставляются классы транспорта для сущностей домена. Сущность домена может быть сложным классом, содержащим несколько подклассов и большой граф объекта. Например, в принципе, можно представить наличие класса User с именем пользователя, адресом электронной почты, именем, фамилией, номером социального страхования, несколькими адресами, веб-адресами, личными блогами, логинами твиттера и многими другими точками данных, не нужных при представлении сводных данных. Отправка всей этой информации приложению Silverlight была бы растратой пропускной способности сети и памяти браузера.

Поэтому создаются объекты транспорта, содержащие более маленькие, "развернутые" версии объектов. Чтобы облегчить преобразование объекта домена в объект транспорта, предоставляется конструктор такого вида:

...
public UserTransport(UserEntity user)
{
    ID = user.ID;
    Username = user.Username;
}
...

Для упрощения примера не создаются классы и архитектура службы на стороне веб-приложения, зато создается "тестовая база данных" внутри приложения Silverlight. Это упрощает настройку и запуск. Были зашиты некоторые инструкции Debug, чтобы можно было увидеть, как вызываются определенные службы.
Чтобы узнать больше об абстрагировании вызовов служб, жмите сюда. По сути, будет имитироваться "объект-помощник", вызываемый для инициации вызова службы.

Транспорты

В примере есть два транспорта: группа и пользователь. Посмотрим на эти классы:

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; }           
    }      
}

Важно отметить, что из-за пропуска стороны веб-приложения ("сервер" в модели клиент/сервер), в класс встроен некоторый бизнес-функционал, обычно существующий только на стороне сервера — 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));
        }
    }       
}

GetGroup инициирует вызов для иерархии группы, и когда он завершается, возбуждается GroupLoaded с корневым GroupTransport. Используется таймер отправки, чтобы немного задержать это, чтобы сымитировать вызов через сеть. Сообщение Debug появится в окне вывода при его запуске в режиме отладки.

GetUsersForGroup инициирует "асинхронный" вызов, чтобы получить список пользователей для конкретной группы. Также печатается сообщение, позволяющее увидеть, как отложенная загрузка работает с иерархическими шаблонами данных. Имитируется краткая задержка, далее используется функция Unroll для отыскания нужной группы, затем возбуждается событие UserLoaded с пользователями для той группы.

Хитрости

Теперь начинается веселье. Надо привязать иерархический шаблон данных, но проблема заключается в том, что используются два типа данных (пользователи и группы). Как обойти эту проблему? Просто: для дерева создается составной объект, содержащий общие данные для отображения вместе со ссылкой на оригинальный объект. Так как происходит привязка к дереву, он был назван 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. Ключ тут – получающий метод для потомков. При вызове получающего метода проверяется этот флаг. Если он установлен в «Ложь», то вызывается служба для пользователей. Когда вызов службы возвращает управление, каждый пользователь преобразуется в TreeNode и добавляется к коллекции.

Это ключ для иерархических шаблонов данных. В шаблоне указывается, какое свойство используется для нахождения следующего уровня или потомков для шаблона. Это будет видно сразу при его подключении. Важно отметить, что к свойству не обращаются, пока оно не понадобится, исходя из схемы уровня + 1.

То есть при нахождении в корне управляющий элемент "список с древовидным отображением" потребует потомков корня (уровень + 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. Возвращается фактический BitmapImage на основе значения IsUser. Преобразователь выглядит так:

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, затем возвращается битовый массив пользователя или группы для внедрения в изображение. Также заметьте, что генерируется NotSupportedException в методе ConvertBack, а не стандартное исключение 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, можно извлечь его и отреагировать. Если узел является пользователем, пользователь привязывается к управляющему элементу, показывающему его имя, логин и адрес электронной почты. Если узел является группой –  пользователи привязываются к сетке данных, и показывается сетка пользователей для этой группы. Это эффективно, потому что оригинальный объект совместно используется управляющими элементами, и не требуется делать дополнительные вызовы или передачи данных туда-обратно, чтобы показать их.

Следующие шаги

Разумеется, с данным приложением можно сделать намного больше, чтобы очистить пользовательский интерфейс, разделить понятия, добавить какую-либо обработку ошибок. Хорошим следующим шагом был бы перенос фиктивной базы данных в веб-приложение с последующей установкой каких-либо служб для транспортировки их в Silverlight. Далее можно использовать утилиту типа Fiddler, чтобы увидеть, когда/как осуществляется доступ к службам.

Надеемся, статья дает полезные идеи по использованию иерархических шаблонов данных в Silverlight вместе с некоторыми базовыми средствами взаимодействия между службами и управляющими элементами.