- PVSM.RU - https://www.pvsm.ru -

Джентльменский набор для создания WPF-приложений

Джентльменский набор для создания WPF-приложений - 1

Введение

Данная статья будет полезна разработчикам, начинающим писать на WPF. Она не является руководством. Здесь приведены лишь некоторые подходы и библиотеки, которые могут быть полезны при создании приложений на WPF. По сути, статья представляет собой набор рецептов полезных при создании WPF приложений. Поэтому опытные WPF-разработчики вряд ли найдут что-то интересное для себя. В качестве примера приводятся части кода из приложения, которое служит для мониторинга клапана (нужно считывать показания датчиков давления и положения и выводить их на экран). Отмечу, что я использую бесплатные пакеты и библиотеки, поскольку приложение создается с целью исследования возможностей оборудования.

Содержание

Инфрастурктура

Первым делом создадим инфраструктурный уровень приложения, который обеспечит работу всего приложения. Я использую библиотеку ReactiveUI [16] поскольку она позволяет в некоторой степени избежать написание boilerplate-кода и содержит в себе необходимый набор инструментов таких, как внутрипроцессная шина, логгер, планировщик и прочее. Основы использования неплохо изложены тут [17]. ReactiveUI исповедует реактивный подход, реализованный в виде Reactive Extensions [18]. Подробнее использование данного подхода я опишу ниже в реализации паттерна MVVM [6].

Обработка исключений

Подключим глобальный exception handler, который пишет ошибки c помощью логгера. Для этого в классе приложения App переопределим метод OnStartup, данный метод преставляет собой обработчик события StartupEvent [19], который в свою очередь вызывается из метода 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 [20] и во внутрипроцессную шину 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 советуют [21] использовать в MessageBus в последнюю очередь, так как MessageBus - глобальная переменная, которая может быть потенциальным местом утечек памяти. Прослушивание сообщений из шины осуществляется на методом MessugeBus.Current.Listen

MessageBus.Current.Listen<ApplicationLog>().ObserveOn(RxApp.MainThreadScheduler).Subscribe(Observer.Create<ApplicationLog>((log) =>
			{
					LogContent += logMessage;
			}));

Настройка IoC

Далее настроем IoC, который облегчит нам управление жизенным циклом объектов. ReactiveUI использует Splat [22]. Регистрация сервисов осуществляется с помощью вызова метода 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 [23]. Регистрацию сервисов вынес в отдельный класс.

Код
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 [24]. Для настройки библиотека имеет 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 [16], позволяющий работать с UI в реактивном стиле. Ниже основные моменты по написанию кода моделей и представлений.

Модель

Классы моделей, используемые в представлениях, наследуются от ReactiveObject. Есть библиотека Fody [25], которая позволяет с помощью аттрибута 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 реализована с помощью, команд. Их работа довольно хорошо описана тут [26], я лишь приведу пример. Привязка команды к событию нажатия кнопки приведена выше в классе представления. Сама команда реализована следующим образом:

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 [27]
С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 и для этого случая есть решение [28]. В библиотеке есть два класса коллекций:

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 [29] + Material Design Extensions [30] поскольку они бесплатны и открыта, и в принципе, представляется собой достаточный набор инструментов для моего приложения. Данный пакет представляет собой набор визуальных стилей 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 [31] и LiveCharts2 [32]. Оба пакета позволяют рисовать различные виды графиков и диаграмм от линий до круговых диаграм и японских свеч. Причем в 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, в которых указывается индекс оси.
Напрмер, график давления, использующий данный контрол, в приложении:

Джентльменский набор для создания WPF-приложений - 2

Радиальный график использует 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; }
	}

Индикаторы давления, созданные с помощью этого контрола в приложении:

Джентльменский набор для создания WPF-приложений - 3

Я их объединил с помощью контрола Card из библиотеки MaterialDesign. Необходимо отмететь, что PieChart не позволяет их отображать шкалу с метками. Есть PolarChart с шкалой, но он не позволяет нарисовать "пирог". Поэтому тут нужно писать собственную реализацию.

Как я говорил, платная верия обещает лучшую производительность при обновлении данных графиков, но меня вполне удовлетворила бесплатная версия для обновления данных 1 раз в 3-4 секунды.

Заключение

