MVVM: новый взгляд

от автора

Предисловие

Некоторое время назад я затеял разработку бесплатного текстового редактора с красивым интерфейсом и широким удобным функционалом на платформе WPF. Довелось решить очень много технических задач, поэтому у меня накопился определённый опыт, которым хочу поделиться с другими людьми.

К делу

Разработчикам WPF, Silverlight и WinPhone-приложений хорошо знаком паттерн проектирования MVVM (Model — View — ViewModel). Однако если дополнительно применить к нему ещё немного фантазии, то может получиться что-то более интересное, и немного даже, осмелюсь заверить, революционное.

Допустим, у нас есть классическое окно (View) текстового редактора с меню, тулбар треем и статус баром, которые можно спрятать при желании. Перед нами стоит задача – сохранить позицию и размеры окна, а также визуальное состояние элементов при закрытии приложения, чтобы потом восстановить их.

Обычное решение, которое сразу напрашивается на ум, состоит в добавлении во вью-модель ряда дополнительных свойств для привязки (Top, Left, Width, Heigth, ShowToolBarTray, ShowStatusBar и других), а затем сохранение их значений, например, в файл. Но не будем спешить… Что если я вам скажу, что можно создать такую вью-модель, которая будет реализовывать необходимую функциональность по умолчанию, поэтому для решения задачи не нужно НИ ОДНОЙ дополнительной строки кода?

Сразу рекомендую скачать пример приложения, который я сделал специально для этой статьи (ссылка один или два), он поможет понять основные идеи и прочувствовать красоту подхода. Здесь же я приведу определённые части кода, на которые стоит обратить особое внимание.

В WPF часто используется привязка к свойствам, но существует также возможность привязки к элементам массива, которой пользуются довольно редко. Но вот она-то и открывает нам новые горизонты. Попробуем рассмотреть вью-модель, как словарь, где ключом-индексом будет имя свойства, по которому можно получить его значение.

Но как же нам лучше сохранять эти значения? Попробуем сериализовать вью-модели! Но?.. Это ведь не DTO-объект, да и как потом их десериализовать, ведь в конструктор часто нужно инжектировать другие параметры, а для десериализации обычно нужен конструктор без параметров? А вам никода не казалось инжектирование в конструктор несколько неудобным, например, при добавлении или удалении параметра ломались юнит тесты, и их тоже необходимо было править, хотя интерфейс тестируемого объекта, по сути, оставался прежним?

Поэтому откажемся от инжекций в конструктор, благо, существуют и другие способы для подобных целей, и пометим вью-модели атрибутом [DataContract], а свойства, которые нужно сериализовать, атрибутом [DataMember] (эти аттрибуты очень упрощают сериализацию).

Теперь создадим небольшой класс Store.

    public static class Store     {         private static readonly Dictionary<Type, object> StoredItemsDictionary = new Dictionary<Type, object>();          public static TItem OfType<TItem>(params object[] args) where TItem : class         {             var itemType = typeof (TItem);             if (StoredItemsDictionary.ContainsKey(itemType))                 return (TItem) StoredItemsDictionary[itemType];              var hasDataContract = Attribute.IsDefined(itemType, typeof (DataContractAttribute));             var item = hasDataContract                 ? Serializer.DeserializeDataContract<TItem>() ?? (TItem) Activator.CreateInstance(itemType, args)                 : (TItem) Activator.CreateInstance(itemType, args);              StoredItemsDictionary.Add(itemType, item);             return (TItem) StoredItemsDictionary[itemType];         }          public static void Snapshot()         {             StoredItemsDictionary                 .Where(p => Attribute.IsDefined(p.Key, typeof (DataContractAttribute)))                 .Select(p => p.Value).ToList()                 .ForEach(i => i.SerializeDataContract());         }     } 

Тут всё просто – лишь два метода. OfType возвращающает нам статический экземпляр объекта требуемого типа, по возможноти десериализуя его, и Snapshot делает «снимок» объектов находящихся в контейнере, сериализуя их. Вызов Snapshot в общем случае можно осуществить лишь один раз при закрытии приложения, например, в обработчике Exit класса Application.

