Sunday, November 3, 2013

Основы паттерна MVVM

Паттерн MVVM (Model-View-ViewModel) предназначен для создания приложений для WPF/Silverlight. Вначале окунемся немного в историю создания паттерна MVVM. MVVM паттерн был разработан Джоном Госсманом (John Gossman) в 2005 году как модификация шаблона Presentation Model (его блог)  На данном этапе разработки программного обеспечения с визуальным интерфейсом для проектирования чаще всего используют такие паттерны:
  • MVP - используют для Windows Forms;
  • MVC - для ASP MVC;
  • MVVM - для приложений написанных на WPF/Silverlight.

Цель данной статьи - ознакомить читателя с принципами паттерна MVVM, поэтому паттерны MVP и MVC мы пропустим. Основная особенность данного паттерна заключается в том, что весь код с View (представление) выносится в ViewModel (модель представления), а вся привязка осуществляется черед байндинг, прописанный в  XAML разметке. Для простоты работы с MVVM был разработан MVVM Toolkit , который включает шаблон для Visual Studio и позволяет использовать данный паттерн без особых усилий. Ми же рассмотрим MVVM в классическом его представлении. Для начала создадим в Visual Studio WPF приложение.

Создадим сразу папки для разделения логики по слоям. (Model - для модели данных, ViewModel – для модели представления, которая будет, по сути, связывать Model + View, View - представление).
Перетащим сразу MainWindows.xaml в View и чтобы проект запустился, изменим в App.xaml  StartupUri
<Application x:Class="WpfApplicationMVVM.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             StartupUri="View\MainWindow.xaml">
    <Application.Resources>
        
    </Application.Resources>
</Application>
После проделанных действий нажимаем кнопочку. После этого у вас должна показаться на экране пустая форма. Теперь можно приступить к реализации. Возьмем за пример реализации библиотеки, где пользователь может выбрать некоторую книгу. Для этого ми создадим в Model класс Book с такими параметрами:
  • автор книги;
  • название;
  • год издания;
  • серийный номер
  • количество;

Для того чтобы уведомлять View об изменениях внутри класса Book, данный класс необходимо наследовать от INotifyPropertyChanged.
public class Book : INotifyPropertyChanged
    {
        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 int _count;
        public int Count
        {
            get { return _count; }
            set
            {
                _count = value;
                OnPropertyChanged("Count");
            }
        }

        public void OnPropertyChanged(string propertyName)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }

        public event PropertyChangedEventHandler PropertyChanged;
    }
Затем создадим в папке ViewModel класс LibraryViewModel, который свяжет представление с моделью данных.
public class LibraryViewModel : INotifyPropertyChanged
    {
        #region Private Variables
        private ObservableCollection<Book> _books;
        #endregion

        #region Constructor
        public LibraryViewModel()
        {
            _books = new ObservableCollection<Book>();
            _books.Add(new Book() { Author = "Jon Skeet", Title = "C# in Depth", Count = 3, SN = "ISBN: 9781617291340", Year = new DateTime(2013,9,10) });
            _books.Add(new Book() { Author = "Martin Fowler", Title = "Refactoring: Improving the Design of Existing Code", Count = 2, SN = "ISBN-10: 0201485672", Year = new DateTime(1999, 7, 8) });
            _books.Add(new Book() { Author = "Jeffrey Richter", Title = "CLR via C# (Developer Reference)", Count = 5, SN = "ISBN-10: 0735667454", Year = new DateTime(2012, 12, 4) });
        }
        #endregion

        #region Public Properties
        public ObservableCollection<Book> Books
        {
            get { return _books; }
            set
            {
                _books = value;
                OnPropertyChanged("Books");
            }
        }

        private Book _selectedBook;
        public Book SelectedBook
        {
            get { return _selectedBook; }
            set
            {
                _selectedBook = value;
                OnPropertyChanged("SelectedBook");
            }
        }
        #endregion

        #region Events
        public event PropertyChangedEventHandler PropertyChanged;
        #endregion

        #region Command
        #endregion

        #region Private Methods
        private void OnPropertyChanged(string propertyChanged)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyChanged));
        }
        #endregion
    }
Осталось определить визуальное представление созданной информации и сделать привязку MainWindow.xaml с LibraryViewModel. Для этого в XAML разметку MainWindow.xaml добавим следующий код:
<Window x:Class="WpfApplicationMVVM.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="Library" Height="350" Width="525">
    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition Width="*" />
            <ColumnDefinition Width="*" />
        </Grid.ColumnDefinitions>
        <ListView x:Name ="library" Grid.Column ="0" ItemsSource="{Binding Books}" SelectedItem="{Binding SelectedBook}" >
            <ListView.ItemTemplate>
                <DataTemplate>
                    <WrapPanel>
                        <TextBlock Text="Author: " />
                        <TextBlock Text="{Binding Author}" FontWeight="Bold" />
                        <TextBlock Text=", " />
                        <TextBlock Text="Caption: " />
                        <TextBlock Text="{Binding Title}" FontWeight="Bold" />
                        <TextBlock Text="Count: " />
                        <TextBlock Text="{Binding Count}" FontWeight="Bold" />
                    </WrapPanel>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
        <Grid Grid.Column="1">
            <Grid.RowDefinitions>
                <RowDefinition Height="Auto" />
                <RowDefinition Height="Auto" />
            </Grid.RowDefinitions>
            <Grid Grid.Row="0" DataContext="{Binding ElementName=library, Path=SelectedItem}">
                <Grid.RowDefinitions>
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                    <RowDefinition Height="Auto" />
                </Grid.RowDefinitions>
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="*" />
                    <ColumnDefinition Width="*" />
                </Grid.ColumnDefinitions>
                <Label Grid.Row="0" Grid.Column="0" Content="Author" />
                <TextBox Grid.Row="0" Grid.Column="1" Text="{Binding Author}" />
                <Label Grid.Row="1" Grid.Column="0" Content="Title" />
                <TextBox Grid.Row="1" Grid.Column="1" Text="{Binding Title}" />
                <Label Grid.Row="2" Grid.Column="0" Content="Year" />
                <DatePicker Grid.Row="2" Grid.Column="1" SelectedDate="{Binding Year}" />
                <Label Grid.Row="3" Grid.Column="0" Content="Serial Number" />
                <TextBox Grid.Row="3" Grid.Column="1" Text="{Binding SN}" />
                <Label Grid.Row="4" Grid.Column="0" Content="Count" />
                <TextBox Grid.Row="4" Grid.Column="1" Text="{Binding Count}" />
            </Grid>
           
            <StackPanel Grid.Row="1" Orientation="Horizontal">
                <Button Content="Add new book" Margin="3" />
                <Button Content="Remove book" Margin="3" />
            </StackPanel>
        </Grid>
    </Grid>
</Window>
Затем делаем привязку созданной модели представления с DataContext MainWindow. Для этого изменяем класс MainWindow.cs на приведенный ниже.
/// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        #region Private Variables
        private readonly LibraryViewModel _libraryViewModel;
        #endregion

        #region Constructor
        public MainWindow()
        {
            InitializeComponent();
            _libraryViewModel = new LibraryViewModel();
            DataContext = _libraryViewModel;
        }
        #endregion
    }
После этого можно запустить форму и просмотреть полученный результат.
После того как нажмем на какой-то элемент в списке, получим более подробную информацию.
 По такому принципу работает MVVM модель. Но такая модель не полна без событий. Для того чтобы передать управляющие действия от визуальных элементов в  модель, используются команды, которые реализуют интерфейс  ICommand. Одна из простых реализаций DelegateCommand. Добавим в проект папку Utils и просто скопируем реализацию по приведенной выше ссылке.
public class DelegateCommand : ICommand
    {
        private readonly Predicate<object> _canExecute;
        private readonly Action<object> _execute;

        public event EventHandler CanExecuteChanged;

        public DelegateCommand(Action<object> execute)
            : this(execute, null)
        {
        }

        public DelegateCommand(Action<object> execute,
                       Predicate<object> canExecute)
        {
            _execute = execute;
            _canExecute = canExecute;
        }

        public bool CanExecute(object parameter)
        {
            if (_canExecute == null)
            {
                return true;
            }

            return _canExecute(parameter);
        }

        public void Execute(object parameter)
        {
            _execute(parameter);
        }

        public void RaiseCanExecuteChanged()
        {
            if (CanExecuteChanged != null)
            {
                CanExecuteChanged(this, EventArgs.Empty);
            }
        }
    }