В данной статье рассмотереные некоторые приемы, облегчабщие разработку WPF-приложения. Уделено внимание инфраструктурным моментам: настройка IoC, логгирование, маппинг объектов. Кроме того, приведен способ улучшения визуального представления UI c помощью компонентов из Material Design вместо стандартных серых кнопок и полей. Все используемые библиотеки бесплатны и с открытым кодом. Конечно по своим возможностям они не дотягивают до платных таких пакетов, как Telerik и SyncFusion, но позволяют получить вполне достойное приложение, когда покупка указанных выше компонент не оправдана. Также замечу, что использование Reactive Extensions, LiveCharts2, в принципе, не ограничено desktop-приложениями, возможно какие-то подходы и паттерны могут быть применены и в других областях разработки. Например, Michael Shpilt описал [33] реализацию Job Queue с помощью Reactive Extensions.

Автор: Алексей

Источник [34]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/c-2/371508

Ссылки в тексте:

[1] Введение: #%D0%92%D0%B2%D0%B5%D0%B4%D0%B5%D0%BD%D0%B8%D0%B5

[2] Инфраструктура: #%D0%98%D0%BD%D1%84%D1%80%D0%B0%D1%81%D1%82%D1%80%D1%83%D0%BA%D1%82%D1%83%D1%80%D0%B0

[3] Обработка исключений: #%D0%98%D1%81%D0%BA%D0%BB%D1%8E%D1%87%D0%B5%D0%BD%D0%B8%D1%8F

[4] Настройка IoC: #ioc

[5] Маппинг объектов: #%D0%BC%D0%B0%D0%BF%D0%BF%D0%B8%D0%BD%D0%B3

[6] Реализация MVVM - паттерна: #MVVM

[7] Модель: #%D0%BC%D0%BE%D0%B4%D0%B5%D0%BB%D1%8C

[8] Представление: #%D0%BF%D1%80%D0%B5%D0%B4%D1%81%D1%82%D0%B0%D0%B2%D0%BB%D0%B5%D0%BD%D0%B8%D0%B5

[9] Валидация: #%D0%B2%D0%B0%D0%BB%D0%B8%D0%B4%D0%B0%D1%86%D0%B8%D1%8F

[10] Команды: #%D0%BA%D0%BE%D0%BC%D0%B0%D0%BD%D0%B4%D1%8B

[11] Отображение динамических данных: #%D0%B4%D0%B8%D0%BD%D0%B0%D0%BC%D0%B8%D1%87%D0%B5%D1%81%D0%BA%D0%B8%D0%B5%D0%B4%D0%B0%D0%BD%D0%BD%D1%8B%D0%B5

[12] Визуальные темы и элементы управления: #%D0%92%D0%B8%D0%B7%D1%83%D0%B0%D0%BB%D1%8C%D0%BD%D1%8B%D0%B9%D1%81%D1%82%D0%B8%D0%BB%D1%8C

[13] Стиль приложения: #%D1%81%D1%82%D0%B8%D0%BB%D1%8C

[14] Графики: #%D0%B3%D1%80%D0%B0%D1%84%D0%B8%D0%BA%D0%B8

[15] Заключение: #%D0%B7%D0%B0%D0%BA%D0%BB%D1%8E%D1%87%D0%B5%D0%BD%D0%B8%D0%B5

[16] ReactiveUI: https://www.reactiveui.net/

[17] тут: https://habr.com/ru/post/303650/

[18] Reactive Extensions: https://github.com/dotnet/reactive

[19] StartupEvent: https://docs.microsoft.com/ru-ru/dotnet/api/system.windows.application.startup?view=windowsdesktop-6.0

[20] NLog: https://nlog-project.org/

[21] советуют: https://www.reactiveui.net/docs/handbook/message-bus/

[22] Splat: https://github.com/reactiveui/splat

[23] Splat.Autofac: https://github.com/reactiveui/splat/tree/main/src/Splat.Autofac

[24] Mapster: https://github.com/MapsterMapper/Mapster

[25] Fody: https://github.com/Fody/Fody

[26] тут: https://habr.com/ru/post/305350/

[27] System.Reactive.Linq: https://www.nuget.org/packages/System.Reactive.Linq

[28] решение: https://www.reactiveui.net/docs/handbook/collections/

[29] Material Design In XAML Toolkit: https://github.com/MaterialDesignInXAML/MaterialDesignInXamlToolkit

[30] Material Design Extensions: https://spiegelp.github.io/MaterialDesignExtensions/

[31] ScottPlot: https://github.com/ScottPlot/ScottPlot

[32] LiveCharts2: https://github.com/beto-rodriguez/LiveCharts2

[33] описал: https://michaelscodingspot.com/c-job-queues-with-reactive-extensions-and-channels/

[34] Источник: https://habr.com/ru/post/647259/?utm_source=habrahabr&utm_medium=rss&utm_campaign=647259