И напишем Json-сериализатор.

    public static class Serializer     {         public const string JsonExtension = ".json";          public static readonly List<Type> KnownTypes = new List<Type>         {             typeof (Type),             typeof (Dictionary<string, string>),             typeof (SolidColorBrush),             typeof (MatrixTransform),         };          public static void SerializeDataContract(this object item, string file = null, Type type = null)         {             try             {                 type = type ?? item.GetType();                 if (string.IsNullOrEmpty(file))                     file = type.Name + JsonExtension;                 var serializer = new DataContractJsonSerializer(type, KnownTypes);                 using (var stream = File.Create(file))                 {                     var currentCulture = Thread.CurrentThread.CurrentCulture;                     Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;                     serializer.WriteObject(stream, item);                     Thread.CurrentThread.CurrentCulture = currentCulture;                 }             }             catch (Exception exception)             {                 Trace.WriteLine("Can not serialize json data contract");                 Trace.WriteLine(exception.StackTrace);             }         }          public static TItem DeserializeDataContract<TItem>(string file = null)         {             try             {                 if (string.IsNullOrEmpty(file))                      file = typeof (TItem).Name + JsonExtension;                 var serializer = new DataContractJsonSerializer(typeof (TItem), KnownTypes);                 using (var stream = File.OpenRead(file))                 {                     var currentCulture = Thread.CurrentThread.CurrentCulture;                     Thread.CurrentThread.CurrentCulture = CultureInfo.InvariantCulture;                     var item = (TItem) serializer.ReadObject(stream);                     Thread.CurrentThread.CurrentCulture = currentCulture;                     return item;                 }             }             catch             {                 return default(TItem);             }         }     } 

Базовый класс для вью моделей выглядит тоже не сложно.

    [DataContract]     public class ViewModelBase : PropertyNameProvider, INotifyPropertyChanging, INotifyPropertyChanged     {         protected Dictionary<string, object> Values = new Dictionary<string, object>();         private const string IndexerName = System.Windows.Data.Binding.IndexerName; /* "Item[]" */         public event PropertyChangingEventHandler PropertyChanging = (sender, args) => { };         public event PropertyChangedEventHandler PropertyChanged = (sender, args) => { };          public object this[string key]         {             get { return Values.ContainsKey(key) ? Values[key] : null; }             set             {                 RaisePropertyChanging(IndexerName);                 if (Values.ContainsKey(key)) Values[key] = value;                 else Values.Add(key, value);                 RaisePropertyChanged(IndexerName);             }         }          public object this[string key, object defaultValue]         {             get             {                 if (Values.ContainsKey(key)) return Values[key];                 Values.Add(key, defaultValue);                 return defaultValue;             }             set { this[key] = value; }         }          public void RaisePropertyChanging(string propertyName)         {             PropertyChanging(this, new PropertyChangingEventArgs(propertyName));         }          public void RaisePropertyChanged(string propertyName)         {             PropertyChanged(this, new PropertyChangedEventArgs(propertyName));         }          [OnDeserializing]         private void Initialize(StreamingContext context = default(StreamingContext))         {             if (PropertyChanging == null) PropertyChanging = (sender, args) => { };             if (PropertyChanged == null) PropertyChanged = (sender, args) => { };             if (Values == null) Values = new Dictionary<string, object>();         }     } 

Также унаследуемся от небольшого класса PropertyNameProvider, который пригодится нам в дальнейшем для работы с лямбда-выражениями.

    [DataContract]     public class PropertyNameProvider     {         public static string GetPropertyName<T>(Expression<Func<T>> expression)         {             var memberExpression = expression.Body as MemberExpression;             var unaryExpression = expression.Body as UnaryExpression;              if (unaryExpression != null)                 memberExpression = unaryExpression.Operand as MemberExpression;              if (memberExpression == null || memberExpression.Member.MemberType != MemberTypes.Property)                 throw new Exception("Invalid lambda expression format.");              return memberExpression.Member.Name;         }     } 

Отлично, на данном этапе мы реализовали возможность привязки к свойствам-индаксам. В xaml можно писать выражения следующего вида

Height="{Binding ‘[Height, 600]’, Mode=TwoWay}"

где первый параметр — это имя свойства, а второй (опциональный) — его дефолтное значение.

Этот подход чем-то напоминает реализацию стандартного интерфейса IDataErrorInfo. Почему бы нам тоже не реализовать его? Хорошая идея, но не станем спешить, а примем её во внимание… Поиграем ещё с переопределением индексатора. Все помнят про ICommand, а в WPF существует ещё крутой механизм работы RoutedCommands и CommandBindings. Вот было бы классно писать реализацию команд во вью-модели подобным образом.

            this[ApplicationCommands.Save].CanExecute += (sender, args) => args.CanExecute = HasChanged;             this[ApplicationCommands.New].CanExecute += (sender, args) =>             {                 args.CanExecute = !string.IsNullOrEmpty(FileName) || !string.IsNullOrEmpty(Text);             };              this[ApplicationCommands.Help].Executed += (sender, args) => MessageBox.Show("Muse 2014");             this[ApplicationCommands.Open].Executed += (sender, args) => Open();             this[ApplicationCommands.Save].Executed += (sender, args) => Save();             this[ApplicationCommands.SaveAs].Executed += (sender, args) => SaveAs();             this[ApplicationCommands.Close].Executed += (sender, args) => Environment.Exit(0);             this[ApplicationCommands.New].Executed += (sender, args) =>             {                 Text = string.Empty;                 FileName = null;                 HasChanged = false;             }; 

Ну, какая же вью-модель без автоматической нотификации свойств и лябда-выражений? Это должно быть по-любому.

        public string Text         {             get { return Get(() => Text); }             set { Set(() => Text, value); }         } 

А что если… Создать PropertyBinding наподобие CommandBinding и совсем чуть-чуть снова поиграть с индексатором?

       this[() => Text].PropertyChanged += (sender, args) => HasChanged = true;        this[() => FontSize].Validation += () => 4.0 < FontSize && FontSize < 128.0 ? null : "Invalid font size"; 

Выглядит неплохо, неправда ли?

И, конечно, наша чудо-вью-модель.

    [DataContract]     public class ViewModel : ViewModelBase, IDataErrorInfo     {         public ViewModel()         {             Initialize();         }          string IDataErrorInfo.this[string propertyName]         {             get             {                 return PropertyBindings.ContainsKey(propertyName)                     ? PropertyBindings[propertyName].InvokeValidation()                     : null;             }         }          public PropertyBinding this[Expression<Func<object>> expression]         {             get             {                 var propertyName = GetPropertyName(expression);                 if (!PropertyBindings.ContainsKey(propertyName))                     PropertyBindings.Add(propertyName, new PropertyBinding(propertyName));                 return PropertyBindings[propertyName];             }         }          public CommandBinding this[ICommand command]         {             get             {                 if (!CommandBindings.ContainsKey(command))                     CommandBindings.Add(command, new CommandBinding(command));                 return CommandBindings[command];             }         }          public string Error { get; protected set; }         public Dictionary<ICommand, CommandBinding> CommandBindings { get; private set; }         public Dictionary<string, PropertyBinding> PropertyBindings { get; private set; }         public CancelEventHandler OnClosing = (o, e) => { };          public TProperty Get<TProperty>(Expression<Func<TProperty>> expression, TProperty defaultValue = default(TProperty))         {             var propertyName = GetPropertyName(expression);             if (!Values.ContainsKey(propertyName))                 Values.Add(propertyName, defaultValue);             return (TProperty) Values[propertyName];         }          public void Set<TProperty>(Expression<Func<TProperty>> expression, TProperty value)         {             var propertyName = GetPropertyName(expression);             RaisePropertyChanging(propertyName);             if (!Values.ContainsKey(propertyName))                 Values.Add(propertyName, value);             else Values[propertyName] = value;             RaisePropertyChanged(propertyName);         }          public void RaisePropertyChanging<TProperty>(Expression<Func<TProperty>> expression)         {             var propertyName = GetPropertyName(expression);             RaisePropertyChanging(propertyName);         }          public void RaisePropertyChanged<TProperty>(Expression<Func<TProperty>> expression)         {             var propertyName = GetPropertyName(expression);             RaisePropertyChanged(propertyName);         }          [OnDeserializing]         private void Initialize(StreamingContext context = default(StreamingContext))         {             CommandBindings = new Dictionary<ICommand, CommandBinding>();             PropertyBindings = new Dictionary<string, PropertyBinding>();             PropertyChanging += OnPropertyChanging;             PropertyChanged += OnPropertyChanged;         }          private void OnPropertyChanging(object sender, PropertyChangingEventArgs e)         {             var propertyName = e.PropertyName;             if (!PropertyBindings.ContainsKey(propertyName)) return;             var binding = PropertyBindings[propertyName];             if (binding != null) binding.InvokePropertyChanging(sender, e);         }          private void OnPropertyChanged(object sender, PropertyChangedEventArgs e)         {             var propertyName = e.PropertyName;             if (!PropertyBindings.ContainsKey(propertyName)) return;             var binding = PropertyBindings[propertyName];             if (binding != null) binding.InvokePropertyChanged(sender, e);         }     } 

