В этой статье я покажу, как использовать 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/