Практическое применение линейной реализации асинхронных операций на примере WPF приложения

от автора

В этой статье я покажу, как использовать IAsyncEnumerable в WPF‑приложении для асинхронных операций. Код полностью рабочий, его можно скопировать и запустить.
Для команд используется библиотека Prism (NuGet: Prism.WPF). Если у вас нет Prism, команды можно реализовать вручную, но в примере используется стандартный подход из Prism.

Предположим нам нужно выполнить поиск устройств по протоколу Modbus RTU или любую другую операцию с результатами.

Часто первым решением становится использовать асинхронную операцию или поток в совокупности с событиями для проброса данных. Но тогда нам необходимо будет управлять подписками на события, пробрасывать данные из внешнего потока в основной UI через Dispatcher.Invoke.

В начале пути я, как и многие, использовал асинхронные операции с событиями, методами‑обработчиками и Dispatcher.Invoke для проброса данных в основной поток. Но если данных много и\или они часто меняются, это создает дополнительные сложности. Так же куча событий, делает код более запутанным и менее масштабируемым.

Начиная с C# 8, появилась возможность использовать IAsyncEnumerable, позволяющие реализовать код профессионального уровня с линейной сигнатурой и преимуществами такими как:

  • Полное соответствие концепции чистой архитектуры

  • Масштабируемость

  • Полной контроль над потоками данных

  • Тестируемость

  • Отменяемость

  • Читаемость

  • Слабая связанность

К примеру, вернемся к поиску устройств. Для демонстрации концепции опишем простой пример потоко‑ориентированного подхода, выполняющего имитацию поиска устройств.

Опишем сущность устройства:

public record struct DeviceModbus(string DeviceName, int Serial, string PortName, byte SlaveId);

Опишем класс имитации поиска:

