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


ссылка на оригинал статьи https://habr.com/ru/post/647259/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *