Mapperly 与 MVVM Toolkit Partial Property 方案的代价¶
- 归档日期:2026-08-25
- 前置分析:Mapperly 与 CommunityToolkit.Mvvm 源码生成器兼容性
- 讨论方案:通过
[ObservableProperty] public partial ...让 Mapperly 在原始 Compilation 中看到 ViewModel 属性。
后续决策(2026-08-25):项目不接受这些语义成本,已改用显式构造函数、Factory 和强类型扩展方法。本文保留为决策依据;现行方案见 WPF 项目用显式映射替代 Mapperly。
结论¶
这种做法的主要代价不是运行时性能,而是:
- 属性初始化和 Mapperly 映射通常都通过 setter,可能触发通知、验证、Command 联动、Messenger 和 partial hooks。
- 无法像字段写法那样在普通构造函数中直接给 backing field 静默赋值。
- ViewModel 更容易被当成 DTO 自动灌入数据,初始化顺序和业务语义变得隐式。
- 项目需要锁定较新的 C#、CommunityToolkit.Mvvm、Mapperly 和 IDE/CI 组合。
- 新增属性可能被按约定自动映射,生成代码和诊断必须进入测试与代码审查。
对于已经选定 .NET 10 + C# 14 + Visual Studio 2026 的项目,第 4 项成本很低;真正需要重视的是前 3 项。
最终建议:
DTO / Application Model / Domain Model
→ 可以放心按需使用 Mapperly
DataGrid Row、筛选条件等纯数据 ViewModel
→ 可以评估 partial property + Mapperly
包含服务、命令、验证、导航、生命周期的完整 ViewModel
→ 保留字段式 ObservableProperty
→ 使用显式构造函数或 Factory,不让 Mapperly 自动灌入
不要为了让 Mapperly 工作,把整个 Presentation 层的 ViewModel 全部改成 partial property。
一、代价总表¶
| 代价 | 影响程度 | 说明 |
|---|---|---|
| 工具链版本下限 | 低 | .NET 10 基线本来就满足;仍需统一 SDK、IDE 和包版本 |
| 普通构造函数无法直接访问 backing field | 中到高 | Name = value 会走完整 setter;字段写法可直接 _name = value |
| 映射触发属性通知和 hook | 高 | Mapperly 写属性时会调用生成 setter |
| 验证与 Command 联动成本 | 中到高 | 大量赋值可能触发验证、CanExecuteChanged、依赖属性通知和消息广播 |
| 初始化顺序耦合 | 高 | 某个 setter/hook 可能观察到尚未映射完成的其他属性 |
| 不可变设计受限 | 中 | Observable partial property 要有 get 和非 init-only set;Mapperly 也需要可访问构造函数或 setter |
| 编译和 IDE 复杂度 | 低到中 | 多一套 incremental generator、诊断和版本兼容矩阵 |
| 调试和代码审查成本 | 中 | 关键行为分散在两个生成器输出中,需要查看生成代码和测试 |
| 运行时映射性能 | 低 | Mapperly 生成普通 C# 赋值,没有反射型 Mapper 的运行时发现成本 |
| 架构误用风险 | 高 | 容易把有生命周期的 ViewModel 当成数据容器 |
二、最大的代价:失去方便的“静默初始化”¶
字段式 ObservableProperty¶
public sealed partial class PersonViewModel : ObservableObject
{
[ObservableProperty]
private string name = string.Empty;
public PersonViewModel(PersonModel model)
{
name = model.Name;
}
}
构造函数直接写 name 字段:
- 不触发
PropertyChanging/PropertyChanged。 - 不调用
OnNameChanging/OnNameChanged。 - 不触发验证、Command 联动或 Messenger。
- 非常适合对象尚未对外发布时的初始装载。
Partial property¶
public sealed partial class PersonViewModel : ObservableObject
{
[ObservableProperty]
public partial string Name { get; set; } = string.Empty;
public PersonViewModel(PersonModel model)
{
Name = model.Name;
}
}
普通构造函数中的 Name = model.Name 会调用生成 setter。当前 MVVM Toolkit 的 setter 可能执行:
OnNameChanging
PropertyChanging
写 backing field
OnNameChanged
PropertyChanged
NotifyPropertyChangedFor
NotifyCanExecuteChangedFor
ValidateProperty
Broadcast
MVVM Toolkit 官方文档展示了这些 setter 扩展点和联动行为。Microsoft:ObservableProperty
CommunityToolkit 的实际讨论也记录了 partial property 在普通构造函数中赋值会触发回调,甚至可能在父对象尚未完成对子对象赋值时执行回调;而属性声明初始化器会直接初始化 backing field,不经过 setter。CommunityToolkit Discussion #1190
可以使用主构造函数参数和属性初始化器:
public sealed partial class PersonViewModel(string initialName) : ObservableObject
{
[ObservableProperty]
public partial string Name { get; set; } = initialName;
}
但这不是通用解法:
- 构造逻辑复杂时主构造函数可读性可能下降。
- 不能方便地在任意方法中访问一个具名 backing field。
- 需要依赖初始化器与 setter 两条不同的状态路径。
- 某些初始化必须等待 DI 服务或异步数据,无法放进属性初始化器。
如果某个属性确实需要静默初始化,手写属性或保留字段式 [ObservableProperty] 通常比引入 suppression flag 更清晰。
三、Mapperly 写入 ViewModel 会触发副作用¶
Mapperly 对可写目标属性通常生成普通属性赋值:
var target = new PersonViewModel();
target.Name = source.Name;
target.Email = source.Email;
return target;
对于普通 DTO,这是理想的。对于 ViewModel,每次赋值都可能产生 UI 语义。
1. 属性通知¶
映射新对象时通常还没有 View 订阅,但 partial hook、验证、Messenger 和内部订阅仍可能运行。映射已绑定的现有 ViewModel 时,每个属性会产生通知,造成多次布局、转换、CanExecute 计算和 UI 刷新。
2. 验证¶
带 [NotifyDataErrorInfo] 的属性会在 setter 中调用 ValidateProperty。如果一次映射几十个属性,验证会逐个执行,而且中间状态可能短暂无效。
3. Command 联动¶
[NotifyCanExecuteChangedFor] 会对目标命令调用 NotifyCanExecuteChanged()。批量映射可能重复计算命令状态。
4. 消息广播¶
[NotifyPropertyChangedRecipients] 会向 Messenger 发送变化消息。数据装载可能被其他模块误解为用户编辑。
5. Partial hooks¶
OnNameChanged 一类 hook 可能:
- 标记 Dirty。
- 启动查询或保存。
- 修改其他属性。
- 订阅/取消订阅子对象事件。
- 通知父 ViewModel。
自动映射并不知道哪些 hook 只适用于“用户修改”,哪些可以在“数据初始化”时运行。
四、属性赋值顺序不能成为业务契约¶
假设:
Mapperly 先设置 Country 还是先设置 Province,不应被当成稳定业务契约。Mapperly 的升级文档明确提醒生成赋值顺序可能变化;如果业务依赖顺序,应查看生成代码并重新设计。Mapperly:v4 Migration
更可靠的设计是:
或者通过构造函数一次建立有效状态,不让逐属性 setter 负责对象装载事务。
五、工具链和版本耦合¶
Partial property 本身从 C# 13 开始存在;CommunityToolkit.Mvvm 8.4.1 更新到 Roslyn 5.0,使其可以在 C# 14 中直接工作,不再需要 LangVersion=preview。Microsoft:Partial Members;CommunityToolkit 8.4.1
因此需要:
.NET 10 stable SDK
C# 14
CommunityToolkit.Mvvm >= 8.4.1
支持对应 Roslyn 的 Visual Studio 2026
CI 与开发机使用同一个 global.json
对本方案原本就选择 .NET 10 的项目,这几乎没有额外成本。但会带来升级测试责任:
- CommunityToolkit 升级:验证属性生成、nullable、hook 和 Analyzer。
- Mapperly 升级:验证成员发现、严格映射和生成赋值。
- SDK/IDE 升级:验证设计时诊断与实际命令行构建一致。
- 不允许开发机依赖 preview SDK,而 CI 使用 stable SDK。
六、不可变性和封装的代价¶
MVVM Toolkit 要求标记 [ObservableProperty] 的 partial property 有 getter 和非 init-only setter。Microsoft:MVVMTK0043
这意味着:
- 不适合本来应该是只读快照或不可变值的对象。
private set虽可用于部分设计,但 Mapperly 从外部生成映射时可能无法直接写入。- 为了自动映射而扩大 setter 可见性,会削弱对象不变量。
- 如果必须通过构造函数保证有效状态,应优先使用 Mapperly 的 constructor mapping 或显式 Factory;Mapperly 支持选择可访问参数化构造函数。Mapperly:Constructor Mapping
不要为了 Mapperly 把只读属性改成公共 setter。
七、编译、调试和维护成本¶
Mapperly 和 MVVM Toolkit 都是 incremental generator,日常编译开销通常比运行时映射问题更值得接受,但仍不是零成本:
- 每次编译多运行一套生成器和 Analyzer。
- IDE 与命令行可能出现短暂的设计时诊断差异,需要 clean/rebuild 或重启 IDE。
- 问题可能来自用户源码、MVVM 生成代码、Mapperly 生成代码或 Roslyn 版本。
- 新成员可能因为同名而自动进入映射,也可能因为命名不同而静默遗漏。
- 团队必须知道如何查看
Dependencies → Analyzers → Generated Files。
建议把以下诊断提升为 error:
[*.cs]
dotnet_diagnostic.RMG012.severity = error
dotnet_diagnostic.RMG020.severity = error
dotnet_diagnostic.RMG066.severity = error
并使用:
至少为每个重要映射写一个 round-trip 或关键字段测试。测试应断言业务字段,而不是只断言目标对象不为空。
八、这种方案没有付出的代价¶
需要客观区分:
- Mapperly 不使用运行时反射发现成员。
- 生成结果是普通、可调试的 C# 代码。
- 不需要运行时注册 mapping profile。
- AOT、裁剪和启动时间通常比运行时反射 Mapper 更容易控制。
- Partial property 本身不会让属性访问比正常属性产生额外抽象层;主要成本来自本来就需要的 MVVM 通知逻辑。
因此,不应因为 Roslyn 生成器限制就认定 Mapperly 本身运行慢或不能进入生产;问题是使用边界,而不是 Mapperly 的运行时模型。
九、四种选项的成本比较¶
| 方案 | 样板代码 | 初始化控制 | 生成器兼容 | 副作用可见性 | 推荐场景 |
|---|---|---|---|---|---|
| 字段式 MVVM + 手写 ViewModel Factory | 中 | 最强 | 不依赖 chaining | 最清晰 | 完整 ViewModel,默认推荐 |
| Partial property + Mapperly | 最少 | 较弱 | 当前可用 | 容易隐藏在 setter | 纯数据行 ViewModel |
| 手写属性 + Mapperly | 中到高 | 强 | 完全可见 | 清晰 | 少量特殊属性、复杂不变量 |
| 拆分程序集后 Mapperly 映射字段式 VM | 少 | 强 | 可工作 | 中 | 多应用共享纯数据 VM,通常不值得单独拆分 |
十、推荐的折中方案¶
1. 完整 ViewModel 保留字段式属性¶
public sealed partial class OrderEditorViewModel : ObservableValidator
{
private readonly IOrderService orderService;
[ObservableProperty]
private string orderNumber = string.Empty;
[ObservableProperty]
private decimal amount;
public OrderEditorViewModel(OrderModel model, IOrderService orderService)
{
this.orderService = orderService;
// 初始装载:直接写字段,不触发“用户编辑”副作用
orderNumber = model.OrderNumber;
amount = model.Amount;
}
}
保存时显式创建模型:
几个字段的显式代码比隐藏副作用更便宜。
2. Mapperly 留在模型边界¶
这里 Mapperly 消除真正的机械映射,又不会接管 UI 生命周期。
3. 纯数据 Row ViewModel 才使用 partial property¶
public sealed partial class OrderRowViewModel : ObservableObject
{
[ObservableProperty]
public partial int Id { get; set; }
[ObservableProperty]
public partial string OrderNumber { get; set; } = string.Empty;
[ObservableProperty]
public partial decimal Amount { get; set; }
}
准入条件:
最终判断¶
如果只是为了减少 5—10 行 ViewModel 初始化代码,partial property + Mapperly 的语义成本往往高于收益。
如果存在几十种结构相近、纯数据、反复双向转换的行模型,并且通知副作用很少,那么它的收益会超过成本。
本技术栈的推荐基线应当是: