В этой статье я покажу, как использовать 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.
На небольших примерах разница не будет заметна. Но в промышленных системах с тысячами событий это становится проблемой — потребовалась бы дополнительная буферизация данных, чтобы не захлебнуться в их потоке.
Такой сценарий применим не только для поиска устройств, но и ко всем асинхронным операциям с результатами.
