Pull to refresh

Context Model Pattern via Aero Framework

Reading time26 min
Views9.6K
Context Model Pattern — концепция проектирования приложений, сочетающая черты архитектурных паттернов MVVM, MVP, MVC и основанная на наборе достаточно свободных, прогрессивных, отлично согласованных между собой положений. Краеугольными камнями являются представления, медиаторы, контекстные объекты и их коллекции, а основополагающая рекомендация — принцип прямых инжекций.

Aero Framework — открытая библиотека на языке C#, содержащая всю необходимую инфраструктуру для создания xaml-ориентированных приложений, соответствующих рассматриваемому паттерну. При её грамотном использовании исходный код получается беспрецедентно лаконичным, высокопроизводительным и интуитивно понятным даже начинающим разработчикам.

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

Внимательный и терпеливый читатель будет щедро вознаграждён знаниям.

image


Принцип контекстной фундаментальности (Context Foundation Principle)

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

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

Иначе говоря, для конечного пользователя имеет смысл лишь связка представление-контекстный объект. Отдельное представление само по себе бессмысленно с практической точки зрения, а объект в невизуальной форме пользователь воспринять не способен. Когда какой-либо объект ставится в соответствие представлению, он приобретает свойство контекстной фундаментальности, а представление — информативность.

CM-паттерн включает следующие базовые положения:

• с любым представлением может быть связан один или несколько контекстных объектов и наоборот (отношение многие ко многим). Любые взаимодействия представлений и контекстных объектов происходят посредством медиаторов трёх видов: привязки свойст, команд и инжекции контекста;

• вью-модели и модели — это сущности, имеющие разную специфику, но выполняющие общие функции моделирования предметной области и обладающие свойством контекстной фундаментальности, поэтому применение одного базового класса как к модели, так и к вью-модели становится вполне допустимым.


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

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

Значимость его состоит в том, что зачастую время жизни представлений короче времени жизни контекстных объектов (например, вью-моделей), а правильно реализованный медиатор, по возможности, содержит лишь слабые подписки и ссылки на взаимодействующие объекты, что не удерживает представления от сборки мусора и защищает от утечек памяти.

Медиаторы выполняют три основные функции:
• привязку свойств (Binding, StoreBinding)
• привязку команд (Context)
• инжекцию контекста (Store)

Касательно xaml к представлениям относятся как отдельные страницы разметки, так Control Templates и Data Templates. В любом случае, сложные представления сами состоят из более мелких и элементарных представлений — элементов визуального дерева.

Рассмотрим пример:
[DataContract] // serialization attributes
public class Person : ModelBase // DTO or ORM entity 
{
	[DataMember]
	public string FirstName { get; set; }
	
	[DataMember]
	public string LastName { get; set; }
	
	// Possible, contains Save Сhanges logic
}

public class PersonsViewModel : ViewModelBase
{
	public ObservableCollection<Person> Persons { get; set; }
	
	// Load, Save, Add, Remove items logic
}

<ListBox ItemsSource={Binding Persons}>
	<ListBox.ItemTemplate>
		<DataTemplate>
			<StackPanel>
				<TextBlock Text="{Binding FirstName}"/>
				<TextBlock Text="{Binding LastName}"/>
			</StackPanel>
		</DataTemplate>
	<ListBox.ItemTemplate>
</ListBox>


Здесь коллекция сущностей-моделей Person привязывается к элементу интерфейса и данные модели напрямую отображаются на интерфейсе. Другая ситуация:

public class TextDocumentViewModel : ViewModelBase
{
	public string FileName { get; set; }
	
	public string Text { get; set; }
	
	// Load, Save document logic
}

public class DocumentsViewModel : ViewModelBase
{
	public ObservableCollection<TextDocumentViewModel> Documents { get; set; }
	
	// Open, Save, Create, Close documents logic
}

<TabControl 
	ItemsSource={Binding Persons} 
	ItemTemplate={Binding FileName, Converter={StaticResource TitleConverter}}>
	<TabControl.ContentTemplate>
		<DataTemplate>
			<StackPanel>
				<TextBox 
					AcceptsTab="True" 
					AcceptsReturn="True"
					Text="{Binding Text, Mode=TwoWay}"/>
			</StackPanel>
		</DataTemplate>
	<TabControl.ContentTemplate>
</TabControl>

Теперь TextDocumentViewModel агрегирует в себе данные модели Text и FileName, то есть, по сути, также является моделью, хотя и наследуется от класса ViewModelBase, однако в обоих случаях Person и TextDocumentViewModel являются объектами дата-контекста для DataTemplate.

Но не указывает ли это на то, что между вью-моделью и моделью всё-таки может быть больше общих черт, чем принято считать обычно? Что если ModelBase и ViewModelBase обобщить в единую сущность ContextObject?

Посмотрим, какими свойствами должен обладать подобный объект. ViewModelBase, как правило, так или иначе реализует интерфейс INotifyPropertyChanged для уведомления элементов интерфейса об изменении значений свойст. ModelBase также иногда может реализовывать этот интерфейс и обычно поддерживает сериализацию. Весь этот функционал имплементируется классом ContextObject, поэтому, по возможности, рекомендуется использовать его как основу не только для вью-моделей, но и самих моделей.

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

