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

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