跳转至

Mapperly 与 MVVM Toolkit Partial Property 方案的代价

后续决策(2026-08-25):项目不接受这些语义成本,已改用显式构造函数、Factory 和强类型扩展方法。本文保留为决策依据;现行方案见 WPF 项目用显式映射替代 Mapperly

结论

这种做法的主要代价不是运行时性能,而是:

  1. 属性初始化和 Mapperly 映射通常都通过 setter,可能触发通知、验证、Command 联动、Messenger 和 partial hooks。
  2. 无法像字段写法那样在普通构造函数中直接给 backing field 静默赋值。
  3. ViewModel 更容易被当成 DTO 自动灌入数据,初始化顺序和业务语义变得隐式。
  4. 项目需要锁定较新的 C#、CommunityToolkit.Mvvm、Mapperly 和 IDE/CI 组合。
  5. 新增属性可能被按约定自动映射,生成代码和诊断必须进入测试与代码审查。

对于已经选定 .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 只适用于“用户修改”,哪些可以在“数据初始化”时运行。

四、属性赋值顺序不能成为业务契约

假设:

partial void OnCountryChanged(string value)
{
    LoadCities(value, Province);
}

Mapperly 先设置 Country 还是先设置 Province,不应被当成稳定业务契约。Mapperly 的升级文档明确提醒生成赋值顺序可能变化;如果业务依赖顺序,应查看生成代码并重新设计。Mapperly:v4 Migration

更可靠的设计是:

public void Load(PersonSnapshot snapshot)
{
    // 显式定义顺序、验证和副作用边界
}

或者通过构造函数一次建立有效状态,不让逐属性 setter 负责对象装载事务。

五、工具链和版本耦合

Partial property 本身从 C# 13 开始存在;CommunityToolkit.Mvvm 8.4.1 更新到 Roslyn 5.0,使其可以在 C# 14 中直接工作,不再需要 LangVersion=previewMicrosoft:Partial MembersCommunityToolkit 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

并使用:

[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Both)]

至少为每个重要映射写一个 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;
    }
}

保存时显式创建模型:

private OrderUpdate CreateUpdate()
    => new(OrderNumber, Amount);

几个字段的显式代码比隐藏副作用更便宜。

2. Mapperly 留在模型边界

OrderApiDto
↕ Mapperly
OrderModel / OrderUpdate
↕ 显式 Factory / Constructor
OrderEditorViewModel

这里 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; }
}

准入条件:

无 DI 服务
无导航/对话框生命周期
无保存副作用
无跨属性初始化顺序依赖
hook 只做局部、确定且低成本的工作
有严格映射诊断和关键字段测试

最终判断

如果只是为了减少 5—10 行 ViewModel 初始化代码,partial property + Mapperly 的语义成本往往高于收益。

如果存在几十种结构相近、纯数据、反复双向转换的行模型,并且通知副作用很少,那么它的收益会超过成本。

本技术栈的推荐基线应当是:

Mapperly 是模型边界的可选生成器
不是完整 ViewModel 的默认装载器

partial property 是局部工具
不是整个 Presentation 层的强制规范