Кроме того, в основах CM-паттерна лежит набор дополнительных рекомендаций, о которых сейчас пойдёт речь.

Простота — залог удачной архитектуры

В первом разделе, чтобы пробудить интерес читателя к материалу и дать вводное понятие о базовых моментах, будет продемонстрирована общая схема построения приложения с использованием библиотеки Aero Framework (исходные коды с примером приложения, веб-сайт) и кратко освещён следующий круг важных вопросов:

• принцип прямых инжекций (Direct Injections Principle)
• независимые инжекции путём экспанирования (Independent Injections via Exposable Pattern)
• командно-ориентированная навигация (Command-Oriented Navigation)
• динамическая локализация (Dynamic Localizing)
• контекстные команды и триггеры (Context Commands & Triggers)
• нотификация и валидация свойств (Property Notification & Validation)
• «умное» состояние (Smart State)

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

Создадим LoginViewModel и ProductsViewModel, унаследовав их от класса ContextObject и интерфейса IExposable, а также LoginView для авторизации пользователя, ProductsView со списком продуктов для выбора и ProductView с детальной информацией о выбранном продукте и набором действий по нему.

[DataContract]
public class LoginViewModel : ContextObject, IExposable
{
	[DataMember]
	public string Name
	{
		get { return Get(() => Name); }
		set { Set(() => Name, value); }
	}
	
	public string Password
	{
		get { return Get(() => Name); }
		set { Set(() => Name, value); }
	}

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

	public virtual void Expose()
	{
		this[() => Name].PropertyChanged += (sender, args) => Context.Login.RaiseCanExecuteChanged();
		this[() => Password].PropertyChanged += (sender, args) => Context.Login.RaiseCanExecuteChanged();
		
		this[() => Name].Validation += () =>
			Error = Name == null || Name.Length < 3 
				? Unity.App.Localize("InvalidName")
				: null;
				
		this[() => Password].Validation += (sender, args) => 
			Error = Name == null || Name.Length < 4 
				? Unity.App.Localize("InvalidPassword")
				: null;

		this[Context.Login].CanExecute += (sender, args) =>
		{
			args.CanExecute = string.IsNullOrEmpty(Error); // Error contains last validation fail message
		};

		this[Context.Login].Executed += async (sender, args) =>
		{
			try
			{
				UserData = await Bank.Current.GetUserData();
				Navigator.GoTo(args.Parameter);
			}
			catch (Exception exception)
			{
				Error = Unity.App.Localize(exception.Message);
			}
		};

		this[Context.Logout].Executed += (sender, args) =>
		{
			UserData = null;
			Navigator.RedirectTo(args.Parameter);
		};
	}
}

<!--LoginView.xaml-->
<View DataContext="{Store Key=viewModels:LoginViewModel}">
	<StackPanel>
		<TextBlock Text="{Localizing Key=Name}"/>
		<TextBox Text="{Binding Name, Mode=TwoWay}"/>
		<TextBlock Text="{Localizing Key=Password}"/>
		<PasswordBox Password="{Binding Password, Mode=TwoWay}"/>
		
		<Button 
			Command="{Context Key=Login}" 
			CommandParameter="{x:Type ProductsView}" 
			Content="{Localizing Key=Login}"/>
		<TextBlock Text="{Binding Error}"/>
	</StackPanel>
</View>

public class ProductsViewModel : ContextObject, IExposable
{
	public Product CurrentProduct
	{
		get { return Get(() => CurrentProduct); }
		set { Set(() => CurrentProduct, value); }
	}

	public ContextSet<Product> Products { get; set; }

	public virtual void Expose()
	{
		Products = new ContextSet<Product>();
		
		this[() => Name].PropertyChanged += (sender, args) => Context.Login.RaiseCanExecuteChanged();
		this[() => Password].PropertyChanged += (sender, args) => Context.Login.RaiseCanExecuteChanged();
		this[() => CurrentProduct].PropertyChanged += (sender, args) => Context.Get("GoToProduct").RaiseCanExecuteChanged();
		
		this[Context.Get("GoToProduct")].CanExecute += (sender, args) => args.CanExecute = CurrentProduct != null;
		this[Context.Get("GoToProduct")].Executed += (sender, args) => Navigator.GoTo(args.Parameter);
		this[Context.Refresh].Executed += async (sender, args) =>
		{
			try
			{
				var products = await Bank.Current.GetProducts();
				CurrentProduct = null;
				Products.Clean();
				products.ForEach(p => Products.Add);
			}
			catch (Exception exception)
			{
				Error = Unity.App.Localize(exception.Message);
			}
		};
	}
}

