Friday, May 23, 2014

Interaction Triggers and Commands in Prism 5

В этой статье мы рассмотрим поведение с привязкой команд в библиотеке Prism 5. Мы также рассмотрим, как связываются триггеры с командами в этой библиотеке. Надеюсь, вам уже приходилось работать с интерфейсом ICommand в WPF, поэтому как минимум первая часть этой статьи не должна вызвать у вас недопонимание. Эту статью я решил написать в перерыве между подготовкой статьи об использовании CompositeCommand в Prism 5, в которой я затронул тему построения модульного приложения, используя  паттерн EventAggregator и такие возможности призма, как модульность и работу с регионами. Поэтому в этой теме мы затронем CompositeCommand разве что мельком, так как в ближайшее время по данной теме я выложу целую статью. 
Приступим к реализации. Первым делом создадим новое WPF-приложение и выберем .NET Framework 4.5, так как Prism 5 не работает с фреймфорком ниже этой версии.
Далее через NuGet Packages поставим саму библиотеку Prism, а также библиотеку для работы с загрузчиком, который работает с IoC контейнером Unity.
Сначала необходимо создать загрузчик и сделать стартовую страничку (термином Prism это оболочка (Shell). Создать начальный загрузчик можно, проделав все этапы со статьи "Введение в Prism 5. Bootstrapper". Если вам не приходилось писать свой загрузчик, то просто перейдите по ссылке выше и проделайте те этапы, которые описаны в указанной статье. Первым делом создадим модель Book в папке Models, которая будет хранить информацию о книге для продажи.
public class Book : BindableBase
{
       private long _id;
       public long Id
       {
             get { return _id; }
             set
             {
                    _id = value;
                    OnPropertyChanged(() => Id);
             }
       }

       private string _author;
       public string Author
       {
             get { return _author; }
             set
             {
                    _author = value;
                    OnPropertyChanged(() => Author);
             }
       }

       private string _title;
       public string Title
       {
             get { return _title; }
             set
             {
                    _title = value;
                    OnPropertyChanged(() => Title);
             }
       }

       private DateTime _year;
       public DateTime Year
       {
             get { return _year; }
             set
             {
                    _year = value;
                    OnPropertyChanged(() => Year);
             }
       }

       private string _sn;
       public string SN
       {
             get { return _sn; }
             set
             {
                    _sn = value;
                    OnPropertyChanged(() => SN);
             }
       }

       private double _price;
       public double Price
       {
             get { return _price; }
             set
             {
                    _price = value;
                    OnPropertyChanged(() => Price);
             }
       }
}
Примечание. Одна из основных проблем начинающих разработчиков состоит в том, что они часто путают понятие модель (Model) и модель представления (ViewModel). Основное отличие двух понятий заключается в том, что модель не должна обрабатывать хоть каким-то образом логику пользователя. Модель может только отображать некоторые данные с БД, с какого-то сервиса и т.д. Если у вас появятся события обработки, или команды, или действия пользователя, которые потребуют обработки, то это уже модель представления.   
Следующим этапом нам необходимо для нашей модели Book добавить модель представления BookViewModel, которая будет отвечать за работу с данной моделью и реагировать на команды, которые нам будет присылать наше представление (View).  Я старался по максимуму упростить логику, чтобы было понятно, как это все работает.
public class BookViewModel : BindableBase
{
       #region Constructors
       public BookViewModel()
       {
             Books = new ObservableCollection<Book>(GenerateBooks());
       }
       #endregion

       #region Static Methods
       public static IEnumerable<Book> GenerateBooks()
       {
             yield return new Book { Id = 1, Author = "Jon Skeet", Title = "C# in Depth", Price = 22.5, SN = "ISBN: 9781617291340", Year = new DateTime(2013, 9, 10) };
             yield return new Book { Id = 2, Author = "Martin Fowler", Title = "Refactoring: Improving the Design of Existing Code", Price = 41.52, SN = "ISBN-10: 0201485672", Year = new DateTime(1999, 7, 8) };
             yield return new Book { Id = 3, Author = "Jeffrey Richter", Title = "CLR via C# (Developer Reference)", Price = 35, SN = "ISBN-10: 0735667454", Year = new DateTime(2012, 12, 4) };
       }
       #endregion

       #region Public Properties
       public ObservableCollection<Book> Books { get; set; }

       private Book _currentBook;
       public Book CurrentBook
       {
             get { return _currentBook; }
             set
             {
                    _currentBook = value;
                    OnPropertyChanged(() => CurrentBook);
                    RemoveBookCommand.RaiseCanExecuteChanged();
             }
       }
       #endregion

       #region Commands

       private DelegateCommand _removeBookCommand;
       public DelegateCommand RemoveBookCommand
       {
             get { return _removeBookCommand ?? (_removeBookCommand = new DelegateCommand(RemoveBook, CanRemoveBook)); }
       }

       private bool CanRemoveBook()
       {
             return CurrentBook != null;
       }

       private void RemoveBook()
       {
             Books.Remove(CurrentBook);
       }

       #endregion
}
В данной модели представления добавлена одна команда RemoveBookCommand типа DelegateCommand (DelegateCommand является реализацией интерфейса ICommand, которую нам предоставляет библиотека Prism), с ее помощью мы можем удалить с нашей коллекции книг выбранную нами книгу. Теперь немного подправим нашу оболочку Shell.xaml, в которой отобразим нашу кнопку для удаления книги, и отображение всех книг, которые у нас есть. Реализация этой оболочки приведена ниже.
<Window x:Class="PrismCommandSample.Shell"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Command Sample" Height="350" Width="525">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <ListBox Grid.Row="0"  ItemsSource="{Binding Books}" SelectedItem="{Binding CurrentBook}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Horizontal">
                        <TextBlock Text="{Binding Author}" Width="100"/>
                        <TextBlock Text="{Binding Price, StringFormat={}{0:C}}" FontWeight="Bold" />
                    </StackPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
        <Button Content="Удалить книгу" Grid.Row="1" Width="120" Command="{Binding RemoveBookCommand}"></Button>
    </Grid>
</Window>
Для того чтобы установить свойство DataContext для нашей оболочки модель представления BookViewModel, нам нужно подправить наш загрузчик Bootstrapper например как показано ниже.
public class Bootstrapper : UnityBootstrapper
{
       protected override DependencyObject CreateShell()
       {
             return Container.Resolve<Shell>();
       }

       protected override void InitializeShell()
       {
             Application.Current.MainWindow = (Window)Shell;
             var viewModel = Container.Resolve<BookViewModel>();
             Application.Current.MainWindow.DataContext = viewModel;
             Application.Current.MainWindow.Show();
       }
}
После проделанных действий попробуем запустить наш проект и посмотреть на результат.
Такой пример использования команд вы, наверное, уже встречали ранее. Теперь пройдемся по новинкам, которые стали доступны в Prism 5. Одним из таких нововведений являются так званные Task-Based Delegate Command. Существует множество сценариев, когда команда будет выполняться продолжительное время. Примером такой команды может быть транзакция до базы данных (БД), при этом интерфейс пользователя должен отвечать на действия пользователя. Реализуется все это с помощью метода FromAsyncHandler класса DelegateCommand, который позволяет создать новый экземпляр класса DelegateCommand с асинхронного метода.
public static DelegateCommand FromAsyncHandler(Func<Task> executeMethod);
public static DelegateCommand FromAsyncHandler(Func<Task> executeMethod, Func<bool> canExecuteMethod);
Интересная новость состоит также в том, что метод Execute() класса DelegateCommand возвращает объект класса Task. Использовать метод FromAsyncHandler можно так:
public DelegateCommand SaveBookCommand { get; private set; }
SaveBookCommand = DelegateCommand.FromAsyncHandler(SaveBook, CanSaveBook);
private bool CanSaveBook()
{
       return true;
}

private Task SaveBook()
{
       return Task.FromResult(10000);
}
Следующее важное отличие Prism 5 от Prism 4.1 состоит в том, что с Prism 5 убрали пространство имен Microsoft.Practices.Prism.Commands со всеми классами, которые там были. Ниже представлены классы, которые были размещены в этом пространстве имен:
Методом экспериментов и документации было обнаружено, что структура указанных классов изменилась. Классы ButtonBaseClickCommandBehavior и Click были удалены с использования, а класс CommandBehaviorBase<T> был перемещен в пространство имен Microsoft.Practices.Prism.Interactivity.
Остальные классы потерпели небольших изменений и были перемещены в сборку Microsoft.Practices.Prism.Mvvm, но оставили старое пространство имен.
По сути, был сделан только небольшой рефакторинг. Убрано старое, и разделено по новым сборкам то, что было. Давайте рассмотрим, как можно использовать CommandBehavoirBase. Для этого создадим класс ButtonClickBehavior.
public class ButtonClickBehavior : CommandBehaviorBase<ButtonBase>
{
       public ButtonClickBehavior(ButtonBase targetObject) : base(targetObject)
       {
             targetObject.Click += OnClick;
       }

       private void OnClick(object sender, System.Windows.RoutedEventArgs e)
       {
             ExecuteCommand(null);
       }
}
Теперь нужно реализовать Attached Properties, чтобы использовать созданный выше класс ButtonClickBehavior. Для этого создадим другой класс ButtonBaseBehaviorHelper. Ниже приведена реализация этого класса.
public static class ButtonBaseBehaviorHelper
{
       private static readonly DependencyProperty ButtonCommandBehaviorProperty
             = DependencyProperty.RegisterAttached(
             "ButtonCommandBehavior",
             typeof(ButtonClickBehavior),
             typeof(ButtonBaseBehaviorHelper),
             null);

       public static readonly DependencyProperty CommandProperty
             = DependencyProperty.RegisterAttached(
             "Command",
             typeof(ICommand),
             typeof(ButtonBaseBehaviorHelper),
             new PropertyMetadata(OnSetCommandCallback));

       public static readonly DependencyProperty CommandParameterProperty
             = DependencyProperty.RegisterAttached(
             "CommandParameter",
             typeof(object),
             typeof(ButtonBaseBehaviorHelper),
             new PropertyMetadata(OnSetCommandParameterCallback));

       public static ICommand GetCommand(Control control)
       {
             return control.GetValue(CommandProperty) as ICommand;
       }

       public static void SetCommand(Control control, ICommand command)
       {
             control.SetValue(CommandProperty, command);
       }

       public static void SetCommandParameter(Control control, object parameter)
       {
             control.SetValue(CommandParameterProperty, parameter);
       }

       public static object GetCommandParameter(Control control)
       {
             return control.GetValue(CommandParameterProperty);
       }

       private static void OnSetCommandCallback
             (DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e)
       {
             var control = dependencyObject as Control;
             if (control != null)
             {
                    ButtonClickBehavior behavior = GetOrCreateBehavior(control);
                    behavior.Command = e.NewValue as ICommand;
             }
       }

       private static void OnSetCommandParameterCallback
             (DependencyObject dependencyObject, DependencyPropertyChangedEventArgs e)
       {
             var control = dependencyObject as Control;
             if (control != null)
             {
                    ButtonClickBehavior behavior = GetOrCreateBehavior(control);
                    behavior.CommandParameter = e.NewValue;
             }
       }

       private static ButtonClickBehavior GetOrCreateBehavior(Control control)
       {
             var behavior =
                    control.GetValue(ButtonCommandBehaviorProperty) as ButtonClickBehavior;
             if (behavior == null)
             {
                    behavior = new ButtonClickBehavior((ButtonBase)control);
                    control.SetValue(ButtonCommandBehaviorProperty, behavior);
             }
             return behavior;
       }
}
Использовать эти Attached Properties можно следующим образом:
<Button Content="Удалить книгу" Grid.Row="1" Width="120"
                source:ButtonBaseBehaviorHelper.Command="{Binding Path=RemoveBookCommand}"
                />
Не хочу сказать ничего плохого в адрес разработчиков Prism, но посмотрите, как можно сделать то, что предложено, намного проще. Для этого нам понадобится воспользоваться библиотекой Interactivity с Expression Blend.
Теперь если мы воспользуемся пространством имен System.Windows.Interactivity, а также пространством имен Interactivity с библиотеки Prism 5 (класс InvokeCommandAction), то наш код может упроститься к такому:
<Button Content="Удалить книгу" Grid.Row="1" Width="120"
        >
    <i:Interaction.Triggers>
        <i:EventTrigger EventName="Click">
            <prism:InvokeCommandAction Command="{Binding RemoveBookCommand}" />
        </i:EventTrigger>
    </i:Interaction.Triggers>
</Button>
Нам нужно добавить в представление (View), в котором мы используем класс InvokeCommandAction, ссылку на два пространства имен.
xmlns:prism="clr-namespace:Microsoft.Practices.Prism.Interactivity;assembly=Microsoft.Practices.Prism.Interactivity"
xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity"
Так в чем, собственно, проблема, которая тянется очень давно. Посмотрите, как выглядит класс InvokeCommandAction:
Я специально на скриншоте выделил пространство имен, которое нам понадобилось добавить вручную. Неужели так сложно было добавить свой MarkupExtension, в котором можно было бы реализовать использование сборки Inveractivity, которая используется в поставке Prism 5. Дело в том, что если я буду использовать сборку System.Windows.Interactivity, то смогу написать свой Behaviors на основании поведения, которое есть в данном пространстве имен, и использовать паттерн EventToCommand, например, с MVVM Light Toolkit. В итоге имеем, что вариант замены у нас есть: EventToCommand, а класс CommandBehaviorBase у нас избыточный, так как требует много логики, часть из которой лучше сделать через Behaviors. Единственный плюс только в CommandBehaviorBase - возможности задать команду, которая будет использована, но делать это нужно через Attached Property.
И напоследок осталось рассмотреть, что такое составные команды (CompositeCommand) в Prism. Во многих случаях команда, определенная моделью представления, будет связана с элементами управления в связанном представлении так, чтобы пользователь мог непосредственно вызвать её изнутри представления. Однако в некоторых случаях может понадобиться вызвать команды от одной или более моделей представления из элемента управления в родительском представлении. Например, если ваше приложение позволяет пользователю редактировать разнообразные элементы одновременно, можно позволить пользователю сохранять все элементы, используя единственную команду, представленную кнопкой на панели инструментов. В этом случае команда Save All вызовет каждую из команд Save, реализованную экземпляром модели представления каждого элемента, как показано на следующей иллюстрации.
Примечание. Картинка взята с документации по Prism. Такой сценарий Prism поддерживает через класс CompositeCommand. Класс CompositeCommand представляет команду, которая складывается из разнообразных дочерних команд. Когда вызывается составная команда, каждая из её дочерних команд вызывается поочередно. Для этого над нашей моделью Book нужно написать presenter, который возьмет на себя всю логику по удалению книги со списка. Пример показан ниже.
public class BookPresenterViewModel
{
       #region Private Variables
       private readonly Book _book;
       #endregion

       #region Constructor
       public BookPresenterViewModel(Book book)
       {
             _book = book;
             RemoveBookCommand = new DelegateCommand(Remove);
       }
       #endregion

       #region Public Properties
       public string Author
       {
             get { return _book.Author; }
             set { _book.Author = value; }
       }

       public double Price
       {
             get { return _book.Price; }
             set { _book.Price = value; }
       }
       #endregion

       #region Commands

       public event EventHandler<DataEventArgs<BookPresenterViewModel>> Removed;

       public DelegateCommand RemoveBookCommand { get; private set; }

       private void Remove()
       {
             OnRemoved(new DataEventArgs<BookPresenterViewModel>(this));
       }

       private void OnRemoved(DataEventArgs<BookPresenterViewModel> e)
       {
             EventHandler<DataEventArgs<BookPresenterViewModel>> removedHandler = Removed;
             if (removedHandler != null)
             {
                    removedHandler(this, e);
             }
       }

       #endregion
}
Теперь нужно добавить новый класс, который будет реализовывать логику по удалению книг с использованием класса CompositeCommand.
public static class BooksCommands
{
       public static CompositeCommand RemoveAllBooksCommand = new CompositeCommand();
}

public class BooksCommandProxy
{
       public virtual CompositeCommand RemoveAllBooksCommand
       {
             get { return BooksCommands.RemoveAllBooksCommand; }
       }
}
Мы использовали дополнительный класс BooksCommandProxy, чтобы для нашего примера можно было воспользоваться возможностями Constructor Injection, которые нам доступны благодаря загрузчику UnityBootstrapper с использованием IoC контейнера Unity.  Из-за того, что мы добавили обертку для нашей модели Book, нам нужно перестроить нашу модель представления BookViewModel для работы с этим презентером BookPresenterViewModel.
public class BookViewModel : BindableBase
{
       #region Private Variables
       private readonly BooksCommandProxy _commandProxy;
       #endregion

       #region Constructors
       public BookViewModel(BooksCommandProxy commandProxy)
       {
             _commandProxy = commandProxy;
             GenerateBooksModels();
       }
       #endregion

       #region Static Methods
       public static IEnumerable<Book> GenerateBooks()
       {
             yield return new Book { Id = 1, Author = "Jon Skeet", Title = "C# in Depth", Price = 22.5, SN = "ISBN: 9781617291340", Year = new DateTime(2013, 9, 10) };
             yield return new Book { Id = 2, Author = "Martin Fowler", Title = "Refactoring: Improving the Design of Existing Code", Price = 41.52, SN = "ISBN-10: 0201485672", Year = new DateTime(1999, 7, 8) };
             yield return new Book { Id = 3, Author = "Jeffrey Richter", Title = "CLR via C# (Developer Reference)", Price = 35, SN = "ISBN-10: 0735667454", Year = new DateTime(2012, 12, 4) };
       }
       #endregion

       #region Public Properties
       public ObservableCollection<BookPresenterViewModel> Books { get; set; }

       private Book _currentBook;
       public Book CurrentBook
       {
             get { return _currentBook; }
             set
             {
                    _currentBook = value;
                    OnPropertyChanged(() => CurrentBook);
             }
       }
       #endregion

       #region Private Methods
       private void GenerateBooksModels()
       {
             Books = new ObservableCollection<BookPresenterViewModel>();

             foreach (Book book in GenerateBooks())
             {
                    var bookPresentationModel = new BookPresenterViewModel(book);
                    Books.Add(bookPresentationModel);

                    // Subscribe to the Removed event on the individual books.
                    bookPresentationModel.Removed += BookRemoved;

                    _commandProxy.RemoveAllBooksCommand.RegisterCommand(bookPresentationModel.RemoveBookCommand);
             }
       }

       private void BookRemoved(object sender, DataEventArgs<BookPresenterViewModel> e)
       {
             if (e != null && e.Value != null)
             {
                    BookPresenterViewModel book = e.Value;
                    if (Books.Contains(book))
                    {
                           book.Removed -= BookRemoved;
                           _commandProxy.RemoveAllBooksCommand.UnregisterCommand(book.RemoveBookCommand);
                           Books.Remove(book);
                    }
             }
       }

       #endregion
}
Логика несложная и не должна нуждаться в особом комментировании. Возможно, вас немного смутит событие Removed, которое мы используем для указания того, что мы удаляем какую то книгу. Но с EventHandler вы, наверное, уже сталкивались, а класс DataEventArgs ничего сложного не представляет.
public class DataEventArgs<TData> : EventArgs
       {
             public DataEventArgs(TData value);

             public TData Value { get; }
       }
Так что это обычный класс, наследуемый от EventArgs, с которого можно получить переданные данные. Последним штрихом нужно немного подправить нашу оболочку Shell, в которой добавить использование CompositeCommand.
<Window x:Class="PrismCommandSample.Shell"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:source="clr-namespace:PrismCommandSample.Helpers"
        Title="Command Sample" Height="350" Width="525">
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition Height="Auto" />
            <RowDefinition Height="Auto" />
        </Grid.RowDefinitions>
        <ListBox Grid.Row="0"  ItemsSource="{Binding Books}" SelectedItem="{Binding CurrentBook}">
            <ListBox.ItemTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Horizontal">
                        <TextBlock Text="{Binding Author}" Width="100"/>
                        <TextBlock Text="{Binding Price, StringFormat={}{0:C}}" Width="50" FontWeight="Bold" />
                        <Button Content="Удалить" Command="{Binding RemoveBookCommand}"></Button>
                    </StackPanel>
                </DataTemplate>
            </ListBox.ItemTemplate>
        </ListBox>
        <Button Content="Удалить всё" Grid.Row="1" Width="120"
                Command="{x:Static source:BooksCommands.RemoveAllBooksCommand}"
                />
    </Grid>
</Window>
После запуска приложения мы можем посмотреть на то, что у нас получилось. 

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

No comments:

Post a Comment