Введение

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

Комментарии (25)


  1. 13_beta2
    23.01.2022 16:26
    +2

    А как насчёт перспектив? В статье про это ничего нет. При взгляде со стороны складывается впечатление, что даже winforms оказались более живучими — до сих пор (в .net 6) правки вносятся, какие-то мелочи, но улучшаются. Не выйдет так, что при схожей "не-кроссплатформенности" и "устаревшести" накидать формы для типичных приложений на пару экранов окажется быстрее и проще?


    1. alec_kalinin
      23.01.2022 17:46
      +8

      Добавлю свои соображения, как разработчик GUI на WPF.

      Если кратко, то перспективы у WPF вполне себе неплохие. Microsoft сделала очень правильную вещь, открыла код .NET и WPF. Репозиторий WPF активный, пулл-реквесты мержутся, roadmap адекватный. Впрочем WinForms себя чувствуют даже лучше WPF. И в целом для простых GUI задач я советую выбирать WInForms.

      Но а если длинно, то Microsoft достаточно сильно запуталась в своих GUI фреймворках. WinForms это удобная обертка над Win32 API, она никуда не денется. Но WinForms тесно завязан на визуальный дизайнер форм, поэтому масштабное GUI на нем писать сложно.

      WPF очень красив архитектурно со своей MVVM моделью и рендерингом на DirectX. Первоначально он оказался не таким быстрым, как планировался и достаточно сложным. Но со временем детские проблемы были решена, и он завоевал ведущую роль в разработке GUI под Windows.

      Но потом Microsoft подзабила на WPF и стала развивать концепцию UWP. UWP это приложения, отделенные от Win32API, живущие в своей песочнице, но способные работать на разных устройствах. Но UWP приложения так и не завовевали популярность. И получилась так сказать фрагментация платформы Windows. Одновоременно существовала старая добрая Win32, но совсем списывать в утиль UWP Microsoft не захотела.

      Новая иницатива Microsoft это создание нового универсального WindowsAPI, объединяющая Win32 и UWP. И новый GUI фрейморк WinUI3. Недавно была выпущена версия 1.0. Но по обсуждениям в GitHub пока это все жутко тормозное и глючное. И не факт, что взлетит.

      Плюс в Microsoft пошла новая инициатива, все пересписать на Web технологии. По крайней мере, лобби этого направление сильно.

      К счастью, Microsoft наконец объединила .NET и .NET фреймворк в единую платформу .NET и открыла код. Все это дало новую жизнь WinForms и WPF.

      Так что на текущий момет времени если писать сложное GUI под Windows desktop не по web технологиям, то WPF почти безальтернативый выбор. Собственно поэтому он сейчас достаточно неплохо чувствует, развивается и его будущее выглядит вполне неплохим.


      1. 13_beta2
        23.01.2022 18:22
        +1

        Спасибо. Как раз живого мнения и хотелось услышать. Фрагментация GUI-фреймворков под Windows, примерная история (даже можно сказать чехарда с winui3 через uwp, через "modern") и то, что WPF не "флагман" мне известно.


      1. AlexDevFx Автор
        23.01.2022 20:41
        +1

        Я выбрал WPF потому, что он актуален сейчас и скорее всего следующие фреймворки под desktop будут XAML-based и исповедовать подход MVVM, поэтому переписать будет легче. Но это мое видение, возможно пойдут и в какую-то другую сторону.


      1. Terras
        24.01.2022 09:34

        А можно несколько примеров, какие типы приложений сейчас пишут под WPF.


        1. alec_kalinin
          24.01.2022 12:16
          +1

          В моей области WPF очень популярен для разработки приложений для медицинской диагностики и медицинской визуализации. В таких приложениях обычно маловато типовых форм, и необходим достаточно сложный кастомный GUI с большой долей графики и анимации.

          Типовой пример это приложение Varian Eclipse для планирования протонной терапии.



    1. AlexDevFx Автор
      23.01.2022 20:37

      Microsoft пилит MAUI, я думаю в нем как минимум ReactiveUI, LiveCharts будут актуалны. Да и старые фреймворки не уходят мгновенно со сцены. Да и кстати, библиотеки перечисленные в статье также можно применять в WinForms.


  1. alec_kalinin
    23.01.2022 18:35
    +3

    Спасибо за статью! Добавлю несколько библиотек от себя

    • WPF UI -- современная библиотека визуальных стилей, молодая и активно развивающаяся

    • OxyPlot -- библиотека 2D графики, тянет real-time отображение

    • Helix Toolkit -- высокоуровненая библиотека 3D графики


    1. AlexDevFx Автор
      23.01.2022 20:32

      Спасибо, OxyPlot как-то пропустил.


    1. AkshinM
      23.01.2022 23:44
      +3

      Это тоже неплоха ModernWpf


    1. leremin
      24.01.2022 12:33
      +1

      Еще Xceed WpfToolkit добавил бы


  1. DarthLexus
    23.01.2022 20:34

    "Джентельменский набор" для WPF у вас перечислен в содержании:

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

    Обработка исключений
    Настройка IoC
    Маппинг объектов

    2 - Реализация MVVM - паттерна

    Модель
    Представление
    Валидация
    Команды
    Отображение динамических данных

    3 - Визуальные темы и элементы управления

    Стиль приложения

    ReactiveUI - это уже вкусовщина и личные предпочтения.

    а писать для контролов вью-модели (ака GaugeControlViewModel) - это вообще дурной тон. ButtonViewModel, TextBoxViewModel в природе ведь не существуют, и хорошо. Контрол должен работать одинаково, независимо как получены значения свойств: через биндинг, цепочку биндингов, или константное значение в xaml:

    <GaugeControl Total="36.6"/>

    <GaugeControl Total="{Binding ElementName=TestNumericUpDown, Path=Value}"/>

    Там еще какой-то подозрительный GaugeBuilder, который во вью-модель затянул визуальные характеристики элемента, которым там не место вообще. Джентельмены настраивают цвет фона (Background) по умолчанию в стилях по умолчанию.

    И вообще, можно поподробнее, как вы собираетесь увязать этот GaugeControl с MVVM? Простая задача: есть N физических приборов (датчиков давления), которые считываются из конфига, в единственном окне приложения надо отображать текущие показания каждого из них на собственном GaugeControl. Загрузка списка датчиков и считывание показаний уже реализованы в классе PressureMonitor

    class Sensor { public string Name {get;set;} public double Pressure {get;set;} }

    class PressureMonitor { public ObservableCollection<Sensor> Sensors { get; } }

    осталось только отобразить


    1. AlexDevFx Автор
      23.01.2022 20:48

      ReactiveUI - это уже вкусовщина и личные предпочтения.

      Вы правы, это моё предпочтение.

      писать для контролов вью-модели (ака GaugeControlViewModel) - это вообще дурной тон

      На мой взгляд, удобно было бы разделить представление от модели как и для окна, не раздувая класс view.

      Там еще какой-то подозрительный GaugeBuilder, который во вью-модель затянул визуальные характеристики элемента, которым там не место вообще. Джентельмены настраивают цвет фона (Background) по умолчанию в стилях по умолчанию.

      Спасибо, учту, в следующих версиях вынесу в стили.

      И вообще, можно поподробнее, как вы собираетесь увязать этот GaugeControl с MVVM?

      Буду решать задачу по мере наступления. Сейчас у меня число сенсоров в устройстве вполне определенное и навряд ли в будущем добавится.


  1. agranom555
    24.01.2022 09:44
    +2

    Для кроссплатформенности и xaml wpf style возможно лучше сразу начинать на Авалонии писать. Если вдруг потом приложение может выйти где-то кроме windows


  1. AlexDevFx Автор
    24.01.2022 10:59

    Avalonia я смотрел, она тоже использует Reactive Extensions. Но меня остановило другое - в Rider визуальный редактор представлений не работает, а с WPF проблем нет. А цели сделать мультиплатформенное приложения у меня не было.


    1. agranom555
      25.01.2022 05:46
      +1

      Для авалонии плагин для райдера есть, чтобы визуально было видно окно


  1. Siemargl
    24.01.2022 11:18

    Пробовал использовать LiveCharts (1.0), отвратительная скорость отрисовки - несколько секунд на 500 точек stepline графика. Но ничего лучше не попалось из бесплатного.


    1. alec_kalinin
      24.01.2022 12:21
      +1

      Попробуйте LiveCharts2. Также можно посмотреть на пример RealtimeDemo в библиотеке OxyPlot. На моей машине рендеринг графика из 20000 точек идет со скорость 60 FPS.


      1. Siemargl
        24.01.2022 12:41

        От второй версии меня удержало монструозное кол-во зависимостей и требование перехода на новейшую студию. Но как нибудь доберусь.


    1. AlexDevFx Автор
      24.01.2022 12:45
      +1

      ScottPlot не пробовали?


  1. Bizonozubr
    24.01.2022 15:06

    Вот про графики очень интересно. А как себя ведут данные плагины, если входных точек больше чем 10000 (например, открыть данные по уровню/давлению за полгода/год)? Как влияет на память и отрисовку?


    1. AlexDevFx Автор
      24.01.2022 15:55

      Не проверял, у меня максимум 20-30 минут нужно мониторить.


  1. HavenDV
    25.01.2022 00:37
    +1

    Мои рекомендации:

    1. Для работы с xaml(любым, не только WPF) - https://github.com/Xavalon/XamlStyler - Форматирует ваш .xaml код согласно вашей конфигурации - https://github.com/Xavalon/XamlStyler/wiki/External-Configurations. Делает это по Ctrl+S при работе с .xaml, очень удобно.

    2. Начните разделять понятия View и Control - View это какой-то специфичный для конкретного приложения UserControl или Window, который имеет ViewModel. А Control имеет только bindable Dependency Properties, и может быть перенесен из проекта в проект.

    3. WPF и Material Design in XAML поддерживают отдельную валидацию для каждого контрола потому что ReactiveValidationObject реализует INotifyDataErrorInfo. Нет необходимости создавать отдельный контрол для вывода ошибок(если это не обусловлено дизайном, конечно). Есть минус - не поддерживаются code-behind биндинги. Для настройки отображения ошибки есть material:ValidationAssist.


  1. Isildur
    25.01.2022 03:55
    +1

    Код MainWindow должен заканчиваться на InitializeComponent. Весь тот код из код бихайнда должен уйти.

    Биндинги должны быть во вью, а вью модель подставляться снаружи, а не с помощью локатора.

    GaugeControl - аналогично, дичь какая-то. Еще и вью модель занимается цветами и конвертацией лейблов для представления.