Джентельменский набор для создания 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<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.

Читайте так же:

  • Xiaomi анонсировала экстремальный флагман Mi 11X на Snapdragon 888Xiaomi анонсировала экстремальный флагман Mi 11X на Snapdragon 888 Компания Xiaomi сегодня официально анонсировала очередной смартфон на платформе Snapdragon 888. Им стал Mi 11X. Ранее вице-президент Xiaomi и глава Xiaomi India Ману Кумар Джейн (Manu Kumar Jain) обещал выпуск не менее трех новых моделей на Snapdragon 888, и Mi 11X – одна из них. […]
  • Google изменил требования к разметке логотипа на AMP-страницахGoogle изменил требования к разметке логотипа на AMP-страницах Компания Google обновила требования к разметке AMP-страниц со структурированными данным Article. Новые изменения относятся к логотипу издателя и внесены пока в англоязычную документацию по поиску. Данное обновление транслирует. Что Google понимает как необработанные URL. Так и разметку […]
  • Панорамы Яндекс.Карт стали интерактивнымиПанорамы Яндекс.Карт стали интерактивными Панорамы Яндекс.Карт стали интерактивными, и теперь прямо в них можно строить пешеходные маршруты. Еще в панорамах появились метки организаций — по ним сразу видно, что есть в зданиях. Все это пригодится тем, кто планирует маршруты заранее и хочет сориентироваться в незнакомом районе. […]
  • Приглашаем на онлайн-конференцию «SEO без воды 4»Приглашаем на онлайн-конференцию «SEO без воды 4» Пресс-релиз 14 сентября 2021 года в 11:00 по Мск состоится онлайн-конференция «SEO без воды 4». На конференции вы: узнаете, как увеличить до 7 раз видимость карточки Google My Business; освоите особенности SEO в беттинге в России; научитесь прорабатывать страницы под высокочастотные […]