CommunityToolkit.Mvvm 源码分析 (5) — Source Generators 篇


本篇定位

Source Generators 是 8.0 最重磅的特性——在编译时生成代码,而不是在运行时通过反射。读完你会理解:

  • [ObservableProperty] 在编译时到底生成了什么?
  • [RelayCommand] 如何把方法变成命令属性?
  • 生成器如何从 C# 语法树中提取信息?

1. 示例一:对比有/无 Source Generators

没有生成器(纯手写)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class LoginViewModel : ObservableObject
{
private string _userName;
private string _password;
private bool _isLoggingIn;

public string UserName
{
get => _userName;
set
{
if (SetProperty(ref _userName, value))
LoginCommand.NotifyCanExecuteChanged();
}
}

public string Password
{
get => _password;
set
{
if (SetProperty(ref _password, value))
LoginCommand.NotifyCanExecuteChanged();
}
}

public bool IsLoggingIn
{
get => _isLoggingIn;
set => SetProperty(ref _isLoggingIn, value);
}

private RelayCommand? _loginCommand;
public RelayCommand LoginCommand =>
_loginCommand ??= new RelayCommand(Login, CanLogin);

private void Login() { /* ... */ }
private bool CanLogin() => !string.IsNullOrEmpty(UserName)
&& !string.IsNullOrEmpty(Password);
}

共 48 行,重复代码多。

有生成器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public partial class LoginViewModel : ObservableObject
{
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoginCommand))]
private string _userName;

[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(LoginCommand))]
private string _password;

[ObservableProperty]
private bool _isLoggingIn;

[RelayCommand(CanExecute = nameof(CanLogin))]
private void Login() { /* ... */ }

private bool CanLogin() => !string.IsNullOrEmpty(UserName)
&& !string.IsNullOrEmpty(Password);
}

共 18 行,减少 60% 样板代码。 两者编译结果完全相同。


2. 示例二:看生成的代码

.csproj 中开启:

1
2
3
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>

编译后在 obj/Debug/net8.0/generated/ 下能看到生成的 .g.cs

[ObservableProperty] 生成的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// <auto-generated/>
partial class LoginViewModel
{
public string UserName
{
get => _userName;
set
{
if (!EqualityComparer<string>.Default.Equals(_userName, value))
{
OnPropertyChanging("UserName");
_userName = value;
OnPropertyChanged("UserName");

// [NotifyCanExecuteChangedFor] → 自动插入
LoginCommand.NotifyCanExecuteChanged();
}
}
}
}

[RelayCommand] 生成的代码

1
2
3
4
5
6
7
8
// <auto-generated/>
partial class LoginViewModel
{
private RelayCommand? _loginCommandField;

public RelayCommand LoginCommand =>
_loginCommandField ??= new RelayCommand(Login, CanLogin);
}

3. 示例三:常用注解组合

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public partial class ProfileViewModel : ObservableObject
{
[ObservableProperty]
private string _firstName;

[ObservableProperty]
private string _lastName;

// firstName 或 lastName 变化时通知 FullName 也变了
public string FullName => $"{FirstName} {LastName}";

[ObservableProperty]
[NotifyPropertyChangedFor(nameof(FullName))]
private string _nickName;
}

// 生成的 LoginViewModel_FullName.g.cs 包含:
// public string FullName => $"{FirstName} {LastName}";
// 注意:FullName 本身是计算属性,不是 [ObservableProperty]
// 但当 FirstName 或 LastName 变化时,它会得到新的值

// 生成的 setter:
// set {
// if (SetProperty(ref _nickName, value)) {
// OnPropertyChanged("FullName"); // ← 自动通知
// }
// }

partial 方法钩子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public partial class FormViewModel : ObservableObject
{
[ObservableProperty]
private string _email;

// 生成器会在 setter 中调用此 partial 方法
partial void OnEmailChanged(string value)
{
// 值已变化,可以做额外处理
ValidateEmail(value);
}

partial void OnEmailChanging(string value)
{
// 值即将变化,可以做前置检查
if (string.IsNullOrEmpty(value))
Console.WriteLine("警告:邮箱为空");
}

private void ValidateEmail(string email)
{
if (!email.Contains('@'))
Console.WriteLine("邮箱格式不正确");
}
}

4. 生成器工作原理

1
2
3
4
5
6
7
8
9
10
你的代码(有注解的 partial class)

Roslyn 解析为 SyntaxTree

Source Generator 分析语法树
↓ 找到 [ObservableProperty] 字段 / [RelayCommand] 方法

生成 .g.cs 文件加入编译

与你手写的代码一起编译为程序集

生成器入口(简化版)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[Generator]
public class ObservablePropertyGenerator : ISourceGenerator
{
public void Initialize(GeneratorInitializationContext context)
{
// 注册语法过滤器:只检查标记了特性的字段
context.RegisterForSyntaxNotifications(() => new SyntaxReceiver());
}

public void Execute(GeneratorExecutionContext context)
{
// 遍历所有候选字段
foreach (var field in GetObservablePropertyFields(context))
{
// 生成属性代码
var source = GenerateProperty(field);
context.AddSource(
$"{field.ContainingType.Name}_{field.Name}.g.cs", source);
}
}
}

字段名 → 属性名转换规则

1
2
3
4
// _name    → Name
// _isReady → IsReady
// m_name → Name
// name → Name

5. 常见编译错误

错误 原因 解决
MVVMTK0001 class 不是 partial partial
MVVMTK0002 没继承 ObservableObject 加上继承
MVVMTK0003 字段是 static 去掉 static
MVVMTK0004 字段是 readonly 去掉 readonly
MVVMTK0032 [RelayCommand] 方法不在 partial class 中 加 partial

6. 字段初始化陷阱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public partial class MyVM : ObservableObject
{
// ✅ 正确:字段初始化
[ObservableProperty]
private string _name = "默认值";

// ❌ 陷阱:在构造函数中给属性赋值
public MyVM()
{
// 这里的 Name 调用是 → set → OnPropertyChanged("Name")
// 生成器生成的 setter 会触发通知,但 View 可能还没绑定
Name = "另一个值";
}
}

// 生成的代码会使用字段初始值:
// public string Name { get => _name; set => ... }
// _name = "默认值" 由字段初始化器设置

7. 调试生成代码的技巧

1
2
3
4
5
6
7
8
9
10
// 在代码中也可以看到生成的成员
// 输入 this. → 智能提示会显示生成的属性
partial void OnNameChanged(string value)
{
// 在这里设断点,看调用栈:
// MyVM.set_Name
// → MyVM.OnNameChanged(用户代码)
// → ObservableObject.SetProperty
// → MyVM.get_Name(调用者)
}

8. 手动实现 vs 生成器实现

对比项 手写 Source Generator
代码量 少 60%+
运行时性能 相同 相同(生成代码就是手写代码)
调试体验 直接看到 可在 生成的文件 中查看
约束 需要 partial class + 正确基类

下一篇预告:ObservableRecipient 源码分析 — 带消息能力的 ViewModel 基类