10 KiB
description, alwaysApply
| description | alwaysApply |
|---|---|
| WPF MVVM 核心行为约束(项目结构、命名、ViewModel、属性、命令、服务、DI、对话框、输入限长、Framework 基类等),始终生效 | true |
WPF 客户端核心规范
项目结构
主项目
项目/
├── Views/ # 视图层
│ ├── MainWindow.xaml
│ ├── AppSplashScreen.xaml
│ └── Dialogs/
├── ViewModels/ # 视图模型层
│ └── MainWindowViewModel.cs
├── App.xaml
└── NLog.config
Framework 项目
项目.Framework/
├── Configs/ Controls/ Converters/ Services/
├── ViewModelServices/ ViewModels/ Helpers/ MarkupExtensions/
├── Mappers/ Utilities/ Interfaces/ Resources/
└── Startup.cs
模块目录
项目.<模块名>/
├── Startup.cs
├── Views/ ├── <模块名>MainView.xaml └── Dialogs/
└── ViewModels/
View / ViewModel 命名约定
以功能名称 Xxx(不含 View 后缀)为前缀:
| 文件类型 | 命名格式 |
|---|---|
| View 文件 | XxxView.xaml |
| ViewModel 文件 | XxxViewModel.cs |
| 对话框 View | XxxDialog.xaml |
| 对话框 ViewModel | XxxDialogViewModel.cs |
严禁将 View 文件名中的 View 后缀保留到 ViewModel 文件名中:
| ❌ 错误 | ✅ 正确 |
|---|---|
AlarmLogViewViewModel.cs |
AlarmLogViewModel.cs |
MainConsoleMainViewViewModel.cs |
MainConsoleMainViewModel.cs |
推导规则:从 {Name}View.xaml 推导 ViewModel 时,取 {Name} 再加 ViewModel.cs。
ViewModel 继承
| 场景 | 基类 |
|---|---|
| 普通页面 | ViewModelBaseEx |
| 对话框 | DialogViewModelBase<TParameter> |
UserControlStyle 与 ViewModelBaseEx 强相关,使用其中一个必须搭配另一个。
// 普通页面
internal class XxxViewModel : ViewModelBaseEx
{
protected override void LoadedOnce()
{
// 首次加载初始化
}
}
// 对话框
internal class XxxDialogViewModel : DialogViewModelBase<XxxDialogParameter>
{
protected override void OnInitialized()
{
// Parameter 和 ParentViewModel 已就绪
}
protected override void Closing(CancelEventArgs args)
{
// 关闭前校验和保存
}
}
初始化与主构造函数
实现接口ViewModelBase / ViewModelBaseEx 时,必须优先使用 Primary Constructor 注入依赖。
主构造函数没有函数体。当构造阶段还有默认值赋值、事件订阅、集合填充等初始化逻辑时,禁止因此退回传统构造函数并手工 _field = param;应 override OnInitializeInRuntime,把这些逻辑放进去(该方法由基类构造流程调用,时机等同于原构造函数体):
internal class XxxViewModel(
IXxxService xxxService,
IYyyService yyyService
) : ViewModelBaseEx
{
protected override void OnInitializeInRuntime()
{
base.OnInitializeInRuntime();
// 原传统构造函数中「除依赖字段赋值外」的初始化逻辑放这里
}
}
| 时机 | 方法 | 用途 |
|---|---|---|
| 构造阶段(基类构造内) | OnInitializeInRuntime |
依赖就绪后的同步初始化;支撑主构造函数写法 |
| 视图首次加载 | LoadedOnce |
依赖 View 已就绪的逻辑 |
| 对话框 Parameter 就绪 | OnInitialized(Dialog) |
对话框专用,勿与 OnInitializeInRuntime 混淆 |
MVVM 属性定义
禁止使用 field 关键字定义绑定属性,必须用 Prism的 父类 BindableBase ,SetProperty:
// ✅ 正确
private string _title;
public string Title
{
get => _title;
set => SetProperty(ref _title, value);
}
Command 写法
使用 field ??= new ... 懒加载,handler 与定义共置,禁止分离到文件底部:
internal class XxxViewModel : BindableBase
{
//同步命令
public ICommand SaveCommand => field?? new DelegateCommand(Save);
private void Save()
{
throw new NotImplementedException();
}
// 异步命令
public ICommand SaveAsyncCommand => field ?? new AsyncDelegateCommand(SaveAsync);
private async Task SaveAsync()
{
throw new NotImplementedException();
}
}
DependencyProperty 模板
public static readonly DependencyProperty XxxProperty =
DependencyProperty.Register(nameof(Xxx), typeof(string), typeof(MyControl), new PropertyMetadata(default(string)));
public string Xxx
{
get => (string)GetValue(XxxProperty);
set => SetValue(XxxProperty, value);
}
确认类对话框优先 IMessageBoxServiceEx.ShowOkCancelWarning / ShowOkCancelQuestion(见 handycontrol-usage.mdc),不要默认用 Yes/No。
AutoFac 依赖注入
生命周期接口
| 接口 | 用途 |
|---|---|
ISingletonDependency |
单例服务 |
IAppInitializeAsync |
异步启动初始化 |
IAppInitialize |
同步启动初始化 |
IAppClosedHandler |
应用关闭时清理(已带单例生命周期;AppClosed(AppCloseParameter),按 Priority 在关窗前调用) |
// 单例服务
internal class XxxService(ILogger<XxxService> logger) : IXxxService, ISingletonDependency
{
}
// 带异步初始化的单例
internal class XxxService(IStatusReport statusReport) : IXxxService, IAppInitializeAsync
{
string IAppInitializeAsync.Description { get; } = "初始化 Xxx";
int IPriority.Priority { get; } = IntPriority.Normal;
async Task IAppInitializeAsync.AppInitializeAsync(IStatusReport statusReport)
{
// 初始化逻辑
}
}
// 应用关闭清理
internal class XxxService : IXxxService, IAppClosedHandler
{
int IAppClosedHandler.Priority { get; } = IntPriority.Normal;
string IAppClosedHandler.Description { get; } = "关闭 Xxx";
void IAppClosedHandler.AppClosed(AppCloseParameter parameter)
{
// 清理资源
}
}
注入方式
优先使用 Primary Constructor。ViewModel 若有构造阶段初始化逻辑,放到 OnInitializeInRuntime(见「初始化与主构造函数」),不要为此改用传统构造函数:
internal class XxxViewModel(IConfigService configService) : ViewModelBaseEx
{
// 直接使用 configService
}
IDialogService
internal class XxxViewModel(IMessageBoxService messageBoxService) : ViewModelBaseEx
{
private void ShowWarning()
{
messageBoxService.Show("操作成功", "提示", MessageButton.OK, MessageIcon.Information);
}
}
复杂对话框使用 add-dialog skill 创建自定义 DialogService。
LoadingStatus
异步加载统一用 LoadingStatus,禁止自建 IsBusy/HasError:
internal class XxxViewModel : ViewModelBaseEx
{
public LoadingStatus LoadingStatus { get; } = new();
public IDelegateCommand LoadCommand => field ??= new AsyncCommand(Load);
private async Task Load()
{
try
{
LoadingStatus.Initialize("读取文件...");
await DoStuff();
LoadingStatus.Done();
}
catch (OperationCanceledException)
{
LoadingStatus.Reset();
}
catch (Exception ex)
{
Debug.WriteLine(ex);
LoadingStatus.Error(ex.Message);
}
}
}
XAML 中用 <f:LoadingCover LoadingStatus="{Binding LoadingStatus}" RefreshCommand="{Binding LoadCommand}"/> 绑定。API 详情见 reference-loading-status skill。
表格控件选择
| 场景 | 控件 |
|---|---|
| 普通配置表格、数据库记录 | 常规 DataGrid |
| 百万行级别大数据 | MAX.FastWpfGrid + FastGridModelBase |
MAX.FastWpfGrid 完整用法见 reference-fast-wpf-grid skill(禁止旧包名 FastWpfGrid)。
文件对话框服务
禁止 Microsoft.Win32.OpenFileDialog / SaveFileDialog,用 DevExpress 服务:
<!-- XAML 注册 -->
<dxmvvm:Interaction.Behaviors>
<dxmvvm:OpenFileDialogService Name="OpenFileDialog"/>
<dxmvvm:SaveFileDialogService Name="SaveFileDialog"/>
</dxmvvm:Interaction.Behaviors>
// ViewModel 使用
public IOpenFileDialogService OpenFileDialog => GetService<IOpenFileDialogService>(nameof(OpenFileDialog));
public ISaveFileDialogService SaveFileDialog => GetService<ISaveFileDialogService>(nameof(SaveFileDialog));
private void OpenFile()
{
if (OpenFileDialog.ShowDialog(OnFileOpen))
{
// 文件已选择
}
}
private void OnFileOpen(CancelEventArgs args, IFileInfo fileInfo)
{
string filePath = fileInfo.GetFullName();
}
文件夹选择
必须使用 AppFileHelper.PickFolder,禁止 WinForms FolderBrowserDialog:
using PipeReviewReport.Framework.Helpers;
if (AppFileHelper.PickFolder("选择文件夹", Environment.GetFolderPath(Environment.SpecialFolder.Desktop), out string path))
{
FolderPath = path;
}
// 简化重载
if (AppFileHelper.PickFolder(out string path)) { FolderPath = path; }
输入框长度限制
强制遵守: 用户能打字的控件都必须显式写 MaxLength,禁止省略。用 {x:Static shared:InputLimits.Xxx},禁止写裸数字。
| 常量 | 值 | 何时用 |
|---|---|---|
InputLimits.Path |
100 | 会变成文件名、文件夹名、路径一部分的名称 |
InputLimits.Text |
250 | 显示名、描述、备注 |
完整路径不要手输,用 AppFileHelper.PickFolder;展示用 TextBlock 或只读 TextBox,不要用 MaxLength 去卡系统路径长度(长路径远超 260)。
<TextBox MaxLength="{x:Static shared:InputLimits.Path}"
Text="{Binding FolderName, UpdateSourceTrigger=PropertyChanged}"/>
<TextBox MaxLength="{x:Static shared:InputLimits.Text}"
Text="{Binding Description, UpdateSourceTrigger=PropertyChanged}"/>
XAML 细则见 wpf-xaml.mdc。
数据映射(忽略)
使用 Mapster:
using PipeReviewReport.Shared;
var dto = model.Adapt<XxxDto>();
var model = dto.Adapt<XxxModel>();
资源引用(忽略)
var icon = ImageResources.Icon_XXX_PNG;
var text = AppStrings.Xxx_Title;
var color = CustomColors.AccentColor;