Джентельменский набор для создания WPF-приложений
Введение
Данная статья будет полезна разработчикам, начинающим писать на WPF. Она не является руководством. Здесь приведены лишь некоторые подходы и библиотеки, которые могут быть полезны при создании приложений на WPF. По сути, статья представляет собой набор рецептов полезных при создании WPF приложений. Поэтому опытные WPF-разработчики вряд ли найдут что-то интересное для себя. В качестве примера приводятся части кода из приложения, которое служит для мониторинга клапана (нужно считывать показания датчиков давления и положения и выводить их на экран). Отмечу, что я использую бесплатные пакеты и библиотеки, поскольку приложение создается с целью исследования возможностей оборудования.
Содержание
Инфрастурктура
Первым делом создадим инфраструктурный уровень приложения, который обеспечит работу всего приложения. Я использую библиотеку ReactiveUI поскольку она позволяет в некоторой степени избежать написание boilerplate-кода и содержит в себе необходимый набор инструментов таких, как внутрипроцессная шина, логгер, планировщик и прочее. Основы использования неплохо изложены тут. ReactiveUI исповедует реактивный подход, реализованный в виде Reactive Extensions. Подробнее использование данного подхода я опишу ниже в реализации паттерна MVVM.
Обработка исключений
Подключим глобальный exception handler, который пишет ошибки c помощью логгера. Для этого в классе приложения App переопределим метод OnStartup
, данный метод преставляет собой обработчик события StartupEvent, который в свою очередь вызывается из метода Application.Run
Код
public partial class App : Application
{
private readonly ILogger _logger;
public App()
{
Bootstrapper.BuildIoC(); // Настраиваем IoC
_logger = Locator.Current.GetService();
}
private void LogException(Exception e, string source)
{
_logger?.Error($"{source}: {e.Message}", e);
}
private void SetupExceptionHandling()
{
// Подключим наш Observer-обработчик исключений
RxApp.DefaultExceptionHandler = new ApcExceptionHandler(_logger);
}
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
SetupExceptionHandling();
}
}
public class ApcExceptionHandler: IObserver
{
private readonly ILogger _logger;
public ApcExceptionHandler(ILogger logger)
{
_logger = logger;
}
public void OnCompleted()
{
if (Debugger.IsAttached) Debugger.Break();
}
public void OnError(Exception error)
{
if (Debugger.IsAttached) Debugger.Break();
_logger.Error($"{error.Source}: {error.Message}", error);
}
public void OnNext(Exception value)
{
if (Debugger.IsAttached) Debugger.Break();
_logger?.Error($"{value.Source}: {value.Message}", value);
}
}
Логгер пишет в файл с помощью NLog и во внутрипроцессную шину MessageBus
, чтобы приложение могло отобразить логи в UI
Код
public class AppLogger: ILogger
{
//Экземпляр логгера NLog
private NLog.Logger _logger = NLog.LogManager.GetCurrentClassLogger();
public AppLogger() { }
public void Info(string message)
{
_logger.Info(message);
MessageBus.Current.SendMessage(new ApplicationLog(message));
}
public void Error(string message, Exception exception = null)
{
_logger.Error(exception, message);
//Отправляем сообщение в шину
MessageBus.Current.SendMessage(new ApplicationLog(message));
}
}
Необоходимо, отметить, что разработчики ReactiveUI советуют использовать в MessageBus
в последнюю очередь, так как MessageBus
— глобальная переменная, которая может быть потенциальным местом утечек памяти. Прослушивание сообщений из шины осуществляется на методом MessugeBus.Current.Listen
MessageBus.Current.Listen().ObserveOn(RxApp.MainThreadScheduler).Subscribe(Observer.Create((log) =>
{
LogContent += logMessage;
}));
Настройка IoC
Далее настроем IoC, который облегчит нам управление жизенным циклом объектов. ReactiveUI использует Splat. Регистрация сервисов осуществляется с помощью вызова метода Register()
поля Locator.CurrentMutable
, а получение — GetService()
поля Locator.Current
.
Например:
Locator.CurrentMutable.Register(() => new AppLogger(), typeof(ILogger));
var logger = Locator.Current.GetService();
Поле Locator.Current
реализовано для интеграции с другими DI/IoC для добавления которых Splat имеет отдельные пакеты. Я использую Autofac c помощью пакета Splat.Autofac. Регистрацию сервисов вынес в отдельный класс.
Код
public static class Bootstrapper
{
public static void BuildIoC()
{
/*
* Создаем контейнер Autofac.
* Регистрируем сервисы и представления
*/
var builder = new ContainerBuilder();
RegisterServices(builder);
RegisterViews(builder);
// Регистрируем Autofac контейнер в Splat
var autofacResolver = builder.UseAutofacDependencyResolver();
builder.RegisterInstance(autofacResolver);
// Вызываем InitializeReactiveUI(), чтобы переопределить дефолтный Service Locator
autofacResolver.InitializeReactiveUI();
var lifetimeScope = builder.Build();
autofacResolver.SetLifetimeScope(lifetimeScope);
}
private static void RegisterServices(ContainerBuilder builder)
{
builder.RegisterModule(new ApcCoreModule());
builder.RegisterType().As();
// Регистрируем профили ObjectMapper путем сканирования сборки
var typeAdapterConfig = TypeAdapterConfig.GlobalSettings;
typeAdapterConfig.Scan(Assembly.GetExecutingAssembly());
}
private static void RegisterViews(ContainerBuilder builder)
{
builder.RegisterType().As>();
builder.RegisterType().As>().AsSelf();
builder.RegisterType();
builder.RegisterType();
}
}
Маппинг объектов
Маппер помогает нам минимизировать код по преобразованию одного типа объекта в другой. Я воспользовался пакетом Mapster. Для настройки библиотека имеет FluetAPI, либо аттрибуты к классам и свойствам. Кроме того, можно настроить кодогенерацию маппинга на стадии сборки, что позволяет сократить время преобразования одних объектов в другие. Регистрацию я решил вынести в отдельный класс, который должен релизовать интерфейс IRegister
:
public class ApplicationMapperRegistration: IRegister
{
public void Register(TypeAdapterConfig config)
{
config.NewConfig()
.ConstructUsing(src => new DeviceViewModel(src.Mode, src.IsConnected, src.DeviceId, src.Name));
config.NewConfig();
}
}
На этом с инфраструктурой собственно всё. Других моментов заслуживающих внимания я не нашёл. Далее опишу некоторые моменты реализации UI приложения.
Реализация MVVM — паттерна
Как я писал выше, я использую ReactivUI, позволяющий работать с UI в реактивном стиле. Ниже основные моменты по написанию кода моделей и представлений.
Модель
Классы моделей, используемые в представлениях, наследуются от ReactiveObject
. Есть библиотека Fody, которая позволяет с помощью аттрибута Reactive
делать свойства модели реактивными. Можно и без нее, но по моему мнению, она помогает сделать код более читаем за счёт сокращения boilerplate-конструкций. Связывание свойств модели со свойствами элементов управления также производится либо в XML разметке, либо в коде с помощью методов.
Небольшой пример модели клапана, которая будет хранить показания основных датчиков.
Код
public class DeviceViewModel: ReactiveObject
{
public DeviceViewModel() { }
[Reactive]
public float Current { get; set; }
[Reactive]
public float Pressure { get; set; }
[Reactive]
public float Position { get; set; }
[Reactive]
public DateTimeOffset DeviceTime { get; set; }
[Reactive]
public bool Connected { get; set; }
public ReactiveCommand ConnectToDevice;
public readonly ReactiveCommand SetValvePosition;
}
Реализация представления
В предсталении реализуем привязки команд и поля модели к элементам управления
Код
public partial class MainWindow
{
public MainWindow()
{
InitializeComponent();
ViewModel = Locator.Current.GetService();
DataContext = ViewModel;
/*
* Данный метод регистрирует привязки модели к элементам представления
* DisposeWith в необходим для очистки привязок при удалении представления
*/
this.WhenActivated(disposable =>
{
/*
* Привязка свойства Text элемента TextBox к свойства модели.
* OneWayBind - однонаправленная привязка, Bind - двунаправленная
*/
this.OneWayBind(ViewModel, vm => vm.Pressure, v => v.Pressure1Indicator.Text)
.DisposeWith(disposable);
// Двунаправленная привязка значения позиции клапана. Конверторы значений свойства в модели и в представлении: FloatToStringConverter, StringToFloatConverter
this.Bind(ViewModel, vm => vm.Position, v => v.Position.Text, FloatToStringConverter, StringToFloatConverter)
.DisposeWith(disposable);
this.OneWayBind(ViewModel, vm => vm.Current, v => v.Current.Text)
.DisposeWith(disposable);
this.OneWayBind(ViewModel, vm => vm.ConnectedDevice.DeviceTime, v => v.DeviceDate.SelectedDate, val => val.Date)
.DisposeWith(disposable);
this.OneWayBind(ViewModel, vm => vm.ConnectedDevice.DeviceTime, v => v.DeviceTime.SelectedTime, val => val.DateTime)
.DisposeWith(disposable);
/* Привязка команд к кнопкам */
this.BindCommand(ViewModel, vm => vm.ConnectToDevice, v => v.ConnectDevice, nameof(ConnectDevice.Click))
.DisposeWith(disposable);
this.BindCommand(ViewModel, vm => vm.SetValvePosition, v => v.SetValvePosition, vm => vm.ConnectedDevice.AssignedPosition, nameof(SetValvePosition.Click))
.DisposeWith(disposable);
});
}
private string FloatToStringConverter(float value)
{
return value.ToString("F2", CultureInfo.InvariantCulture);
}
private float StringToFloatConverter(string input)
{
float result;
if (!float.TryParse(input, NumberStyles.Float, CultureInfo.InvariantCulture, out result))
{
result = 0;
}
return result;
}
}
Валидация
Валидация модели реализуется путем наследования класса от ReactiveValidationObject
, в конструктор добавляем правило валидации, например:
this.ValidationRule(e => e.Position, val => float.TryParse(val, NumberStyles.Float, CultureInfo.InvariantCulture, out _), "Допускает только ввод цифр");
Для вывода ошибок валидации поля в UI создаем привязку в представлении, например к элементу TextBlock:
this.BindValidation(ViewModel, v => v.Position, v => v.ValidationErrors.Text)
.DisposeWith(disposable);
// Отображаем элемент только при наличии ошибки
this.WhenAnyValue(x => x.ValidationErrors.Text, text => !string.IsNullOrWhiteSpace(text))
.BindTo(this, x => x.ValidationErrors.Visibility)
.DisposeWith(disposable);
Команды
Обработка действий пользователя в UI реализована с помощью, команд. Их работа довольно хорошо описана тут, я лишь приведу пример. Привязка команды к событию нажатия кнопки приведена выше в классе представления. Сама команда реализована следующим образом:
ConnectToDevice = ReactiveCommand.CreateFromTask(async () =>
{
bool isAuthorized = await Authorize.Execute();
return isAuthorized;
}, this.WhenAnyValue(e => e.CanConnect));
/* На команду также можно подписаться как и на любой Observable объект.
После подключения к устройству читаем информацию и показания сенсоров.
*/
ConnectToDevice
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(async result =>
{
ConnectedDevice.IsConnected = result;
await ReadDeviceInfo.Execute();
await ReadDeviceIndicators.Execute();
});
Метод CreateFromTask
добавлен как расширение к классу ReactiveCommand
с помощью пакета System.Reactive.LinqСanConnect
— флаг управляющий возможностью выполнения команды
_canConnect = this.WhenAnyValue(e => e.SelectedDevice,
e => e.IsCommandExecuting,
(device, isExecuting) => device!=null && !isExecuting)
.ToProperty(this, e => e.CanConnect);
public bool CanExecuteCommand => _canExecuteCommand?.Value == true;
private readonly ObservableAsPropertyHelper _canConnect;
public bool CanConnect => _canConnect?.Value == true;
Иногда необходимо объединить Observable — объекты в один. Производится это с помощью Observable.Merge
/* Тут мы объединили флаги выполнения команд, чтобы мониторить выполение любой
из них через флагIsCommandExecuting */
_isCommandExecuting = Observable.Merge(SetValvePosition.IsExecuting,
ConnectToDevice.IsExecuting,
Authorize.IsExecuting,
ReadDeviceIndicators.IsExecuting,
ReadDeviceInfo.IsExecuting,
PingDevice.IsExecuting)
.ToProperty(this, e => e.IsCommandExecuting );
Отображение динамических данных
Бывают случаи, когда необходимо реализовать отображение табличных данных в DataGrid
с возможностью динамического изменения. ReactiveCollection
в данном случае не подходит, так как не реализует уведомления об изменении элементов коллекции. В ReactiveUI и для этого случая есть решение. В библиотеке есть два класса коллекций:
1. Обычный список SourceList
2. Словарь SourceCache
Экземпляры данных классов хранят динамически изменяемые данные. Изменения данных публикуются как IObservable
, ChangeSet
— содержит данные об изменяемых элементах. Для преобразования в IObservable
используется метод Connect
. В своем приложении я реализовал отображение в виде таблицы данных об устройстве: версия прошивки, id устройства, дата калибровки и прочее.
Представление:
this.OneWayBind(ViewModel, vm => vm.ConnectedDevice.DeviceInfo, v => v.DeviceInfo.ItemsSource) .DisposeWith(disposable);
Определяем коллекции для хранения и для привязки
public ReadOnlyObservableCollection DeviceInfoBind;
public SourceCache DeviceInfoSource = new(e => e.Key);
В модели привязываем источник данных к коллекции:
ConnectedDevice.DeviceInfoSource
.Connect()
.ObserveOn(RxApp.MainThreadScheduler)
.Bind(out ConnectedDevice.DeviceInfoBind)
.Subscribe();
На этом завершаем обзор MVVM — рецептов и рассмотрим способы сделать приятнее UI приложения.
Визуальные темы и элементы управления
Стиль приложения
Существуют множество библиотек визуальных компонентов как платных, так и бесплатных. Я остановился на Material Design In XAML Toolkit + Material Design Extensions поскольку они бесплатны и открыта, и в принципе, представляется собой достаточный набор инструментов для моего приложения. Данный пакет представляет собой набор визуальных стилей Materail Design для базовых элементов управления. Документация библиотеки скудновата, но есть демо — проект с помощью которого, можно разобраться как и что работает. Чтобы все приложение использовало темы из данного тулкита нужно в ресурсы добавить глобальные стили:
Код
Помимо этого нужно, чтобы представления наследовали класс MaterialWindow. Я добавил новый свой базовый классMaterialReactiveWindow
Код
public class MaterialReactiveWindow :
MaterialWindow, IViewFor
where TViewModel : class
{
///
/// Ссылка на модель представления
///
public static readonly DependencyProperty ViewModelProperty =
DependencyProperty.Register(
"ViewModel",
typeof(TViewModel),
typeof(ReactiveWindow),
new PropertyMetadata(null));
public TViewModel? BindingRoot => ViewModel;
public TViewModel? ViewModel
{
get => (TViewModel)GetValue(ViewModelProperty);
set => SetValue(ViewModelProperty, value);
}
object? IViewFor.ViewModel
{
get => ViewModel;
set => ViewModel = (TViewModel?)value;
}
}
В XAML — файлах добавим ссылки на библиотеки Material Design и Material Design Extensions:
xmlns:md="http://materialdesigninxaml.net/winfx/xaml/themes"
xmlns:mde="clr-namespace:MaterialDesignExtensions.Controls;assembly=MaterialDesignExtensions"
Пример использования некоторых элементов управления из библиотеки:
-->
Графики
Мне необходима была визуалицация исторических данных и текущих значений датчиков устройства в приложении. После обзора нескольких библиотек для отображения графиков я остановился на ScottPlot и LiveCharts2. Оба пакета позволяют рисовать различные виды графиков и диаграмм от линий до круговых диаграм и японских свеч. Причем в ScottPlot интерактивное взаимодействие с графиком (масштабирование, перемещение и пр.) работает по-умолчанию без всякого тюнинга. Но в ней мне не удалось заставить работать Realtime обновление данных на графике, поэтому я в итоге пришел к LiveChart2. Данная библиотека имеет платную версию, которая обладает улучшенной производительностью и обеспечивает поддержку разработчиков. В своем приложении я использовал два типа графиков: простой линейный для вывода исторических данных с датчиков и радиальный для индикации текущего значения. Они были реализованы в виде отдельных контролов. Итак, обычный двумерный график в виде линии:
Класс представления довольно тривиален :
Представление
public partial class PlotControl
{
public PlotControl()
{
InitializeComponent();
ViewModel = Locator.Current.GetService();
this.WhenActivated(disposable =>
{
this.OneWayBind(ViewModel, vm => vm.Series, v => v.Plot.Series)
.DisposeWith(disposable);
this.OneWayBind(ViewModel, vm => vm.XAxes, v => v.Plot.XAxes)
.DisposeWith(disposable);
this.OneWayBind(ViewModel, vm => vm.YAxes, v => v.Plot.YAxes)
.DisposeWith(disposable);
this.OneWayBind(ViewModel, vm => vm.LegendPosition, v => v.Plot.LegendPosition)
.DisposeWith(disposable);
});
}
}
Тут я реализовал возможность настройки осей и легенды графика через свойства модели.
Модель
public class PlotControlViewModel: ReactiveObject
{
public PlotControlViewModel()
{
_values = new Collection>();
Series = new ObservableCollection();
XAxes = new []
{
new Axis
{
// Labeler отвечает за форматирование числовых меток оси
Labeler = value => new DateTime((long) value).ToString("HH:mm:ss"),
UnitWidth = TimeSpan.FromSeconds(1).Ticks,
MinStep = TimeSpan.FromSeconds(1).Ticks,
// Настраиваем отображение разделительных линий сетки
ShowSeparatorLines = true,
SeparatorsPaint = new SolidColorPaint { Color = SKColors.DarkGray, StrokeThickness = 1 },
// Шрифт меток оси
TextSize = 11,
NamePaint = new SolidColorPaint
{
Color = SKColors.Black,
FontFamily = "Segoe UI",
},
}
};
YAxes = new[]
{
new Axis
{
Labeler = value => $"{value:F1}",
TextSize = 11,
NameTextSize = 11,
UnitWidth = 0.5,
MinStep = 0.5,
ShowSeparatorLines = true,
SeparatorsPaint = new SolidColorPaint { Color = SKColors.DarkGray, StrokeThickness = 1 },
NamePaint = new SolidColorPaint
{
Color = SKColors.Black,
FontFamily = "Segoe UI",
}
}
};
}
public ObservableCollection Series { get; }
private readonly Collection> _values;
[Reactive]
public Axis[] XAxes { get; set; }
[Reactive]
public Axis[] YAxes { get; set; }
public string Title { get; set; }
[Reactive]
public LegendPosition LegendPosition { get; set; }
public int AddSeries(string name, SKColor color, float width)
{
var newValues = new ObservableCollection();
_values.Add(newValues);
var lineSeries = new LineSeries
{
Values = newValues,
Fill = null,
Stroke = new SolidColorPaint(color, width),
Name = name,
GeometrySize = 5,
LineSmoothness = 0
};
Series.Add(lineSeries);
return Series.IndexOf(lineSeries);
}
public void AddData(int index, DateTime time, double value)
{
if (index >= _values.Count)
{
return;
}
_values[index].Add(new DateTimePoint(time, value));
}
public void ClearData(int index)
{
if (index >= _values.Count)
{
return;
}
_values[index].Clear();
}
}
CartesianChart
использует данные в виде серий, которые добавляются при инициализации графика методом AddSeries()
. Метод возвращает индекс серии в коллекции. Его я использую для добавления данных в нужную серию. Таким образом, есть возможность нарисовать несколько серий данных на одном графике.
Пример
// Инициализируем график давления. Будет рисовать две линии данных
int pressure1Index = PressurePlot.ViewModel.AddSeries("Давление1", new SKColor(25, 118, 210), 2);
int pressure2Index = PressurePlot.ViewModel.AddSeries("Давление2", new SKColor(229, 57, 53), 2);
//...
// Подписываемся на команду чтения показаний датчиков и добавляем данные на график
ViewModel?.ReadDeviceIndicators
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(indicators =>
{
var currentTime = _clockProvider.Now();
PressurePlot?.ViewModel?.AddData(pressure1Index, currentTime, indicators.Pressure1);
PressurePlot?.ViewModel?.AddData(pressure2Index, currentTime, indicators.Pressure2);
}).DisposeWith(disposable);
Для вывода линий используется LineSeries
c точками DateTimePoint
, так как нужно выводить графики зависимости от времени. Коллекция Series
является Observable
, чтобы иметь возможность динамически добавлять данные и отображать изменения на графике. Необходимо отметить, что оси графика представленны массивом элементов Axis
, что позвляет использовать дополнительные оси для отображения серий. Для этого в серии есть свойства ScalesXAt
, ScalesYAt
, в которых указывается индекс оси.
Напрмер, график давления, использующий данный контрол, в приложении:
Радиальный график использует PieChart
Представление
public partial class GaugeControl
{
public GaugeControl()
{
InitializeComponent();
ViewModel = new GaugeControlViewModel();
this.WhenActivated(disposable =>
{
this.OneWayBind(ViewModel, vm => vm.Total, v => v.Gauge.Total)
.DisposeWith(disposable);
this.OneWayBind(ViewModel, vm => vm.InitialRotation, v => v.Gauge.InitialRotation)
.DisposeWith(disposable);
this.Bind(ViewModel, vm => vm.Series, v => v.Gauge.Series)
.DisposeWith(disposable);
});
}
public double Total
{
get
{
return ViewModel.Total;
}
set
{
ViewModel.Total = value;
}
}
public double InitialRotation
{
get => ViewModel?.InitialRotation ?? 0.0;
set
{
ViewModel.InitialRotation = value;
}
}
/* Поскольку необходимо отображать только текущее зачение,
то вместо добавления элемента, обновляю последнее значение */
public double this[int index]
{
get => ViewModel.LastValues[index].Value ?? 0.0;
set
{
ViewModel.LastValues[index].Value = Math.Round(value, 2);
}
}
}
Модель
public class GaugeControlViewModel: ReactiveObject
{
public GaugeControlViewModel()
{
}
public void InitSeries(SeriesInitialize[] seriesInitializes, Func labelFormatter = null)
{
var builder = new GaugeBuilder
{
LabelsSize = 18,
InnerRadius = 40,
CornerRadius = 90,
BackgroundInnerRadius = 40,
Background = new SolidColorPaint(new SKColor(100, 181, 246, 90)),
LabelsPosition = PolarLabelsPosition.ChartCenter,
LabelFormatter = labelFormatter ?? (point => point.PrimaryValue.ToString(CultureInfo.InvariantCulture)),
OffsetRadius = 0,
BackgroundOffsetRadius = 0
};
LastValues = new(seriesInitializes.Length);
foreach (var init in seriesInitializes)
{
var defaultSeriesValue = new ObservableValue(0);
builder.AddValue(defaultSeriesValue, init.Name, init.DrawColor);
LastValues.Add(defaultSeriesValue);
}
Series = builder.BuildSeries();
}
[Reactive]
public IEnumerable Series { get; set; }
[Reactive]
public double Total { get; set; }
[Reactive]
public double InitialRotation { get; set; }
[Reactive]
public List LastValues { get; private set; }
}
Индикаторы давления, созданные с помощью этого контрола в приложении:
Я их объединил с помощью контрола Card
из библиотеки MaterialDesign. Необходимо отмететь, что PieChart
не позволяет их отображать шкалу с метками. Есть PolarChart
с шкалой, но он не позволяет нарисовать «пирог». Поэтому тут нужно писать собственную реализацию.
Как я говорил, платная верия обещает лучшую производительность при обновлении данных графиков, но меня вполне удовлетворила бесплатная версия для обновления данных 1 раз в 3–4 секунды.
Заключение
В данной статье рассмотереные некоторые приемы, облегчабщие разработку WPF-приложения. Уделено внимание инфраструктурным моментам: настройка IoC, логгирование, маппинг объектов. Кроме того, приведен способ улучшения визуального представления UI c помощью компонентов из Material Design вместо стандартных серых кнопок и полей. Все используемые библиотеки бесплатны и с открытым кодом. Конечно по своим возможностям они не дотягивают до платных таких пакетов, как Telerik и SyncFusion, но позволяют получить вполне достойное приложение, когда покупка указанных выше компонент не оправдана. Также замечу, что использование Reactive Extensions, LiveCharts2, в принципе, не ограничено desktop-приложениями, возможно какие-то подходы и паттерны могут быть применены и в других областях разработки. Например, Michael Shpilt описал реализацию Job Queue с помощью Reactive Extensions.