<!--ProductsView .xaml-->
<View DataContext="{Store Key=viewModels:ProductsViewModel}">
	<Attacher.ContextTriggers>
		<ContextTrigger 
			EventName="Loaded" 
			UseEventArgsAsCommandParameter="False"
			Command="{Context Key=Refresh, StoreKey=viewModels:ProductsViewModel}"/>
	</Attacher.ContextTriggers>

	<StackPanel>
	
		<StackPanel DataContext="{Store Key=viewModels:LoginViewModel}">
			<TextBlock Text="{Localizing Key=UserName}"/>
			<TextBlock Text="{Binding Path=UserData.FullName}"/>
			
			<Button 
				Command="{Context Key=Logout}" 
				CommandParameter="{x:Type LoginView}"
				Content="{Localizing Key=Logout}"/>
		</StackPanel>
		
		<ListBox 
			ItemsSource="{Binding Products}"
			SelectedItem="{Binding CurrentProduct, Mode=TwoWay}">
			<ListBox.ItemTemplate>
				<DataTemplate>
					<StackPanel>
						<TextBlock Text="{Binding Name}"/>
						<TextBlock Text="{Binding Balance, StringFormat=D2}"/>
						<TextBlock Text="{Binding CurrencyCode}"/>
						
						<StackPanel 
							Visibility="{StoreBinding StoreKey=viewModels:SettingsViewModel, 
									Path=ShowDetails, Converter="{StaticResource TrueToVisibleConverter}"}">
							<TextBlock Text="{Localizing Key=ExpireDate}"/>
							<TextBlock Text="{Binding ExpireDate}"/>
						</StackPanel>
					</StackPanel>
				</DataTemplate>
			</ListBox.ItemTemplate>
		</ListBox>
		
		<Button
			Content="{Localizing Key=Next}"
			Command="{Context Key=GoToProduct}" 
			CommandParameter="{x:Type ProductView}"/>

	<StackPanel>
</View>

<!--ProductView .xaml-->
<View DataContext="{Store Key=viewModels:ProductsViewModel}">
	<StackPanel DataContext="{Binding CurrentProduct}">
		<TextBlock Text="{Binding Name}"/>
		<TextBlock Text="{Binding Balance, StringFormat=D2}"/>
		<TextBlock Text="{Binding CurrencyCode}"/>
		
		<TextBlock Text="{Localizing Key=ExpireDate}"/>
		<TextBlock Text="{Binding ExpireDate}"/>
		
		<StackPanel DataContext="{Store Key=viewModels:NavigatorViewModel}">
			<Button 
				Content="{Localizing Key=MakePayment}"
				Command="{Context Key=Navigate}"
				CommandParameter="{x:Type PaymentView}"/>
			<Button 
				Content="{Localizing Key=MakeTransfer}"
				Command="{Context Key=Navigate}"
				CommandParameter="{x:Type TransferView}"/>
		</StackPanel>
	</StackPanel>
</View>

Итак, теперь же обратим внимание на ключевые моменты.

1) Принцип прямых инжекций (Direct Injections Principle)

Связывание вью-моделей с представлениями происходит непосредственно в xaml путём прямого инжектирования контекстных объектов с использованием расширений привязки/разметки (Binding/Markup Extensions).

DataContext="{Store Key=viewModels:ProductsViewModel}"
Visibility="{StoreBinding StoreKey=viewModels:SettingsViewModel, 
		Path=ShowDetails, Converter="{StaticResource TrueToVisibleConverter}"}"

В зависимости от платформы, например, Windows Desktop или Windows Phone, синтаксис может отличаться из-за деталей имплементации парсера разметки, но принцип остаётся прежним.

На Windows Desktop допустимо писать следующими способами:

DataContext="{Store viewModels:ProductsViewModel}"
DataContext="{Store Key=viewModels:ProductsViewModel}"

Visibility="{StoreBinding Path=ShowDetails, KeyType=viewModels:SettingsViewModel, Converter="{StaticResource TrueToVisibleConverter}"
Visibility="{StoreBinding ShowDetails, KeyType=viewModels:SettingsViewModel, Converter="{StaticResource TrueToVisibleConverter}"}"
Visibility="{Binding ShowDetails, Source={Store viewModels:SettingsViewModel}, Converter="{StaticResource TrueToVisibleConverter}"}"

Windows Phone допускает лишь такой вид:

DataContext="{m:Store Key=viewModels:ProductsViewModel}"

Visibility="{m:StoreBinding StoreKey=viewModels:SettingsViewModel, Path=ShowDetails, Converter="{StaticResource TrueToVisibleConverter}"

Синтаксис C#-кода выглядит крайне просто:

var anyViewModel = Store.Get<AnyViewModel>();

Заметим, что инжектирование допустимо не только в DataContext контрола, но и непосредственно в привязку (Binding)! Второй способ очень выручает в некоторых ситуациях, например, при отображении списковых данных, когда контекстом уже является элемент коллекции, а также в тех случаях, когда разные свойства контрола должны работать с различными источниками (мультиконтекстное поведение).

Стоит обратить внимание на ProductsView, поскольку оно состоит из двух логических частей, связанных с различными вью-моделями. Первая часть несёт информацию о пользователе и позволяет выполнить Logout, а вторая непосредственно отображает список продуктов пользователя. В большинстве классических реализации потребовалось бы инжектирование LoginViewModel в ProductsViewModel, однако в нашем случае решание получилось более красивым, поскольку вью-модели сохранили значительную независимость друг от друга.