Теперь мы вооружены по полной, но нет предела совершенству. Как правило, вью-модель связывается со своим представлением (вью) в C# коде, но насколько бы было красиво эту привязку осуществлять непосредственно в xaml! Помните про наш отказ от инжекций в конструктор? Вот он нам и даёт такую возможность. Напишем небольшое расширение для разметки*.

    public class StoreExtension : MarkupExtension     {         public StoreExtension(Type itemType)         {             ItemType = itemType;         }          [ConstructorArgument("ItemType")]         public Type ItemType { get; set; }          public override object ProvideValue(IServiceProvider serviceProvider)         {             var service = (IProvideValueTarget) serviceProvider.GetService(typeof (IProvideValueTarget));             var frameworkElement = service.TargetObject as FrameworkElement;             var dependancyProperty = service.TargetProperty as DependencyProperty;             var methodInfo = typeof(Store).GetMethod("OfType").MakeGenericMethod(ItemType);             var item = methodInfo.Invoke(null, new object[] { new object[0] });             if (frameworkElement != null &&                 dependancyProperty == FrameworkElement.DataContextProperty &&                 item is ViewModel)             {                 var viewModel = (ViewModel) item;                 frameworkElement.CommandBindings.AddRange(viewModel.CommandBindings.Values);                 var window = frameworkElement as Window;                 if (window != null)                     viewModel.OnClosing += (o, e) => { if (!e.Cancel) window.Close(); };                 frameworkElement.Initialized += (sender, args) => frameworkElement.DataContext = viewModel;                 return null;             }              return item;         }     } 

Вуаля, готово!

DataContext="{Store viewModels:MainViewModel}"

Обращаю внимание на то, что во время привязки у контрола изменяется не только DataContext, но и заполняется коллекция CommandBindings, значениями из вью-модели.

(* чтобы перед расширениями для разметки не писать префиксов вроде "{foundation:Store viewModels:MainViewModel}", они должны быть реализованы в отдельном проекте и в этом же проекте в файде AssemblyInfo.cs нужно написать что-то вроде

[assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2006/xaml/presentation", "Foundation")] [assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2006/xaml/presentation", "Foundation.Converters")] [assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2006/xaml/presentation", "Foundation.MarkupExtensions")]

)

