
Введение
Данная статья будет полезна разработчикам, начинающим писать на 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.