Однако принцип прямых инжекций является более широким. Например, он позволяет также красиво реализовать Bindable Dependancy Converters, о чём будет подробно рассказано ниже.

2) Независимые инжекции путём экспанирования (Independent Injections via Exposable Pattern)

Важным моментом является на наследование от интерфейса IExposable, который отвечает за отложенную инициализацию объекта. Это значит, что ссылка на экземпляр становится доступной ещё до момента инициализации, что диаметрально противоположно применению Disposable-паттерна (интерфейс IDisposabe), который позволяет освобождать ресурсы до того, как все ссылки на объект будут утрачены и сборщик мусора ликвидирует его. Такая ленивая инициализация имеет ряд преимуществ перед классической с помощью конструктора, однако и второй вариант не исключается, более того, их можно использовать совместно, как, например, в финализаторе вызывать метод Dispose.

C помощью ленивой инициализации возможно реализовать принцип независимых инжекций, который позволяет проинициализировать два взаимосвязанных объекта, содержащих взаимные ссылки друг на друга.

public class ProductsViewModel : ContextObject, IExposable
{
	public virtual void Expose()
	{
		var settingsViewModel = Store.Get<SettingsViewModel>();
		
		this[Context.Get("AnyCommand")].Executed += (sender, args) => 
		{
			// safe using of settingsViewModel
		}
	}
}

public class SettingsViewModel : ContextObject, IExposable
{
	public virtual void Expose()
	{
		var productsViewModel = Store.Get<SettingsViewModel>();
		
		this[Context.Get("AnyCommand")].Executed += (sender, args) => 
		{
			// safe using of productsViewModel
		}
	}
}

В случае использования конструктора вместо Exposable-паттерна при попытке создания одной из вью-моделей возникло бы рекурсивное переполнение стека. Конечно, нужно помнить о том ограничении, что в сам момент инициализации (выполнение метода Expose) пользоваться другой непроинициализированной вью-моделью иногда небезопасно, но на практике такие ситуации встречаются редко и скорее указывают на ошибку в проектировании дизайна классов.

Если планируется дальнейшее наследование, то метод Expose следует использовать с модификатором virtual, чтобы в наследуемых классах его можно было переопределить и обеспечить полиморфное поведение.

3) Командно-ориентированная навигация (Command-Oriented Navigation)

Пожалуй, это довольно интересный аспект. Для того, чтобы обеспечить возможность повторного использования одинаковой бизнес-логики вью-моделей в нескольких проектах, например, под разные платформы, необходимо в них избавиться от любых ссылок на UI-классы. Но как же тогда организовать механизм выбора представления в зависимости от выполнения определённых бизнес-правил? CM-паттерн даёт на это свою рекомендацию — нужно использовать механизм команд, а тип представления или uri передавать в качестве параметра команды.

<Button 
	Content="{Localizing GoToPayment}"
	Command="{Context Key=GoTo}"
	CommandParameter="{x:Type PaymentView}">

<Button 
	Content="{Localizing GoToPayment}"
	Command="{Context Key=GoTo}"
	CommandParameter="/Views/PaymentView.xaml">

public class AnyViewModel : ContextObject, IExposable
{
	public virtual void Expose()
	{
		// this[Context.Get("GoTo")].CanExecute += (sender, args) => args.CanExecute = 'any condition';
		
		this[Context.Get("GoTo")].Executed += (sender, args) => Navigator.GoTo(args.Parameter);
	}
}

Класс Navigator принимает этот параметр и на основе него выполняет навигацию на нужное представление. Следующее же представление с помощью механизма прямых инжекций запросто связывается с необходимым контекстным объектом (вью-моделью), поэтому утрачивается всякая необходимость в непосредственной передаче каких-либо служебных параметров во процессе навигации, ведь любое представление способно получить доступ к любому контекстному объекту. Возможность перехода на тот или иной экран регулируется событием CanExecute у команды.

Всё получилось весьма элегантно и просто, ничего лишнего. Однако можно возразить, что мы заняли параметр команды и ничего другого передать не сможем. Но снова есть красивый решение: воспользуемся очень примитивным классом Set:

public class Set : List<object>
{
}

Он позволяет прямо в xaml создавать коллекции различных элементов. К примеру, поместив в ресурсы запись вида:

<Set x:Key="ParameterSet">
	<system:String>/Views/AnyView.xaml</system:String>
	<system:String>SecondParaneter</system:String>
</Set>

запросто можно передать несколько аргументов в команду:

<Button 
	Content="{Localizing GoToAnyView}"
	Command="{Context Key=GoTo}"
	CommandParameter="{StaticResource ParameterSet}">


4) Динамическая локализация (Dynamic Localizing)

Локализация реализуется также посредством использования расширений привязки/разметки, что позволяет реализовать горячую смену языка во время работы приложения. То есть источник локализованных строк представляет собой инжектируемый контекстный объект из которого по ключам извлекаются необходимые значения.

Для Windows Desktop допустим очень простой синтаксис:

Text="{Localizing Name}"
Text="{Localizing Key=Name}"

Для Windows Phone немного сложнее, но тоже достаточно элементарный:

Text="{m:Localizing Key=Name}"

В C#-коде необходимо в нужный момент (при загрузке приложения или смене языка) установить менеджер ресурсов:

Localizing.DefaultManager.Source = English.ResourceManager;

А локализованную строку получить можно так:

var localizedValue = Unity.App.Localize("KeyValue");


5) Контекстные команды и триггеры (Context Commands & Triggers)

В классическом desktop WPF детально разработана мощная и удобная концепция маршрутизируемых команд (Routed Commands), однако на других платформах она не поддерживается, отчасти из-за технических различий, поэтому возник вопрос, возможно ли создать нечто универсальное, синтаксически и идеологически схожее, и в то же время совместимое с маршрутизируемыми командами.

В результате родилась идея контекстных команд. Как было показано выше, использовать её до крайне просто и, более того, она прекрасно сочетаема с Routed Commands.

<Button 
	Content="{Localizing GoToPayment}"
	Command="{Context Key=GoTo}"
	CommandParameter="/Views/PaymentView.xaml"/>

<Button 
	Content="{Localizing New}"
	Command="New"/>

	public class AnyViewModel : ContextObject, IExposable
	{
		public virtual void Expose()
		{
			// Context Command
			this[Context.Get("GoTo")].CanExecute += (sender, args) => args.CanExecute = 'any condition';
			this[Context.Get("GoTo")].Executed += (sender, args) => Navigator.GoTo(args.Parameter);

			// Routed Command
			this[ApplicationCommands.New].CanExecute += (sender, args) => args.CanExecute = 'any condition';
			this[ApplicationCommands.New].Executed += (sender, args) => AddNewDocument();
		}
}

Статический класс Context содержит несколько заранее объявленных названий команд, а при необходимости туда можно добавить свои. Записи this[Context.Make] и this[Context.Get(«Make»)] эквивалентны друг другу. Стоит отметить, что реализация контекстных команд является безопасной в плане утечек памяти, поскольку в некоторых ситуациях подписка контрола на событие CanExecuteChanged у команды может удерживать интерфейс от сборки мусора, что не сразу очевидно.

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

<Button
	DataContext="{Store viewModels:FirstViewModel}"
	Command="{Context Key=Make}">

<Button
	DataContext="{Store viewModels:FirstViewModel}"
	IsEnabled="{Binding CanMake}"
	Command="{Context Key=Make, StoreKey=viewModels:SecondViewModel}">

В первом случае команда Make исполнится у FirstViewModel, а во втором у SecondViewModel.

Необходимо также упомянуть о контекстных триггерах команд. Зачастую какое-либо действие или ряд действий нужно выполнить при возникновении определённого события контрола, например, при загрузке страницы нужно запросить или обновить бизнес-данные. Реализуется подобная функциональность с помощью триггеров команд, причём поддерживаются все типы событий, а не только Routed Events.

<Window>
	<Attacher.ContextTriggers>
		<Set>
			<ContextTrigger 
				EventName="Loaded"
				Command="{Context Refresh, StoreKey=viewModels:AppViewModel}"/>
			<ContextTrigger 
				EventName="Closing" 
				UseEventArgsAsCommandParameter="True"
				Command="{Context Exit, StoreKey=viewModels:AppViewModel}"/>
		</Set>
	</Attacher.ContextTriggers>
	...
</Window>

В данном примере важно обратить внимание на то, что триггеры не являются наследниками визуального дерева, поэтому контекстный объект необходимо инжектировать прямо в медиатор команды — StoreKey=viewModels:AppViewModel. Также существует возможность передавать в качестве параметра команды аргумент события. Поэтому при закрытии окна, событие Closing, присутствует возможность отмены действия путём установки args.Cancel = true.

6) Нотификация и валидация свойств (Property Notification & Validation)

Чтобы уведомлять интерфейс и прочие объекты программы об изменении свойств существует интерфейс INotifyPropertyChanged. Aero Framework предусматривает ряд удобных механизмов для подобных целей. Во-первых, необходимо унаследоваться от класса ContextObject, после чего станет доступной возможность использования лаконичного и удобного синтаксиса.

public class LoginViewModel : ContextObject, IExposable
{
	// Auto with manual notification
	public string Name
	{
		get { return Get(() => Name); }
		set { Set(() => Name, value); }
	}
	
	// Only manual notification
	public string Password { get; set; }
	
	public virtual void Expose()
	{
		this[() => Name].PropertyChanging += (sender, args) => 
		{
			// do anythink
		};
		
		this[() => Name].PropertyChanged += (sender, args) => 
		{
			// do anythink
		};
	}
}

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

RaisePropertyChanging(() => Password);
RaisePropertyChanged(() => Password);

RaisePropertyChanging("Name");
RaisePropertyChanged("Name");

Запись this[() => Name].PropertyChanged позволяет избежать громоздких if-else-конструкций, кроме того, подобным образом легко подписаться на изменение свойств извне:

var loginViewModel = Store.Get<LoginViewModel>();
loginViewModel[() => Name].PropertyChanging += (sender, args) => 
{
	// do anythink
};

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