Подобным образом приукрасим привязку к индексам, о которой речь шла выше.

    public class ViewModelExtension : MarkupExtension     {         private static readonly BooleanConverter BooleanToVisibilityConverter = new BooleanConverter         {             OnTrue = Visibility.Visible,             OnFalse = Visibility.Collapsed,         };          private FrameworkElement _targetObject;         private DependencyProperty _targetProperty;          public ViewModelExtension()         {         }          public ViewModelExtension(string key)         {             Key = key;         }          public ViewModelExtension(string key, object defaultValue)         {             Key = key;             DefaultValue = defaultValue;         }          public string Key { get; set; }         public string StringFormat { get; set; }         public string ElementName { get; set; }         public object DefaultValue { get; set; }         public object FallbackValue { get; set; }         public object TargetNullValue { get; set; }         public IValueConverter Converter { get; set; }         public RelativeSource RelativeSource { get; set; }          public override object ProvideValue(IServiceProvider serviceProvider)         {             var service = (IProvideValueTarget) serviceProvider.GetService(typeof (IProvideValueTarget));             _targetProperty = service.TargetProperty as DependencyProperty;             _targetObject = service.TargetObject as FrameworkElement;             if (_targetObject == null || _targetProperty == null) return this;              var key = Key;             if (_targetProperty == UIElement.VisibilityProperty && string.IsNullOrWhiteSpace(key))                 key = string.Format("Show{0}",                                     string.IsNullOrWhiteSpace(_targetObject.Name)                                         ? _targetObject.Tag                                         : _targetObject.Name);              key = string.IsNullOrWhiteSpace(key) ? _targetProperty.Name : key;             if (!string.IsNullOrWhiteSpace(StringFormat)) Key = string.Format(StringFormat, _targetObject.Tag);              var index = DefaultValue == null ? key : key + "," + DefaultValue;             var path = string.IsNullOrWhiteSpace(ElementName) && RelativeSource == null                            ? "[" + index + "]"                            : "DataContext[" + index + "]";              if (_targetProperty == UIElement.VisibilityProperty && Converter == null)                 Converter = BooleanToVisibilityConverter;              var binding = new Binding(path) {Mode = BindingMode.TwoWay, Converter = Converter};             if (ElementName != null) binding.ElementName = ElementName;             if (FallbackValue != null) binding.FallbackValue = FallbackValue;             if (TargetNullValue != null) binding.TargetNullValue = TargetNullValue;              if (RelativeSource != null) binding.RelativeSource = RelativeSource;              _targetObject.SetBinding(_targetProperty, binding);             return binding.ProvideValue(serviceProvider);         }     } 

В xaml можно писать так:

Width="{ViewModel DefaultValue=800}"

Итоги

Пожалуй, достаточно, я преподнёс много информации в сжатом виде, поэтому для полноты понимания лучше ознакомиться с примером проекта.

Резюмируя всё сказанное, можно выделить следующие плюсы подхода:
— чистый, лаконичный и структурированный код. Интерфейсная логика, слабо связанная с бизнес-логикой, инкапсулируется внутри базовых классов вью-модели, в то время как конкретная реализация вью-модели содержит именно ту логику, которая тесно связана с бизнес-правилами;
— простота и универсальность решения. Ко всему прочему, сериализация позволяет очень гибко настраивать интерфейс приложения с помощью конфигурационных файлов;
— удобная реализация валидации через интерфейс IDataErrorInfo.

Минусы:
— отказ от инжекций в конструктор (хотя это и не обязательное требование);
— некоторая неявность решения для человека, не знакомого с ним.

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

Очень надеюсь, что статья окажется для вас полезной! Спасибо за внимание!

P.S. Не знаю точно, как в Silverlight, но на WinPhone-платформе есть некоторые ограничения (отсутствуют расширения разметки, RoutedCommands и CommandBindings), однако при большом желании, думаю, это можно обойти.

P.P.S. Как я уже сказал выше, все описанные методы, применены мной при создании полноценного текстового редактора. Те, кому интересно, что же в итоге получилось за творение, могут найти его по этой ссылке. Мне кажется, что в программировании и поэзии очень много общего: также как мастер слова способен несколькими фразами выразить то, на что у обычного человека уйдет не один абзац, так и опытный программист решает сложную задачу несколькими строками кода.

Вдохновения вам!

ссылка на оригинал статьи http://habrahabr.ru/post/208326/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *