Разработка приложений для платформ WPF, Silverlight, Windows Store и Windows Phone, почти всегда подразумевает использование паттерна MVVM. Это закономерно, так как базовой философией этих платформ, является разделение представления (так же я буду использовать термин интерфейс пользователя) и остальной логики программы. Этот подход позволяет получить следующие преимущества:
- Разделение пользовательского интерфейса и логики представления: что позволяет дизайнерам работать над пользовательским интерфейсом, а программистам над бизнес логикой приложения используя для взаимодействия абстрактный интерфейс модели представления
- Расширенные возможности автоматизированного тестирования: отделение пользовательского интерфейса от остальной логики, позволяет полностью протестировать логику представления без ограничений накладываемых автоматизацией тестирования через пользовательский интерфейс
- Множественные представления для одной модели представления: одна модель представления может использоваться многими реализациями интерфейса пользователя. Например, сокращенный и полный вариант представления данных, интерфейс зависящий от прав пользователя. Возможность использовать одну реализацию модели представления на различных платформах
- Расширенные возможности повторного использования компонентов: так как модели представления отделены от реализации представления, возможны любые варианты их использования, наследование от базовых моделей, композиция нескольких моделей и т.п.
Разрабатывая приложения под платформу Windows Phone, я столкнулся с тем, большинство статей описывают базовую реализацию паттерна MWWM, которая обычно сводится к реализации в классе модели представления интерфейса INotifyPropertyChanged, создания простой реализации ICommand и простые сценарии связывания этих данных с представлением. К сожалению, остаются за рамками обсуждения такие важные вопросы как, реализация обобщенных классов с удобным интерфейсом, синхронизация потоков при асинхронном исполнении, навигация на уровне модели представления и многие другие.
Отдавая должное таким фреймворкам как MVVM Light и Prism, я предпочитаю в своих проектах использовать собственную реализацию данного паттерна, так как даже самые простые фреймворки излишне громоздки в силу своей универсальности.
Данная статья рассчитана на начинающих разработчиков, знакомых с основами разработки приложений для платформы Windows Phone, которые хотят более детально вникнуть в реализацию паттерна MVVM для платформы Windows Phone и научиться находить и применять более гибкие и простые решения для реализации приложений, построенных с его использованием. Возможно опытные разработчики найдут для себя статью интересной и предложат другие удобные решения описанных задач.
В качестве примера, создадим простое приложение «Кредитный калькулятор», вся функциональность которого будет реализована в Code-behind стиле.
Приложение содержит всего две страницы: главная страница приложения предназначена для ввод параметров кредита и страница подробной информации о рассчитанном кредите предназначена для отображения подробной информации о расчете. Исходные коды этого проекта доступны на GitHub ветка codebehind
Фрагмент файла разметки главной страницы MainPage.xaml
<Grid x:Name="ContentPanel" Grid.Row="1" Margin="12,0,12,0">
<ScrollViewer>
<StackPanel>
<StackPanel.Resources>
<Style TargetType="TextBlock" BasedOn="{StaticResource PhoneTextNormalStyle}"/>
</StackPanel.Resources>
<TextBlock Text="Сумма кредита" />
<TextBox x:Name="viewAmount" InputScope="Number" />
<TextBlock Text="Процентная ставка"/>
<TextBox x:Name="viewPercent" InputScope="Number" />
<TextBlock Text="Срок кредита" />
<TextBox x:Name="viewTerm" InputScope="Number"/>
<Button x:Name="viewCalculate" Content="расчитать" Click="CalculateClick" />
<Border x:Name="viewCalculationPanel" BorderBrush="{StaticResource PhoneBorderBrush}" BorderThickness="{StaticResource PhoneBorderThickness}" Margin="{StaticResource PhoneTouchTargetOverhang}" Visibility="Collapsed">
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Платеж:" Style="{StaticResource PhoneTextNormalStyle}"/>
<TextBlock x:Name="viewPayment" Style="{StaticResource PhoneTextNormalStyle}"/>
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="Выплаты:" Style="{StaticResource PhoneTextNormalStyle}"/>
<TextBlock x:Name="viewTotalPayment" Style="{StaticResource PhoneTextNormalStyle}" />
</StackPanel>
<Button Content="подробно" Click="DetailsClick" />
</StackPanel>
</Border>
</StackPanel>
</ScrollViewer>
</Grid>
<Grid x:Name="viewProgressPanel" Grid.Row="0" Grid.RowSpan="2" Background="{StaticResource OpacityBackgroundBrush}" Visibility="Collapsed">
<ProgressBar Opacity="1" IsIndeterminate="True" />
</Grid>
В данной разметке полностью отсутствует связывание данных. Все данные устанавливаются с помощью доступа к свойствам элементов управления из code-behind файла.
Code-behind файл главной страницы MainPage.xaml.cs
using System;
using System.Threading.Tasks;
using System.Windows;
using Microsoft.Phone.Controls;
namespace MVVM_Article
{
public partial class MainPage
: PhoneApplicationPage
{
public MainPage()
{
InitializeComponent();
}
private void CalculateClick(object sender, RoutedEventArgs e)
{
decimal amount;
decimal percent;
int term;
if(!decimal.TryParse(viewAmount.Text, out amount))
{
viewProgressPanel.Visibility = Visibility.Collapsed;
MessageBox.Show("Сумма должна быть числом");
return;
}
if(!decimal.TryParse(viewPercent.Text, out percent))
{
viewProgressPanel.Visibility = Visibility.Collapsed;
MessageBox.Show("Процент должен быть числом");
return;
}
if(!int.TryParse(viewTerm.Text, out term))
{
viewProgressPanel.Visibility = Visibility.Collapsed;
MessageBox.Show("Срок кредита должен быть числом");
return;
}
Focus();
viewProgressPanel.Visibility = Visibility.Visible;
Task.Run(() =>
{
try
{
var payment = Calculator.CalculatePayment(amount, percent, term);
Dispatcher.BeginInvoke(() =>
{
viewCalculationPanel.Visibility = Visibility.Visible;
viewPayment.Text = payment.ToString("N2");
viewTotalPayment.Text = (payment * term).ToString("N2");
});
}
finally
{
Dispatcher.BeginInvoke(() =>
{
viewProgressPanel.Visibility = Visibility.Collapsed;
});
}
});
}
private void DetailsClick(object sender, RoutedEventArgs e)
{
var pageUri = string.Format("/DetailsPage.xaml?amount={0}&percent={1}&term={2}", viewAmount.Text, viewPercent.Text, viewTerm.Text);
NavigationService.Navigate(new Uri(pageUri, UriKind.Relative));
}
}
}
Обратите внимание на то, что часть расчетов перенесена в фоновый поток, в данном случае обоснованной необходимости в этом нет. Это сделано намеренно, чтобы охватить тему синхронизации потоков. Все свойства элементов управления должны задаваться из главного потока приложения, если необходимо установить свойство элемента управления из другого потока, необходимо передать управление главному потоку приложения. Для этих целей используется объект Dispatcher страницы, который всегда связан с главным потоком приложения.
Передача параметров на страницу подробного описания кредита, осуществляется через параметры URI страницы.
Страница подробного описания кредита организована подобным образом. Обратить внимание стоит на заполнение таблицы расписания платежей, данный блок было проще реализовать используя ItemsControl. Но такая реализация требует использования связывания данных и не подходит для целей статьи.
Заполнение таблицы расписания платежей в файле DetailsPage.xaml.cs
var style = (Style)Resources["PhoneTextNormalStyle"];
foreach(var record in schedule)
{
var grid = new Grid();
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
var loanElement = new TextBlock
{
Text = record.Loan.ToString("N2"),
Style = style
};
Grid.SetColumn(loanElement, 0);
var interestElement = new TextBlock
{
Text = record.Interest.ToString("N2"),
Style = style
};
Grid.SetColumn(interestElement, 1);
var balanceElement = new TextBlock
{
Text = record.Balance.ToString("N2"),
Style = style
};
Grid.SetColumn(balanceElement, 2);
grid.Children.Add(loanElement);
grid.Children.Add(interestElement);
grid.Children.Add(balanceElement);
viewRecords.Children.Add(grid);
}
Логика расчета кредита реализована в отдельном статическом классе Calculator. Обратите внимание на задержку в начале метода расчета платежа, ее задача имитировать интенсивные расчеты, на выполнение которых требуется некоторое время. Попытка вызова этого метода в главном потоке приложения приведет к зависанию пользовательского интерфейса. Для предотвращения необходимо выполнять все ресурсоемкие задачи в фоновых потоках.
Фрагмент файла Calculator.cs
internal static class Calculator
{
public static decimal CalculatePayment(decimal amount, decimal percent, int term)
{
Task.Delay(1000).Wait();
percent /= 1200;
var common = (decimal) Math.Pow((double) (1 + percent), term);
var multiplier = percent*common/(common - 1);
var payment = amount*multiplier;
return payment;
}
public static List<PaymentsScheduleRecord> GetPaymentsSchedule(decimal amount, decimal percent, int term)
{
var balance = amount;
var interestRate = percent / 1200;
var payment = CalculatePayment(amount, percent, term);
var schedule = new List<PaymentsScheduleRecord>();
for (var period = 0; period < term; period++)
{
var interest = Math.Round(balance * interestRate, 2);
var loan = payment - interest;
balance -= loan;
var record = new PaymentsScheduleRecord
{
Interest = interest,
Loan = loan,
Balance = balance
};
schedule.Add(record);
}
return schedule;
}
}
Простейшая реализация MVVM
Теперь реализуем самую простую версию MVVM, для этого создадим для каждой страницы модель представления, которая будет реализовывать интерфейс INotifyPropertyChanged используемый для уведомления представления об изменениях свойств объекта. Исходный код доступен на GitHub в ветке naivemvvm
Реализация классом интерфейса предполагает генерацию события PropertyChanged каждый раз, когда значение свойства объекта изменяется. Такое поведение позволяет привязкам данных отслеживать состояние объекта и обновлять данные пользовательского интерфейса при изменении значения связанного свойства.
Фрагмент файла MainPageViewModel.cs
public class MainPageViewModel
: INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName]string propertyName = null)
{
var handler = PropertyChanged;
if (handler != null)
{
handler(this, new PropertyChangedEventArgs(propertyName));
}
}
}
Обратите внимание на использование атрибута [CallerMemberName], этот атрибут указывает компилятору, что в данный параметр необходимо передать имя члена класса из которого был вызван метод. Это позволяет не передавать в метод имя свойства в явном виде, если метод вызывается из самого свойства.
Пример реализации свойства модели представления
private string _amount;
public string Amount
{
get { return _amount; }
set
{
_amount = value;
OnPropertyChanged();
}
}
После установки значения поля вызывается метод OnPropertyChanged который генерирует событие об изменении значения свойства из которого он был вызван.
Модель представления может предоставлять потребителям команды, которые позволяют выполнять определенные моделью представления действия. Команды представляют собой объекты реализующие интерфейс ICommand, если потребителю надо выполнить действие заданное командой, он должен вызвать метод Execute команды. Команда предоставляет потребителям информацию, о том может ли она быть выполнена или нет. Для получения информации о доступности команды необходимо вызвать метод CanExecute, а так же подписаться на событие CanExecuteChanged которое уведомит потребителей об изменении состояния команды.
Реализация команды для каждого отдельного действия модели представления очень трудоемкий процесс, для его облегчения создадим класс DelegateCommand который будет делегировать выполнение методов команды делегатам заданным при создании экземпляра класса
Файл DelegateCommand.cs
public sealed class DelegateCommand
: ICommand
{
private readonly Action<object> _execute;
private readonly Func<object, bool> _canExecute;
public DelegateCommand(Action<object> execute, Func<object, bool> canExecute = null)
{
if(execute == null)
{
throw new ArgumentNullException();
}
_execute = execute;
_canExecute = canExecute;
}
public bool CanExecute(object parameter)
{
return _canExecute == null
|| _canExecute(parameter);
}
public void Execute(object parameter)
{
if(!CanExecute(parameter))
{
return;
}
_execute(parameter);
}
public event EventHandler CanExecuteChanged;
public void RiseCanExecuteChanged()
{
var handler = CanExecuteChanged;
if(handler != null)
{
handler(this, EventArgs.Empty);
}
}
}
Объявление команды модели представления с использованием класса DelegateCommand
private DelegateCommand _calculateCommand;
public DelegateCommand CalculateCommand
{
get
{
if(_calculateCommand == null)
{
_calculateCommand = new DelegateCommand(o => Calculate());
}
return _calculateCommand;
}
}
После создания модели представления, внесем изменения в описание пользовательского интерфейса. Для этого удалим весь код из файла MainPage.xaml.cs, а в конструкторе страницы установим значение свойства DataContext страницы, после этого мы сможем использовать привязки данных.
Файл MainPage.xaml.cs после изменений
using Microsoft.Phone.Controls;
using MVVM_Article.ViewModels;
namespace MVVM_Article
{
public partial class MainPage
: PhoneApplicationPage
{
public MainPage()
{
InitializeComponent();
DataContext = new MainPageViewModel();
}
}
}
Обратите внимание, что code-behind страницы уменьшился до одной строки, в следующих главах эта строка так же будет удалена.
Далее необходимо задать привязки данных в описании пользовательского интерфейса. Для задания привязок данных используется конструкция {Binding Path=<Имя свойства>}, в большинстве случаев Path можно опустить и сократить запись до вида {Binding <Имя свойства>}.
Пример связывания данных, фрагмент файла MainPage.xaml
<TextBlock Text="Срок кредита" />
<TextBox Text="{Binding Term, Mode=TwoWay}" InputScope="Number"/>
<Button Content="рассчитать" Command="{Binding CalculateCommand}" />
<Border BorderBrush="{StaticResource PhoneBorderBrush}" BorderThickness="{StaticResource PhoneBorderThickness}" Margin="{StaticResource PhoneTouchTargetOverhang}" Visibility="{Binding IsCalculated, Converter={StaticResource BoolToVisibilityConverter}}">
Обратите внимание на параметр Mode=TwoWay при задании связывания для текстового поля, этот параметр указывает привязке данных, что при изменении значения свойства элемента управления, необходимо его передать в поле модели представления. Таким образом модель представления получает данные пользовательского ввода. Свойство Visibility элемента управления и IsLoaded модели представления, не могут быть связанны на прямую, потому что их типы различны. Для решения подобных задач предназначены конвертеры значений.
Для привязки свойства типа Boolean к свойству типа Visibility создадим конвертер, BoolToVisibilityConverter
public class BoolToVisibilityConverter
: IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return (value as bool?) == true
? Visibility.Visible
: Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
Используя этот конвертер можно связывать между собой поля типа Boolean и Visibility.
Visibility="{Binding IsCalculated, Converter={StaticResource BoolToVisibilityConverter}}"
К сожалению при реализации паттерна MVVM для страницы DeptailsPage не удалось полностью избавиться от code-behind потому, он используется для инициализации модели представления параметрами переданными из главной страницы.
Заключение
Текущее приложение формально соблюдает паттерн MVVM, но фактически мы просто перенесли code-behind из класса страницы в отдельный класс. Реализация имеет множество недостатков и не позволяет пользоваться приемуществами MVVM описанными в начале статьи.
В следующих статьях будут рассмотрены темы: использование DI в MVVM, реализация навигации, взаимодействия с пользователем, обобщение базового класса MVVM и многое другое.
Автор: Viacheslav01