public class FindDevicesService {private readonly TimeSpan _findDevicesTimout;  // Таймаут прерывания поиска устройств, если конечно необходимprivate readonly Random _rnd = new();  // Обьект для генерации рандомных данныхpublic FindDevicesService(int findDevicesTimeoutSec = 60) {_findDevicesTimout = TimeSpan.FromSeconds(findDevicesTimeoutSec);}/// <summary>/// Имитация поиска устройств/// </summary>/// <param name="token"></param>/// <returns></returns>public async IAsyncEnumerable<DeviceModbus> StartFindDevicesAsync([EnumeratorCancellation]CancellationToken token) {int numberOfSerialPorts = _rnd.Next(1, 256);// Если необходимо ограничение по времени поиска, можем связать токен отмены с таймером// Тогда это будет выглядеть как var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token);linkedCts.CancelAfter(_findDevicesTimout);for (int i = 1; i < numberOfSerialPorts && !linkedCts.Token.IsCancellationRequested; i++ ) {var foundDevice = new DeviceModbus() {DeviceName = $"FoundDevice{_rnd.Next()}",Serial = _rnd.Next(1, 99999),PortName = $"COM{i}",SlaveId = (byte)_rnd.Next(1, 256)};yield return foundDevice;int delayMs = _rnd.Next(50, 500);await Task.Delay(delayMs);}}}

Опишем ViewModel:

public class ViewModel {private readonly AsyncDelegateCommand _startFindDevicesCommand;private readonly DelegateCommand _cancelFindDevicesCommand;private readonly ObservableCollection<DeviceModbus> _connectedDevices = new();private readonly FindDevicesService _findDevicesService = new();private CancellationTokenSource? _findDevicesCTS;public ViewModel() {_startFindDevicesCommand = new AsyncDelegateCommand(StartFindDevicesAsync);_cancelFindDevicesCommand = new DelegateCommand(CancelFindDevices);}public AsyncDelegateCommand StartFindDevicesCommand => _startFindDevicesCommand;public DelegateCommand CancelFindDevicesCommand => _cancelFindDevicesCommand;public ObservableCollection<DeviceModbus> ConnectedDevices => _connectedDevices;/// <summary>/// Запуск процесса поиска устройств/// </summary>private async Task StartFindDevicesAsync() {if (null != _findDevicesCTS&& !_findDevicesCTS.IsCancellationRequested) {// Выполняем какие-то действия, к примеру, говорим пользователю, что токен поиска устройств занятreturn;}_findDevicesCTS = new CancellationTokenSource();if (0 < ConnectedDevices.Count)ConnectedDevices.Clear();IAsyncEnumerable<DeviceModbus> sourceCollection = _findDevicesService.StartFindDevicesAsync(_findDevicesCTS.Token);await foreach (var foundDevice in sourceCollection) {ConnectedDevices.Add(foundDevice);}CancelFindDevices();}/// <summary>/// Остановка процесса поиска устройств/// </summary>private void CancelFindDevices() {if (null == _findDevicesCTS|| _findDevicesCTS.IsCancellationRequested)return;_findDevicesCTS.Cancel();}}

Опишем окно\страницу:

<Window.Resources>    <!-- Визуальный шаблон для карточки управляющего устройства -->    <DataTemplate x:Key="ControlDeviceCardTemplate">        <Border Width="180"                CornerRadius="5"                Background="AntiqueWhite"                Margin="5">            <StackPanel>                <StackPanel Orientation="Vertical"                            VerticalAlignment="Center">                    <TextBlock Text="{Binding DeviceName, Mode=OneTime}"                               TextWrapping="Wrap"/>                    <StackPanel Orientation="Vertical">                        <StackPanel Orientation="Horizontal">                            <TextBlock Text="№"                                       FontWeight="Bold"/>                            <TextBlock Text="{Binding Serial, Mode=OneTime}"                                       FontWeight="Bold"/>                        </StackPanel>                        <StackPanel Orientation="Horizontal">                            <TextBlock Text="{Binding PortName, Mode=OneTime}"                                       FontWeight="Bold"/>                        </StackPanel>                        <StackPanel Orientation="Horizontal">                            <TextBlock Text="{Binding SlaveId, Mode=OneTime}"                                       FontWeight="Bold"/>                        </StackPanel>                    </StackPanel>                </StackPanel>            </StackPanel>        </Border>    </DataTemplate></Window.Resources><Grid>    <Grid.RowDefinitions>        <RowDefinition Height="*"/>        <RowDefinition Height="50"/>    </Grid.RowDefinitions>        <ScrollViewer Grid.Row="0"                  Margin="5"                  HorizontalAlignment="Stretch"                   VerticalAlignment="Stretch"                  VerticalScrollBarVisibility="Auto">        <ItemsControl VerticalAlignment="Top"                      HorizontalAlignment="Left"                      ItemsSource="{Binding ConnectedDevices, UpdateSourceTrigger=PropertyChanged}"                      ItemTemplate="{StaticResource ControlDeviceCardTemplate}">            <ItemsControl.ItemsPanel>                <ItemsPanelTemplate>                    <WrapPanel/>                </ItemsPanelTemplate>            </ItemsControl.ItemsPanel>        </ItemsControl>    </ScrollViewer>    <StackPanel Grid.Row="1"                Orientation="Horizontal"                HorizontalAlignment="Left">        <Button Content="Начать поиск"                Margin="5"                Command="{Binding StartFindDevicesCommand}"/>        <Button Content="Остановить поиск"                Margin="5"                Command="{Binding CancelFindDevicesCommand}"/>    </StackPanel></Grid>

Не забываем про DataContext окна\страницы:

public partial class MainWindow : Window {public MainWindow() {InitializeComponent();DataContext = new ViewModel();}}

Для сравнения: событийный подход потребовал бы подписки, обработчика, отдельного метода для отмены и, в большинстве случаев, Dispatcher.Invoke для обновления UI.
На небольших примерах разница не будет заметна. Но в промышленных системах с тысячами событий это становится проблемой — потребовалась бы дополнительная буферизация данных, чтобы не захлебнуться в их потоке.

Такой сценарий применим не только для поиска устройств, но и ко всем асинхронным операциям с результатами.

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