Валидация значений свойств также удобно выглядит с применением данного подхода и реализацией интерфейса IDataErrorInfo.

this[() => Name].Validation += (sender, args) =>
	Error = Name == null || Name.Length < 3 
		? Unity.App.Localize("InvalidName")
		: null;
			
this[() => FontSize].Validation += () =>
	4.0 < FontSize && FontSize < 128.0
		? null
		: Unity.App.Localize("InvalidFontSize");


7) «Умное» состояние (Smart State)

Теперь мы подошли к весьма необычному, но в то же время полезному механизму сохранения состояния. Aero Framework позволяет очень изящно и непревзойденно лаконично решать задачи подобного рода.

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

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

Unity.AppStorage = new AppStorage();
Unity.App = new AppAssistent();

По умолчанию сериализация происходит в файлы, но легко можно создать свою имплементацию и сохранять сериализованные объекты, например, в базу данных. Для этого нужно унаследоваться от интерфейса Unity.IApplication (по умолчанию имплементируется AppStorage). Что касается интерфейса Unity.IApplication (AppAssistent), то он необходим для культурных настроек при сериализации и в большинстве случаев можно ограничиться его стандартной реализацией.

Для сохранения состояния любого объекта, поддерживающего сериализацию, достаточно вызвать аттачед-метод Snapshot, либо воспользоваться вызовом Store.Snapshot, если объект находится в общем контейнере.

Мы разобрались с сохранением логического состояния, но ведь зачастую возникает необходимость хранения и визуального, к примеру, размеров и положения окон, состояния контролов и других параметров. CM-паттерн предлагает нестандартное, но невероятно удобное решение. Что если хранить такие параметры в контекстных объектах (вью-моделях), но не в виде отдельных свойств для сериализации, а неявно, в виде словаря, где ключом является имя «мнимого» свойства?

На основе данной концепции родилась идея smart-свойств. Значение smart-свойства должно быть доступно через индексатор по имени-ключу, как в словаре, а классический get или set являются опциональными и могут отсутствовать! Эта функциональность реализована в классе SmartObject, от которого наследуется ContextObject, расширяя её.

Достаточно всего лишь написать в десктоп-версии:

public class AppViewModel : SmartObject // ContextObject
{}

<Window 
	x:Class="Sparrow.Views.AppView"
	xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
	xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
	xmlns:viewModels="clr-namespace:Sparrow.ViewModels"
	DataContext="{Store Key=viewModels:AppViewModel}"
	WindowStyle="{ViewModel DefaultValue=SingleBorderWindow}"
	ResizeMode="{Binding '[ResizeMode, CanResizeWithGrip]', Mode=TwoWay}"
	Height="{Binding '[Height, 600]', Mode=TwoWay}" 
	Width="{ViewModel DefaultValue=800}"
	Left="{ViewModel DefaultValue=NaN}"
	Top="{Binding '[Top, NaN]', Mode=TwoWay}"
	Title="{ViewModel DefaultValue='Sparrow'}"
	Icon="/Sparrow.png"
	ShowActivated="True"
	Name="This"/>

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

Однако несколько ограниченный Window Phone, в отличии от десктоп-версии, не позволяет осуществлять привязку через индексатор, но это ограничение обходится путём внедрения в smart-объект специального общего свойства для привязки, к примеру, Indexer и использования специального расширения разметки ViewModel. Тогда синтаксис выглядит немного сложнее, но всё ещё достаточно понятно:
Background="{m:ViewModel Index=UseTable, FinalConverter={StaticResource BackgroundConverter}, Mode=OneWay,
			StoreKey=viewModels:SettingsViewModel, DefaultValue=True}"

SelectedIndex="{m:ViewModel Index=SelectedIndex, StoreKey=viewModels:AppViewModel}"


Стоит понимать, что при изменении одного из таких неявных свойств через индексатор или общее свойство Indexer, уведомления об обновлении получают и другие контролы, которые к изменившемуся в данный момент свойству не имеют отношения, и перепроверяют свои значения. Зачастую в этом ничего страшного нет и никакого сколько-нибудь заметного падения производительности не происходит, однако бывают крайне редкие ситуации, когда этот эффект может оказаться нежелательным. Чтобы его избежать Aero Framework позволяет изолировать «проблемное» свойство от остальных. Способ такой изоляции присутствует в тестовом проекте к библиотеке, а именно, обратить внимание нужно на привязку свойства SelectedIndex.
<TabControl 
	Grid.Row="2" 
	Name="TabControl"
	MouseMove="UIElement_OnMouseMove"
	ItemsSource="{Binding Documents}" 
	SelectedItem="{Binding Document}"
	SelectedIndex="{Binding '[DocumentIndex, 0, True].Value', Mode=TwoWay}">

Если её описать слегка другим образом

SelectedIndex="{Binding '[DocumentIndex, 0]', Mode=TwoWay}"

и закрыть всё вкладки в тестовом приложении, то при перемещении главного окна в дебаг-режиме с Visual Studio можно будет заметить притормаживания, связанные с тем, что TabControl на каждое обновление индексных свойств генерирует исключение, которое трейсится в «Output» Visual Studio. Такие ситуации скорее редкость и исключение из правил, однако применение изоляции запросто решает проблему.

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

Итоги

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

Что ещё нужно знать?

Во-первых, полезно иметь представление о нюансах в работе различных сериализаторов. Aero Framework по умолчанию использует DataContractJsonSerializer, но при необходимости можно использовать другой. Важно помнить, что этот контрактные сериализаторы при десериализации объекта не вызывает конструктор, а также не выполняют инициализацию полей по умолчанию!

// Incorrect !!!
[DataContract]
public class AnyViewModel: ContextObject
{
	private AnyType Field1 = new AnyType();
	private AnyType Field2;

	public AnyViewModel()
	{
		Field2 = new AnyType();
	}
}

// Correct
[DataContract]
public class AnyViewModel : ContextObject
{
	private AnyType Field1;
	private AnyType Field2;

	public AnyViewModel()
	{
		Initialize();
	}

	// Called by serializer
	[OnDeserialized] // [OnDeserializing]
	public new void Initialize(StreamingContext context = default(StreamingContext))
	{
		Field1 = new AnyType();
		Field2 = new AnyType();
	}
}

// Correct (recomended)
[DataContract]
public class AnyViewModel : ContextObject, IExposable
{
	private AnyType Field1;
	private AnyType Field2;
	
	public virtual void Expose()
	{
		Field2 = new AnyType();
		Field2 = new AnyType();
	}
}


Также иногда может потребоваться добавление незнакомого сериализатору типа в коллекцию KnownTypes.
ContractFactory.KnownTypes.Add(typeof(SolidColorBrush));
//ContractFactory.KnownTypes.Add(typeof(MatrixTransform));
//ContractFactory.KnownTypes.Add(typeof(Transform));
ContractFactory.KnownTypes.Add(typeof(TextViewModel));


• Binding Extensions

На некоторых реализациях xaml-платформ существует возможность создавать свои расширения разметки (Markup Extensions) путём наследования от одноимённого класса. Однако такая возможность присутствует не везде. Но всё же в большинстве случаев разрешено наследования от класса Binding.

using System;
using System.Globalization;
using System.Windows.Data;

namespace Aero.Markup.Base
{
	public abstract class BindingExtension : Binding, IValueConverter
	{
		protected BindingExtension()
		{
			Source = Converter = this;
		}

		protected BindingExtension(object source) // set Source to null for using DataContext
		{
			Source = source;
			Converter = this;
		}

		protected BindingExtension(RelativeSource relativeSource)
		{
			RelativeSource = relativeSource;
			Converter = this;
		}

		public abstract object Convert(object value, Type targetType, object parameter, CultureInfo culture);

		public virtual object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
		{
			throw new NotImplementedException();
		}
	}
}

Такой класс почти не уступает привычному MarkupExtension, а иногда даже более удобен и предпочтителен в использовании, поскольку остаётся доступным функционал обычного Binding, в котором уже предусмотрена защита от утечек памяти.

• Bindable Dependency Converters

Рано или поздно разработчик, имеющий постоянное дело с xaml, сталкивается с вопросом, возможно ли создать конвертер, поддерживающий привязку каких-либо параметров. Но даже если помимо реализации интерфейса IValueConverter произвести наследование от класса DependencyObject и объявить в конвертере DependencyProperty, то привязка работать в большинстве случаев не станет, поскольку конвертер не является элементом визуального дерева! Конечно, возможно пойти ещё дальше и создать гибрид контрол-конвертер, но такое экзотическое решения вряд ли можно назвать красивым, да и спектр его применения ограничен.

Но, как упоминалось выше, на выручку приходит принцип прямых инжекций, ведь нично не мешает применить StoreBinding к Dependency Converter.

<BooleanConverter 
	x:Key="BindableConverter" 
	OnTrue="Value1" 
	OnFalse="Value2" 
	OnNull="{StoreBinding StoreKey=viewModels: SettingsViewModel, Path=AnyValue3Path}"/>

Switch Converter

Нередко в больших проектах приходится создавать много различных однотипных классов-конвертеров логика которых очень напоминает поведение оператора switch. Но на самом деле, во многих случаях можно ограничится применением универсального Switch Converter:

<SwitchConverter Default="ResultValue0" x:Key="ValueConverter1">
	<Case Key="KeyValue1" Value="ResultValue1"/>
	<Case Key="KeyValue2" Value="ResultValue2"/>
</SwitchConverter>

Более того, свойства этого конвертера (в том числе конструкции Case) являются Dependency, то есть доступными для привязки с помощью StoreBinding! Кроме того поддерживается type mode, когда ключом является не само значение объекта, а тип:

<SwitchConverter TypeMode="True" Default="{StaticResource DefaultDataTemplate}" x:Key="InfoConverter">
	<Case Type="local:Person" Value="{StaticResource PersonDataTemplate}"/>
	<Case Type="local:PersonGroup" Value="{StaticResource PersonGroupDataTemplate}"/>
</SwitchConverter>

Получается, что такой конвертер запросто применим в качестве DataTemplateSelector даже там, где последний не поддерживается! Универсальность SwitchConverter позволяет покрыть огромное число случаев, стоит только применить к нему немного фантазии.

• Global Resources

Раз уж зашёл разговор о конвертерах, то стоит рассказать, как лучше всего организовать работу с ними. Прежде всего самые распространённые нужно вынести в отдельный словарь ресурсов:

<!--AppConverters .xaml-->
<ResourceDictionary 
	xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
	xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

	<BooleanConverter x:Key="NullToTrueConverter" OnNull="True" OnNotNull="False"/>
	<BooleanConverter x:Key="NullToFalseConverter" OnNull="False" OnNotNull="True"/>
	<BooleanConverter x:Key="NullToVisibleConverter" OnNull="Visible" OnNotNull="Collapsed"/>
	<BooleanConverter x:Key="NullToCollapsedConverter" OnNull="Collapsed" OnNotNull="Visible"/>
	<BooleanConverter x:Key="TrueToFalseConverter" OnTrue="False" OnFalse="True" OnNull="True"/>
	<BooleanConverter x:Key="FalseToTrueConverter" OnTrue="False" OnFalse="True" OnNull="False"/>
	<BooleanConverter x:Key="TrueToVisibleConverter" OnTrue="Visible" OnFalse="Collapsed" OnNull="Collapsed"/>
	<BooleanConverter x:Key="TrueToCollapsedConverter" OnTrue="Collapsed" OnFalse="Visible" OnNull="Visible"/>
	<BooleanConverter x:Key="FalseToVisibleConverter" OnTrue="Collapsed" OnFalse="Visible" OnNull="Collapsed"/>
	<BooleanConverter x:Key="FalseToCollapsedConverter" OnTrue="Visible" OnFalse="Collapsed" OnNull="Visible"/>
	<EqualsConverter x:Key="EqualsToCollapsedConverter" OnEqual="Collapsed" OnNotEqual="Visible"/>
	<EqualsConverter x:Key="EqualsToVisibleConverter" OnEqual="Visible" OnNotEqual="Collapsed"/>
	<EqualsConverter x:Key="EqualsToFalseConverter" OnEqual="False" OnNotEqual="True"/>
	<EqualsConverter x:Key="EqualsToTrueConverter" OnEqual="True" OnNotEqual="False"/>
	<AnyConverter x:Key="AnyToCollapsedConverter" OnAny="Collapsed" OnNotAny="Vsible"/>
	<AnyConverter x:Key="AnyToVisibleConverter" OnAny="Visible" OnNotAny="Collapsed"/>
	<AnyConverter x:Key="AnyToFalseConverter" OnAny="False" OnNotAny="True"/>
	<AnyConverter x:Key="AnyToTrueConverter" OnAny="True" OnNotAny="False"/>

</ResourceDictionary>

После чего необходимо прямо или косвенно смержить этот словарь с ресурсами в App.xaml, что позволит использовать основные конвертеры практически в любых xaml-файлах приложения без дополнительных действий. Такое добавление в глобальные ресурсы приложения полезно производить для любых более-менее общих вещей: цветов, кистей, шаблонов и стилей, — что помогает очень просто реализовать, к примеру, механизмы смены тем в приложении.

<Application 
	x:Class="Sparrow.App"
	xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
	xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
	StartupUri="Views/AppView.xaml">
	
	<Application.Resources>
		<ResourceDictionary>
			<ResourceDictionary.MergedDictionaries>
				<!--<ResourceDictionary Source="AppConverters.xaml"/>-->
				<ResourceDictionary Source="AppStore.xaml"/>
			</ResourceDictionary.MergedDictionaries>
		</ResourceDictionary>
	</Application.Resources>
	
</Application>

• Sets

Как уже упоминалось ранее, полезным может оказаться использование в xaml универсальной коллекции Set, применимой во множестве случаев. Иногда она позволяет избежать «многоэтажных конструкций», вынести общие моменты и сделать код более аккуратным.

<Set x:Key="EditMenuSet" x:Shared="False">
	<MenuItem
		Header="{Localizing Undo}"
		Command="Undo"/>
	<MenuItem
		Header="{Localizing Redo}"
		Command="Redo"/>
	<Separator/>
	<MenuItem
		Header="{Localizing Cut}"
		Command="Cut"/>
	<MenuItem
		Header="{Localizing Copy}"
		Command="Copy"/>
	<MenuItem
		Header="{Localizing Paste}"
		Command="Paste"/>
	<MenuItem
		Header="{Localizing Delete}"
		Command="Delete"/>
	<Separator/>
	<MenuItem
		Header="{Localizing SelectAll}"
		Command="SelectAll"/>
</Set>

<MenuItem Header="{Localizing Edit}" ItemsSource="{StaticResource EditMenuSet}"/>


Это всё?

Не совсем… Главный компонент — это фантазия разработчика, развивайте её. Исследуйте, эксперементируйте и созидайте!

P.S. Концепция индексных свойств перекликается с ассоциативными безатрибутными моделями данных, поэтому если хватит сил и любопытства, то имеет смысл изучить статью "Мысли".
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+4
Comments6

Articles