Теперь добавим реализацию команд для кнопок добавления  и удаления новой книги.
public class LibraryViewModel : INotifyPropertyChanged
    {
        #region Private Variables
        private ObservableCollection<Book> _books;
        #endregion

        #region Constructor
        public LibraryViewModel()
        {
            _books = new ObservableCollection<Book>();
            _books.Add(new Book() { Author = "Jon Skeet", Title = "C# in Depth", Count = 3, SN = "ISBN: 9781617291340", Year = new DateTime(2013,9,10) });
            _books.Add(new Book() { Author = "Martin Fowler", Title = "Refactoring: Improving the Design of Existing Code", Count = 2, SN = "ISBN-10: 0201485672", Year = new DateTime(1999, 7, 8) });
            _books.Add(new Book() { Author = "Jeffrey Richter", Title = "CLR via C# (Developer Reference)", Count = 5, SN = "ISBN-10: 0735667454", Year = new DateTime(2012, 12, 4) });
        }
        #endregion

        #region Public Properties
        public ObservableCollection<Book> Books
        {
            get { return _books; }
            set
            {
                _books = value;
                OnPropertyChanged("Books");
            }
        }

        private Book _selectedBook;
        public Book SelectedBook
        {
            get { return _selectedBook; }
            set
            {
                _selectedBook = value;
                OnPropertyChanged("SelectedBook");
                RemoveBookCommand.RaiseCanExecuteChanged();
            }
        }
        #endregion

        #region Events
        public event PropertyChangedEventHandler PropertyChanged;
        #endregion

        #region Command
        private DelegateCommand _addBookCommand;
        public DelegateCommand AddBookCommand
        {
            get
            {
                return _addBookCommand ?? (_addBookCommand = new DelegateCommand(AddNewBook));
            }
        }

        private void AddNewBook(object arg)
        {
            _books.Add(new Book() { Author = "Test1", Title = "Test1", Count = 5, SN = "ISBN-10: 0735667454", Year = DateTime.Now });
        }

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

        private void RemoveBook(object args)
        {
            _books.Remove(SelectedBook);
        }

        private bool CanRemoveBook(object args)
        {
            if(SelectedBook == null)
                return false;
            var book = FindBook(SelectedBook);
            if (book == null)
                return false;

            return true;
        }
        #endregion

        #region Private Methods
        private Book FindBook(Book findBook)
        {
            if (findBook == null)
                return null;

            return _books.FirstOrDefault(book => book.Author == findBook.Author
                                       && book.Title == findBook.Title
                                       && book.SN == findBook.SN
                                       && book.Year == findBook.Year);
        }

        private void OnPropertyChanged(string propertyChanged)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyChanged));
        }
        #endregion
    }
В представлении изменилось только две строчки.
<Button Content="Add new book" Margin="3" Command="{Binding AddBookCommand}" />
<Button Content="Remove book" Margin="3" Command="{Binding RemoveBookCommand}"/>
После всего проделанного можно запустить приложение и убедиться в том, что все работает.
Примечание 1: Не все можно решить с помощью команд. В некоторых случаях может потребоваться прямая привязка, к какому то событию во View. Это нарушает принцип MVVM. Поэтому для таких случаев в WPF есть поддержка Behaviors. Иногда это решает проблему.

Примечание 2: Пример можно было решить, не написав в классе MainWindow.cs ни одной дополнительной строчки кода. Для этого нужно в ресурсы добавить DataTemplate, в котором указать привязку ViewModel к конкретной View. В приведенном случае это MainWindow.xaml (View) к LibraryViewModel.cs (ViewModel). Второй вариант байндинга можно сделать через IoC контейнер, но для этого нужно будет переписать класс MainWindow.cs, чтобы он принимал нужную модель как параметр и устанавливал DataContext для этой модели. Если статья кого-то заинтересует, постараюсь написать об этих тонкостях более подробно. 

2 comments:

  1. Добрый день! Спасибо Вам за статью! Подскажите пожалуйста, а как быть при использовании нескольких связанных таблиц? Как я понимаю, их каким-то образом надо связывать в ViewModel?

    ReplyDelete
    Replies
    1. Здравствуйте. Если у вас есть таблицы вам нужно добавить уровень DAL (data access layer) в котором имплементировать всю логику с помощью ORM (EntityFramewrok) или MicroORM (Dapper). Поскольку ваши таблицы (database entity) != model вам нужно перегнать необходимые для роботы поля в модель. Можно это сделать с помощью automapper или emitmapper. Логику по выборке и перегонки с db entity в model спрячьте за слоем сервисов (business layer). И по сути всю это логику нужно будет заинджектить через конструктор. Если вы знакомы с тем как строить 3 layer architecture, тогда это то что вам нужно. Если же нет и вы хотите это попробовать в тестовых целях - посмотрите в сторону EF Core для примера, и чтобы не лепить разных слоев для работы с базой и бизнес логикой, просто прокидывайте DbContext у ваши ViewModel, тогда Model будут данные с ваших таблиц. Сгенерировать таблицы можно кстати тоже автоматом через Visual Studio ислользуя scaffolding.

      Delete