WPF MVVM开发中异步命令抽象类的创建与使用及async void规避方案咨询
Hey there, let's break down how to fix the async void issue in your WPF MVVM setup, plus clean up some code along the way.
First, why avoid async void? It’s a big red flag because exceptions thrown in async void methods can’t be caught with regular try/catch blocks (they’ll crash your app instead), and you lose the ability to track when the task completes. Let’s build a proper async-friendly command system.
Step 1: Replace Async Void with Async Task Command Implementation
First, let’s rework your command base to support async execution. We’ll create an async-aware command interface and base class:
// Async command interface to enforce proper task-based execution public interface IAsyncRelayCommand : ICommand { Task ExecuteAsync(object parameter); } // Base class for async commands public abstract class AsyncRelayCommand : IAsyncRelayCommand { public event EventHandler CanExecuteChanged { add => CommandManager.RequerySuggested += value; remove => CommandManager.RequerySuggested -= value; } public virtual bool CanExecute(object parameter) { // Default logic: allow execution unless overridden return true; } // Implement ICommand's void Execute to call our async method void ICommand.Execute(object parameter) { ExecuteAsync(parameter).ConfigureAwait(false); } // Abstract async method for subclasses to implement public abstract Task ExecuteAsync(object parameter); }
Step 2: Update Your ButtonCommand to Use Async Task
Now modify your ButtonCommand to inherit from AsyncRelayCommand and replace async void with async Task:
public class ButtonCommand : AsyncRelayCommand { private readonly ViewModel _viewModel; public ButtonCommand(ViewModel viewModel) { _viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); } public override async Task ExecuteAsync(object obj) { // Skip execution if parameter is invalid (reuse your CanExecute logic) if (!CanExecute(obj)) return; _viewModel.IsBusy = true; try { await Task.Delay(1500); var data = await FetchData(); // Don't reassign the ObservableCollection (breaks binding) // Instead, update the existing collection _viewModel.Models.Clear(); foreach (var item in data) { _viewModel.Models.Add(item); } } catch (Exception ex) { // Handle exceptions properly (e.g., show a user-friendly message) MessageBox.Show($"Oops, something went wrong: {ex.Message}"); } finally { _viewModel.IsBusy = false; } } private async Task<List<Model>> FetchData() { await Task.Delay(100); var data = new List<Model>(); // Add your data fetching logic here return data; } // Override CanExecute to keep your parameter validation logic public override bool CanExecute(object parameter) { return !string.IsNullOrEmpty(parameter?.ToString()); } }
Step 3: Clean Up Your ViewModel
Fix naming conventions, initialize collections properly, and avoid recreating commands on every access:
public class ViewModel : INotifyPropertyChanged { private bool _isBusy; // Use PascalCase for properties (C# convention) public bool IsBusy { get => _isBusy; set => SetField(ref _isBusy, value); } // Initialize collection in constructor to avoid null references public ObservableCollection<Model> Models { get; } // Initialize command once in constructor (not on every property access) public ICommand OnClickButtonCommand { get; } public ViewModel() { Models = new ObservableCollection<Model>(); OnClickButtonCommand = new ButtonCommand(this); } // INotifyPropertyChanged implementation (assuming you have this) public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = null) { if (EqualityComparer<T>.Default.Equals(field, value)) return false; field = value; OnPropertyChanged(propertyName); return true; } }
Bonus: Even Better - Use a Generic Async Command for Reusability
To avoid creating a separate command class for every button, you can build a generic async command that accepts delegates. This reduces boilerplate and keeps business logic in the ViewModel where it belongs:
public class AsyncRelayCommand<T> : IAsyncRelayCommand { private readonly Func<T, Task> _execute; private readonly Predicate<T> _canExecute; private bool _isExecuting; public event EventHandler CanExecuteChanged { add => CommandManager.RequerySuggested += value; remove => CommandManager.RequerySuggested -= value; } public AsyncRelayCommand(Func<T, Task> execute, Predicate<T> canExecute = null) { _execute = execute ?? throw new ArgumentNullException(nameof(execute)); _canExecute = canExecute; } public bool CanExecute(object parameter) { // Prevent duplicate execution while the task is running return !_isExecuting && (_canExecute?.Invoke((T)parameter) ?? true); } void ICommand.Execute(object parameter) { ExecuteAsync((T)parameter).ConfigureAwait(false); } public async Task ExecuteAsync(T parameter) { if (!CanExecute(parameter)) return; try { _isExecuting = true; await _execute(parameter); } finally { _isExecuting = false; // Refresh button enabled/disabled state CommandManager.InvalidateRequerySuggested(); } } }
Now your ViewModel can use this generic command directly, no need for a separate ButtonCommand class:
public class ViewModel : INotifyPropertyChanged { // ... existing properties ... public ICommand OnClickButtonCommand { get; } public ViewModel() { Models = new ObservableCollection<Model>(); OnClickButtonCommand = new AsyncRelayCommand<string>(async (param) => { IsBusy = true; try { await Task.Delay(1500); var data = await FetchData(); Models.Clear(); foreach (var item in data) Models.Add(item); } catch (Exception ex) { MessageBox.Show($"Error: {ex.Message}"); } finally { IsBusy = false; } }, param => !string.IsNullOrEmpty(param)); } // ... existing methods ... }
Key Optimizations Recap
- Eliminated async void: Using
Taskinstead ofvoidlets you catch exceptions and track task completion. - Reduced coupling: Generic commands keep business logic in the ViewModel, avoiding tight links between commands and view models.
- Fixed binding issues: Updating the existing
ObservableCollectioninstead of reassigning it prevents UI binding breaks. - Prevent duplicate clicks: The generic command tracks
_isExecutingto stop users from clicking the button multiple times while a task runs. - Followed C# conventions: PascalCase properties, proper null checks, and one-time command initialization.
内容的提问来源于stack exchange,提问作者jskim

