Введение
Данная статья будет полезна разработчикам, начинающим писать на 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<ILogger>();
}
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<Exception>
{
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<ApplicationLog>().ObserveOn(RxApp.MainThreadScheduler).Subscribe(Observer.Create<ApplicationLog>((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<ILogger>();
Поле 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<AppLogger>().As<ILogger>();
// Регистрируем профили ObjectMapper путем сканирования сборки
var typeAdapterConfig = TypeAdapterConfig.GlobalSettings;
typeAdapterConfig.Scan(Assembly.GetExecutingAssembly());
}
private static void RegisterViews(ContainerBuilder builder)
{
builder.RegisterType<MainWindow>().As<IViewFor<MainWindowViewModel>>();
builder.RegisterType<MessageWindow>().As<IViewFor<<MessageWindowViewModel>>().AsSelf();
builder.RegisterType<MainWindowViewModel>();
builder.RegisterType<MessageWindowViewModel>();
}
}
Маппинг объектов
Маппер помогает нам минимизировать код по преобразованию одного типа объекта в другой. Я воспользовался пакетом Mapster. Для настройки библиотека имеет FluetAPI, либо аттрибуты к классам и свойствам. Кроме того, можно настроить кодогенерацию маппинга на стадии сборки, что позволяет сократить время преобразования одних объектов в другие. Регистрацию я решил вынести в отдельный класс, который должен релизовать интерфейс IRegister
:
public class ApplicationMapperRegistration: IRegister
{
public void Register(TypeAdapterConfig config)
{
config.NewConfig<IPositionerDevice, DeviceViewModel>()
.ConstructUsing(src => new DeviceViewModel(src.Mode, src.IsConnected, src.DeviceId, src.Name));
config.NewConfig<DeviceIndicators, DeviceViewModel>();
}
}
На этом с инфраструктурой собственно всё. Других моментов заслуживающих внимания я не нашёл. Далее опишу некоторые моменты реализации 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<Unit, bool> ConnectToDevice;
public readonly ReactiveCommand<float, float> SetValvePosition;
}
Реализация представления
В предсталении реализуем привязки команд и поля модели к элементам управления
Код
public partial class MainWindow
{
public MainWindow()
{
InitializeComponent();
ViewModel = Locator.Current.GetService<DeviceViewModel>();
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:
<TextBlock x:Name="ValidationErrors" FontSize="10" Foreground="Red"/>
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<bool> _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<T>
2. Словарь SourceCache<TObject, TKey>
Экземпляры данных классов хранят динамически изменяемые данные. Изменения данных публикуются как IObservable<ChangeSet>
, ChangeSet
- содержит данные об изменяемых элементах. Для преобразования в IObservable<ChangeSet>
используется метод Connect
. В своем приложении я реализовал отображение в виде таблицы данных об устройстве: версия прошивки, id устройства, дата калибровки и прочее.
Представление:
this.OneWayBind(ViewModel, vm => vm.ConnectedDevice.DeviceInfo, v => v.DeviceInfo.ItemsSource) .DisposeWith(disposable);
<DataGrid x:Name="DeviceInfo" AutoGenerateColumns="False" Margin="0,0,0,3" Background="Transparent" CanUserAddRows="False" HeadersVisibility="None"> <DataGrid.Columns>
<DataGridTextColumn Binding="{Binding Key}" FontWeight="Bold" IsReadOnly="True"/>
<DataGridTextColumn Binding="{Binding Value}" IsReadOnly="True"/>
</DataGrid.Columns>
</DataGrid>
Определяем коллекции для хранения и для привязки
public ReadOnlyObservableCollection<VariableInfo> DeviceInfoBind;
public SourceCache<VariableInfo, string> 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 для базовых элементов управления. Документация библиотеки скудновата, но есть демо - проект с помощью которого, можно разобраться как и что работает. Чтобы все приложение использовало темы из данного тулкита нужно в ресурсы добавить глобальные стили:
Код
<Application x:Class="Apc.Application2.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes"
StartupUri="Views/MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!-- Добавляем тему приложения и стили из Material Design Extensions -->
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/Generic.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignThemes.Wpf;component/Themes/MaterialDesignTheme.Defaults.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignExtensions;component/Themes/Generic.xaml" />
<ResourceDictionary Source="pack://application:,,,/MaterialDesignExtensions;component/Themes/MaterialDesignLightTheme.xaml" />
<!-- Настраиваем глобальные цветовые стили -->
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="pack://application:,,,/MaterialDesignColors;component/Themes/MaterialDesignColor.Blue.xaml" />
</ResourceDictionary.MergedDictionaries>
<SolidColorBrush x:Key="PrimaryHueLightBrush" Color="{StaticResource Primary100}" />
<SolidColorBrush x:Key="PrimaryHueLightForegroundBrush" Color="{StaticResource Primary100Foreground}" />
<SolidColorBrush x:Key="PrimaryHueMidBrush" Color="{StaticResource Primary500}" />
<SolidColorBrush x:Key="PrimaryHueMidForegroundBrush" Color="{StaticResource Primary500Foreground}" />
<SolidColorBrush x:Key="PrimaryHueDarkBrush" Color="{StaticResource Primary600}" />
<SolidColorBrush x:Key="PrimaryHueDarkForegroundBrush" Color="{StaticResource Primary600Foreground}" />
</ResourceDictionary>
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
Помимо этого нужно, чтобы представления наследовали класс MaterialWindow. Я добавил новый свой базовый классMaterialReactiveWindow
Код
public class MaterialReactiveWindow<TViewModel> :
MaterialWindow, IViewFor<TViewModel>
where TViewModel : class
{
/// <summary>
/// Ссылка на модель представления
/// </summary>
public static readonly DependencyProperty ViewModelProperty =
DependencyProperty.Register(
"ViewModel",
typeof(TViewModel),
typeof(ReactiveWindow<TViewModel>),
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"
Пример использования некоторых элементов управления из библиотеки:
<!-- BusyOverlay, который делает окно неактивным и показывает значок процесса во время выполнения долгоиграющей команды --> -->
<mde:BusyOverlay x:Name="BusyOverlay"></mde:BusyOverlay>
<!-- TimePicker из библиотеки -->
<md:TimePicker x:Name="DeviceTime"/>
<!-- В кнопке можно добавить визуализацию выполнения команды
в виде индикатора прогресса с помощью свойства ButtonProgressAssist.
Для данной кнопки мы отображаем анимацию пока обновляем данные сенсоров устройства.
-->
<Button x:Name="RefreshIndicators"
md:ButtonProgressAssist.Value="-1"
md:ButtonProgressAssist.IsIndicatorVisible="{Binding Path=IsCommandExecuting}"
md:ButtonProgressAssist.IsIndeterminate="True">
<Button.Content>
<!-- Используем иконку для кнопки из библиотеки -->
<md:PackIcon Kind="Refresh" />
</Button.Content>
</Button>
Графики
Мне необходима была визуалицация исторических данных и текущих значений датчиков устройства в приложении. После обзора нескольких библиотек для отображения графиков я остановился на ScottPlot и LiveCharts2. Оба пакета позволяют рисовать различные виды графиков и диаграмм от линий до круговых диаграм и японских свеч. Причем в ScottPlot интерактивное взаимодействие с графиком (масштабирование, перемещение и пр.) работает по-умолчанию без всякого тюнинга. Но в ней мне не удалось заставить работать Realtime обновление данных на графике, поэтому я в итоге пришел к LiveChart2. Данная библиотека имеет платную версию, которая обладает улучшенной производительностью и обеспечивает поддержку разработчиков. В своем приложении я использовал два типа графиков: простой линейный для вывода исторических данных с датчиков и радиальный для индикации текущего значения. Они были реализованы в виде отдельных контролов. Итак, обычный двумерный график в виде линии:
<reactiveui:ReactiveUserControl x:Class="Apc.Application2.Views.PlotControl" x:TypeArguments="models:PlotControlViewModel" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:reactiveui="http://reactiveui.net" xmlns:models="clr-namespace:Apc.Application2.Models" xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.WPF;assembly=LiveChartsCore.SkiaSharpView.WPF" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="300">
<Grid>
<lvc:CartesianChart x:Name="Plot" Background="White" ZoomMode="Both"/>
</Grid>
</reactiveui:ReactiveUserControl>
Класс представления довольно тривиален :
Представление
public partial class PlotControl
{
public PlotControl()
{
InitializeComponent();
ViewModel = Locator.Current.GetService<PlotControlViewModel>();
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<ObservableCollection<DateTimePoint>>();
Series = new ObservableCollection<ISeries>();
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<ISeries> Series { get; }
private readonly Collection<ObservableCollection<DateTimePoint>> _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<DateTimePoint>();
_values.Add(newValues);
var lineSeries = new LineSeries<DateTimePoint>
{
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
<lvc:PieChart x:Name="Gauge" Width="200"/>
Представление
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<ChartPoint, string> 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<ISeries> Series { get; set; }
[Reactive]
public double Total { get; set; }
[Reactive]
public double InitialRotation { get; set; }
[Reactive]
public List<ObservableValue> 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.
Автор: Алексей