375 lines
10 KiB
Markdown
375 lines
10 KiB
Markdown
---
|
||
description: WPF MVVM 核心行为约束(项目结构、命名、ViewModel、属性、命令、服务、DI、对话框、输入限长、Framework 基类等),始终生效
|
||
alwaysApply: 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` 强相关,使用其中一个必须搭配另一个。
|
||
|
||
```csharp
|
||
// 普通页面
|
||
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`,把这些逻辑放进去(该方法由基类构造流程调用,时机等同于原构造函数体):
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```csharp
|
||
// ✅ 正确
|
||
private string _title;
|
||
public string Title
|
||
{
|
||
get => _title;
|
||
set => SetProperty(ref _title, value);
|
||
}
|
||
|
||
|
||
```
|
||
|
||
## Command 写法
|
||
|
||
使用 `field ??= new ...` 懒加载,handler 与定义共置,禁止分离到文件底部:
|
||
|
||
```csharp
|
||
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 模板
|
||
|
||
```csharp
|
||
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 在关窗前调用) |
|
||
|
||
```csharp
|
||
// 单例服务
|
||
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`(见「初始化与主构造函数」),不要为此改用传统构造函数:
|
||
|
||
```csharp
|
||
internal class XxxViewModel(IConfigService configService) : ViewModelBaseEx
|
||
{
|
||
// 直接使用 configService
|
||
}
|
||
```
|
||
|
||
## IDialogService
|
||
|
||
```csharp
|
||
|
||
internal class XxxViewModel(IMessageBoxService messageBoxService) : ViewModelBaseEx
|
||
{
|
||
private void ShowWarning()
|
||
{
|
||
messageBoxService.Show("操作成功", "提示", MessageButton.OK, MessageIcon.Information);
|
||
}
|
||
}
|
||
```
|
||
|
||
复杂对话框使用 `add-dialog` skill 创建自定义 DialogService。
|
||
|
||
## LoadingStatus
|
||
|
||
异步加载统一用 `LoadingStatus`,禁止自建 `IsBusy`/`HasError`:
|
||
|
||
```csharp
|
||
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 服务:
|
||
|
||
```xml
|
||
<!-- XAML 注册 -->
|
||
<dxmvvm:Interaction.Behaviors>
|
||
<dxmvvm:OpenFileDialogService Name="OpenFileDialog"/>
|
||
<dxmvvm:SaveFileDialogService Name="SaveFileDialog"/>
|
||
</dxmvvm:Interaction.Behaviors>
|
||
```
|
||
|
||
```csharp
|
||
// 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`:
|
||
|
||
```csharp
|
||
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)。
|
||
|
||
```xml
|
||
<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`。
|
||
|
||
## 服务端实体与 WPF 展示模型
|
||
|
||
- 服务端返回的业务实体命名为 `XxxInfo`(如 `UserInfo`),放在独立的 `Store.Management.Models` 项目中,可脱离 API 和 WPF 使用。
|
||
- WPF 视图绑定展示模型。单个实体的展示模型命名为 `XxxViewModel`(如 `UserViewModel`),集合中的每个元素命名为 `XxxItemViewModel`(如 `IReadOnlyList<UserItemViewModel>` 中的 `UserItemViewModel`)。
|
||
- 从服务端实体到展示模型的转换使用 Riok.Mapperly,映射类放在使用方项目的 `Mappers/` 目录。
|
||
|
||
## 资源引用(忽略)
|
||
|
||
```csharp
|
||
var icon = ImageResources.Icon_XXX_PNG;
|
||
var text = AppStrings.Xxx_Title;
|
||
var color = CustomColors.AccentColor;
|
||
```
|