WPF 对话框服务
大约 9 分钟约 2826 字
WPF 对话框服务
简介
在 MVVM 模式中,ViewModel 不应直接创建 Window 或调用 MessageBox.Show()。对话框服务(IDialogService)将弹窗逻辑抽象为接口,使 ViewModel 可测试且不依赖具体 UI 实现。在工业上位机场景中,对话框服务还涉及设备选择、参数输入、告警确认等业务弹窗,需要统一的弹窗管理策略。
为什么 ViewModel 不应该直接操作 Window
// 错误做法 — ViewModel 直接依赖 UI
public class DeviceViewModel
{
public void DeleteDevice()
{
// ViewModel 引用了 Window 和 MessageBox,破坏了 MVVM
var result = MessageBox.Show("确定删除?", "确认", MessageBoxButton.YesNo);
if (result == MessageBoxResult.Yes)
{
// 执行删除
}
}
}
// 正确做法 — ViewModel 通过接口依赖
public class DeviceViewModel
{
private readonly IDialogService _dialogService;
public DeviceViewModel(IDialogService dialogService)
{
_dialogService = dialogService;
}
public async Task DeleteDeviceAsync()
{
var confirmed = await _dialogService.ShowConfirmAsync("确认删除", "确定要删除该设备吗?");
if (confirmed)
{
// 执行删除
}
}
}对话框服务的核心职责
ViewModel → IDialogService → 具体实现(WPF Window / MessageBox / Custom Dialog)
↓
单元测试中可 Mock特点
实现
基础对话框服务接口
// ========== 对话框服务接口定义 ==========
/// <summary>
/// 对话框服务接口 — ViewModel 通过此接口与 UI 交互
/// </summary>
public interface IDialogService
{
/// <summary>显示确认对话框</summary>
Task<bool> ShowConfirmAsync(string title, string message, string confirmText = "确定", string cancelText = "取消");
/// <summary>显示提示对话框</summary>
Task ShowAlertAsync(string title, string message, string buttonText = "确定");
/// <summary>显示错误对话框</summary>
Task ShowErrorAsync(string title, string message, string buttonText = "确定");
/// <summary>显示输入对话框</summary>
Task<string?> ShowInputAsync(string title, string prompt, string defaultValue = "", string confirmText = "确定");
/// <summary>显示进度对话框</summary>
Task<T?> ShowProgressAsync<T>(string title, Func<IProgress<(int percent, string message)>, Task<T>> work);
/// <summary>显示自定义对话框</summary>
Task<TResult?> ShowDialogAsync<TResult>(string title, object viewModel);
/// <summary>显示 Toast 通知</summary>
void ShowToast(string message, ToastType type = ToastType.Info, int durationMs = 3000);
}
public enum ToastType { Info, Success, Warning, Error }基础对话框服务实现
// ========== WPF 对话框服务实现 ==========
/// <summary>
/// 对话框服务 — WPF 平台实现
/// </summary>
public class DialogService : IDialogService
{
private readonly ILogger<DialogService> _logger;
public DialogService(ILogger<DialogService> logger)
{
_logger = logger;
}
/// <summary>
/// 确认对话框
/// </summary>
public async Task<bool> ShowConfirmAsync(string title, string message,
string confirmText = "确定", string cancelText = "取消")
{
return await Application.Current.Dispatcher.InvokeAsync(() =>
{
_logger.LogDebug("显示确认对话框: {Title} - {Message}", title, message);
var result = MessageBox.Show(
message, title,
MessageBoxButton.YesNo,
MessageBoxImage.Question,
MessageBoxResult.No);
return result == MessageBoxResult.Yes;
});
}
/// <summary>
/// 提示对话框
/// </summary>
public async Task ShowAlertAsync(string title, string message, string buttonText = "确定")
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
MessageBox.Show(message, title, MessageBoxButton.OK, MessageBoxImage.Information);
});
}
/// <summary>
/// 错误对话框
/// </summary>
public async Task ShowErrorAsync(string title, string message, string buttonText = "确定")
{
await Application.Current.Dispatcher.InvokeAsync(() =>
{
MessageBox.Show(message, title, MessageBoxButton.OK, MessageBoxImage.Error);
});
}
/// <summary>
/// 输入对话框
/// </summary>
public async Task<string?> ShowInputAsync(string title, string prompt,
string defaultValue = "", string confirmText = "确定")
{
return await Application.Current.Dispatcher.InvokeAsync(() =>
{
var viewModel = new InputDialogViewModel
{
Title = title,
PromptText = prompt,
InputValue = defaultValue,
ConfirmText = confirmText
};
var dialog = new InputDialog { DataContext = viewModel, Owner = GetMainWindow() };
if (dialog.ShowDialog() == true)
return viewModel.InputValue;
return null;
});
}
/// <summary>
/// 显示进度对话框
/// </summary>
public async Task<T?> ShowProgressAsync<T>(string title,
Func<IProgress<(int percent, string message)>, Task<T>> work)
{
return await Application.Current.Dispatcher.InvokeAsync(async () =>
{
var viewModel = new ProgressDialogViewModel { Title = title };
var dialog = new ProgressDialog { DataContext = viewModel, Owner = GetMainWindow() };
var progress = new Progress<(int percent, string message)>(p =>
{
viewModel.Percent = p.percent;
viewModel.StatusMessage = p.message;
});
var task = Task.Run(() => work(progress));
dialog.Show();
try
{
var result = await task;
dialog.Close();
return result;
}
catch (Exception ex)
{
dialog.Close();
await ShowErrorAsync("操作失败", ex.Message);
return default;
}
});
}
/// <summary>
/// 显示自定义对话框(通过 ViewModel 类型自动定位 View)
/// </summary>
public async Task<TResult?> ShowDialogAsync<TResult>(string title, object viewModel)
{
return await Application.Current.Dispatcher.InvokeAsync(() =>
{
// 根据约定查找 View 类型
var viewType = FindViewType(viewModel.GetType());
if (viewType == null)
{
_logger.LogError("找不到 ViewModel {VmType} 对应的 View", viewModel.GetType().Name);
return default;
}
var dialog = Activator.CreateInstance(viewType) as Window;
if (dialog == null) return default;
dialog.DataContext = viewModel;
dialog.Title = title;
dialog.Owner = GetMainWindow();
dialog.WindowStartupLocation = WindowStartupLocation.CenterOwner;
// 监听 RequestClose 事件
if (viewModel is DialogViewModelBase dvm)
{
dvm.RequestClose += (_, _) => dialog.Close();
}
dialog.ShowDialog();
return (TResult?)dvm?.DialogResult;
});
}
/// <summary>
/// 显示 Toast 通知
/// </summary>
public void ShowToast(string message, ToastType type = ToastType.Info, int durationMs = 3000)
{
Application.Current.Dispatcher.Invoke(() =>
{
var toast = new ToastNotification(message, type, durationMs);
toast.Show();
});
}
private Window? GetMainWindow() => Application.Current.MainWindow;
private Type? FindViewType(Type viewModelType)
{
// 约定:ViewModel 在 ViewModels 命名空间,View 在 Views 命名空间
// ViewModel 名称去掉 "ViewModel" 后缀即为 View 名称
var vmName = viewModelType.Name;
var viewName = vmName.EndsWith("ViewModel")
? vmName[..^"ViewModel".Length]
: vmName;
var assembly = viewModelType.Assembly;
return assembly.GetType($"MyApp.Views.{viewName}");
}
}对话框 ViewModel 基类
// ========== 通用对话框 ViewModel ==========
/// <summary>
/// 对话框 ViewModel 基类
/// 所有对话框 ViewModel 继承此基类
/// </summary>
public class DialogViewModelBase : ObservableObject
{
private string _title = "";
private string _confirmText = "确定";
private string _cancelText = "取消";
private bool? _dialogResult;
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
public string ConfirmText
{
get => _confirmText;
set => SetProperty(ref _confirmText, value);
}
public string CancelText
{
get => _cancelText;
set => SetProperty(ref _cancelText, value);
}
public bool? DialogResult
{
get => _dialogResult;
protected set => SetProperty(ref _dialogResult, value);
}
public event EventHandler? RequestClose;
public ICommand ConfirmCommand { get; }
public ICommand CancelCommand { get; }
protected DialogViewModelBase()
{
ConfirmCommand = new RelayCommand(
() => { DialogResult = true; RequestClose?.Invoke(this, EventArgs.Empty); },
() => CanConfirm());
CancelCommand = new RelayCommand(
() => { DialogResult = false; RequestClose?.Invoke(this, EventArgs.Empty); });
}
/// <summary>
/// 子类重写以控制确认按钮是否可用
/// </summary>
protected virtual bool CanConfirm() => true;
}自定义输入对话框
// ========== 输入对话框 ViewModel ==========
public class InputDialogViewModel : DialogViewModelBase
{
private string _promptText = "";
private string _inputValue = "";
public string PromptText
{
get => _promptText;
set => SetProperty(ref _promptText, value);
}
public string InputValue
{
get => _inputValue;
set => SetProperty(ref _inputValue, value);
}
protected override bool CanConfirm() => !string.IsNullOrWhiteSpace(InputValue);
}
// ========== 输入对话框 XAML ==========
/*
<Window x:Class="MyApp.Views.InputDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="{Binding Title}" SizeToContent="WidthAndHeight"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
ShowInTaskbar="False">
<StackPanel Margin="20" Width="400">
<TextBlock Text="{Binding PromptText}" Margin="0,0,0,10" TextWrapping="Wrap"/>
<TextBox Text="{Binding InputValue, UpdateSourceTrigger=PropertyChanged}"
Margin="0,0,0,15" Padding="6"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="{Binding CancelText}" Command="{Binding CancelCommand}"
Width="80" Margin="0,0,8,0" Padding="4"/>
<Button Content="{Binding ConfirmText}" Command="{Binding ConfirmCommand}"
Width="80" Padding="4" IsDefault="True"/>
</StackPanel>
</StackPanel>
</Window>
*/设备选择对话框
// ========== 业务对话框示例 — 设备选择 ==========
/// <summary>
/// 设备选择对话框 ViewModel
/// </summary>
public class DevicePickerViewModel : DialogViewModelBase
{
private readonly IEnumerable<Device> _devices;
private Device? _selectedDevice;
private string _filterText = "";
public ObservableCollection<Device> FilteredDevices { get; } = new();
public Device? SelectedDevice
{
get => _selectedDevice;
set => SetProperty(ref _selectedDevice, value);
}
public string FilterText
{
get => _filterText;
set
{
SetProperty(ref _filterText, value);
ApplyFilter();
}
}
public DevicePickerViewModel(IEnumerable<Device> devices)
{
_devices = devices;
foreach (var device in devices)
FilteredDevices.Add(device);
}
private void ApplyFilter()
{
FilteredDevices.Clear();
foreach (var device in _devices.Where(d =>
d.Name.Contains(FilterText, StringComparison.OrdinalIgnoreCase) ||
d.Address.Contains(FilterText, StringComparison.OrdinalIgnoreCase)))
{
FilteredDevices.Add(device);
}
if (FilteredDevices.Count > 0)
SelectedDevice = FilteredDevices[0];
}
/// <summary>
/// 双击选择
/// </summary>
[RelayCommand]
private void DoubleClickSelect()
{
if (SelectedDevice != null)
ConfirmCommand.Execute(null);
}
}
// ========== ViewModel 中调用 ==========
public class OrderViewModel : ObservableObject
{
private readonly IDialogService _dialogService;
private Device? _selectedDevice;
public OrderViewModel(IDialogService dialogService)
{
_dialogService = dialogService;
}
public async Task SelectDeviceAsync()
{
var availableDevices = await _deviceService.GetAvailableAsync();
var pickerVm = new DevicePickerViewModel(availableDevices);
// 通过对话框服务显示
// 使用 ShowDialogAsync 需要注册 View-ViewModel 映射
// 这里使用简化方式
var result = await _dialogService.ShowDialogAsync<Device>("选择设备", pickerVm);
if (result != null)
{
SelectedDevice = result;
}
}
}Toast 通知实现
// ========== Toast 通知控件 ==========
/// <summary>
/// Toast 通知窗口
/// </summary>
public class ToastNotification : Window
{
private readonly System.Threading.Timer _closeTimer;
public ToastNotification(string message, ToastType type = ToastType.Info, int durationMs = 3000)
{
// 窗口样式设置
WindowStyle = WindowStyle.None;
AllowsTransparency = true;
Background = Brushes.Transparent;
ShowInTaskbar = false;
Topmost = true;
Width = 320;
SizeToContent = SizeToContent.Height;
WindowStartupLocation = WindowStartupLocation.Manual;
// 定位在右下角
Left = SystemParameters.PrimaryScreenWidth - Width - 20;
Top = SystemParameters.PrimaryScreenHeight - 120;
// 内容
var color = type switch
{
ToastType.Success => "#4CAF50",
ToastType.Warning => "#FF9800",
ToastType.Error => "#F44336",
_ => "#2196F3"
};
Content = new Border
{
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(color)),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(16, 12, 16, 12),
Child = new TextBlock
{
Text = message,
Foreground = Brushes.White,
FontSize = 14,
TextWrapping = TextWrapping.Wrap
}
};
// 自动关闭
_closeTimer = new System.Threading.Timer(_ =>
{
Dispatcher.Invoke(() => Close());
}, null, durationMs, Timeout.Infinite);
}
public new void Show()
{
ShowActivated = false;
base.Show();
}
}单元测试中的 Mock
// ========== 单元测试 — Mock 对话框服务 ==========
/// <summary>
/// Mock 对话框服务 — 单元测试中使用
/// </summary>
public class MockDialogService : IDialogService
{
// 自动确认
public bool AutoConfirm { get; set; } = true;
public string? AutoInputValue { get; set; } = "测试输入";
public List<string> ShownMessages { get; } = new();
public Task<bool> ShowConfirmAsync(string title, string message,
string confirmText = "确定", string cancelText = "取消")
{
ShownMessages.Add($"Confirm: {title} - {message}");
return Task.FromResult(AutoConfirm);
}
public Task ShowAlertAsync(string title, string message, string buttonText = "确定")
{
ShownMessages.Add($"Alert: {title} - {message}");
return Task.CompletedTask;
}
public Task ShowErrorAsync(string title, string message, string buttonText = "确定")
{
ShownMessages.Add($"Error: {title} - {message}");
return Task.CompletedTask;
}
public Task<string?> ShowInputAsync(string title, string prompt,
string defaultValue = "", string confirmText = "确定")
{
ShownMessages.Add($"Input: {title} - {prompt}");
return Task.FromResult(AutoInputValue);
}
public Task<T?> ShowProgressAsync<T>(string title,
Func<IProgress<(int percent, string message)>, Task<T>> work)
{
var progress = new Progress<(int, string)>();
return Task.FromResult(work(progress).GetAwaiter().GetResult());
}
public Task<TResult?> ShowDialogAsync<TResult>(string title, object viewModel)
{
return Task.FromResult<TResult?>(default);
}
public void ShowToast(string message, ToastType type = ToastType.Info, int durationMs = 3000)
{
ShownMessages.Add($"Toast: {message}");
}
}
// 使用示例
[Fact]
public async Task DeleteDevice_ShouldShowConfirm_AndDeleteOnConfirm()
{
// Arrange
var mockDialog = new MockDialogService { AutoConfirm = true };
var vm = new DeviceViewModel(mockDialog, _deviceService);
// Act
await vm.DeleteDeviceAsync();
// Assert
Assert.Single(mockDialog.ShownMessages);
Assert.Contains("确认删除", mockDialog.ShownMessages[0]);
Assert.False(vm.Devices.Any()); // 设备已删除
}DI 注册
// ========== 依赖注入注册 ==========
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddDialogServices(this IServiceCollection services)
{
services.AddSingleton<IDialogService, DialogService>();
return services;
}
}
// App.xaml.cs
var services = new ServiceCollection();
services.AddDialogServices();
var serviceProvider = services.BuildServiceProvider();优点
缺点
总结
IDialogService 抽象了 MVVM 中的弹窗逻辑,使 ViewModel 保持可测试性。基础场景可封装 MessageBox,复杂场景使用自定义 DialogViewModel。建议在 DI 容器中注册 DialogService 为单例,ViewModel 通过构造函数注入使用。对话框 ViewModel 继承 DialogViewModelBase,通过 RequestClose 事件关闭窗口。
关键知识点
- ViewModel 不应直接引用 Window 或 MessageBox 类型。
- 对话框操作必须在 UI 线程执行,需要 Dispatcher 调度。
- 模态对话框通过 ShowDialog 阻塞,非模态通过 Show 显示。
- DialogViewModel 的 RequestClose 事件是关闭窗口的标准模式。
- Owner 属性确保对话框与主窗口关联,防止任务栏独立显示。
- MockDialogService 使单元测试中不弹出真实窗口。
项目落地视角
- 在 DI 容器中注册 IDialogService 为单例。
- 为常用弹窗场景(确认、输入、选择)提供标准方法。
- 对话框样式统一,与主题系统集成。
- 为对话框操作添加日志,方便调试追踪。
常见误区
- 在 ViewModel 中直接 new Window() 破坏 MVVM。
- 忘记处理对话框的 Owner 导致弹窗在任务栏独立显示。
- 非模态对话框关闭后忘记清理资源。
- 在 Dispatcher.Invoke 中执行长时间操作阻塞 UI。
- 对话框 ViewModel 没有继承 DialogViewModelBase 导致关闭逻辑不一致。
进阶路线
- 学习 Prism 的 IDialogService 实现和对话框注册机制。
- 研究非模态通知和 Toast 组件的实现。
- 实现对话框的动画效果和遮罩层。
- 研究 ContentDialog(类似 UWP 的内联对话框)。
适用场景
- 需要弹出确认、警告、输入等对话框。
- 需要从列表中选择项目的弹窗选择器。
- 需要显示操作结果通知(Toast)。
- 需要显示长时间操作的进度对话框。
落地建议
- 定义 IDialogService 接口覆盖所有弹窗需求。
- 为弹窗提供统一的动画和样式模板。
- 使用 Owner 属性确保对话框与主窗口关联。
- 编写 MockDialogService 方便单元测试。
排错清单
- 确认弹窗操作在 UI 线程执行。
- 检查 Owner 是否设置正确。
- 确认 DialogResult 在关闭前被正确设置。
- 检查 View-ViewModel 映射是否正确。
复盘问题
- 如何在单元测试中验证 ViewModel 的弹窗逻辑?
- 模态对话框和 Messenger 通知在什么场景下各更合适?
- 如何处理对话框被用户直接关闭(点 X)的情况?
