.NET MAUI 10 实战:用 CommunityToolkit.Mvvm 把 MVVM 写到刚刚好
.NET MAUI 10 实战:用 CommunityToolkit.Mvvm 把 MVVM 写到刚刚好
从官方 Notes 教程继续往前一步:用 CommunityToolkit.Mvvm 的
[ObservableProperty]与[RelayCommand]源生成器,把 MAUI 的 MVVM 样板代码压缩成一行,并补上依赖注入、Shell 路由、编译绑定与 17 个可运行的单元测试。版本基线:.NET 10(SDK 10.0.402) · .NET MAUI 10 · CommunityToolkit.Mvvm 8.4.2 · 12 节 · 约 50 分钟
目录
- 导读:从官方教程停下的地方继续
- 项目蓝图:把 MVVM 拆成三个项目
- 认识工具箱与源生成器
- 用 [ObservableProperty] 声明属性
- 用 [RelayCommand] 声明命令
- 模型与存储服务
- 列表页:集合、搜索与下拉刷新
- 编辑页:路由参数、保存与删除
- 装配:依赖注入、Shell 与启动
- 单元测试:没有界面也能验证行为
- 避坑与诊断清单
- 源码清单与延伸阅读
00 导读:从官方教程停下的地方继续
你已经跟着官方教程把 Notes 应用改成 MVVM 了。但代码里还是一堆手写的属性通知、一堆 new AsyncRelayCommand(...)。这一节先说清楚:我们为什么要再往前一步,以及会得到什么。
微软官方的 《使用 MVVM 概念升级应用》 是一份很好的入门材料:它把模型、视图、视图模型拆开,把 Clicked 事件换成 Command,还演示了 IQueryAttributable 与 Shell 导航参数。学完之后,你会有一个能跑、结构正确的应用。
它停下的地方,恰好是日常开发真正开始的地方:每加一个可绑定属性,就要写十行 get/set 与 OnPropertyChanged;每加一个命令,就要声明字段、在构造函数里赋值、再考虑 CanExecute 什么时候失效。这些代码没有表达任何业务意图,却占据了 ViewModel 的大半篇幅。
官方教程:手写
private Models.Note _note;
public string Text
{
get => _note.Text;
set
{
if (_note.Text != value)
{
_note.Text = value;
OnPropertyChanged();
}
}
}
public ICommand SaveCommand { get; private set; }
public NoteViewModel()
{
_note = new Models.Note();
SaveCommand = new AsyncRelayCommand(Save);
}
本教程:声明式
[ObservableProperty]
public partial string Text { get; set; }
[ObservableProperty]
public partial bool IsExisting { get; set; }
[RelayCommand(CanExecute = nameof(CanSave))]
async Task SaveAsync()
{
_note.Text = Text;
await Task.Run(() => _store.Save(_note));
await _navigation.GoBackAsync();
}
右边这些 [ObservableProperty]、[RelayCommand] 不是运行时反射魔法,而是 编译期源生成器:C# 编译器在编译时把属性、命令、通知逻辑写进你的程序集。生成出来的代码与手写等价,只是不用你写,也永远不会写漏。
这份教程与官方教程的关系
- 延续:同样是 Notes 应用,同样是 Shell 导航、
ObservableCollection、CollectionView。 - 替换:所有属性改用
[ObservableProperty],所有命令改用[RelayCommand],不再手写ICommand字段。 - 加固:把模型/服务/ViewModel 抽到一个不依赖 MAUI 的类库,于是可以真的写单元测试;Shell 静态 API 用
INavigationService隔离。 - 补齐:依赖注入、路由参数、
CanExecute联动、异常处理、编译期绑定,一样不少。
读完之后,你会得到
- 一个可以运行的三项目解决方案:
Notes(MAUI 应用)、Notes.Core(纯 .NET 的 MVVM 核心)、Notes.Tests(xUnit)。 - 一个不再出现
OnPropertyChanged()、new RelayCommand(...)的 ViewModel 层。 - 一份可复用的判断标准:什么时候该用源生成器,什么时候手写反而更清楚。
- 一套能直接抄走的排查清单,覆盖源生成器、编译绑定、依赖注入、Shell 导航的常见坑。
开始之前
| 项目 | 要求 / 说明 |
|---|---|
| .NET SDK | 10.0 或更高。本教程用 10.0.402 验证,C# 14,因此 [ObservableProperty] 的 partial 属性写法开箱即用。 |
| MAUI 工作负载 | dotnet workload install maui(只做 Windows 可装 maui-windows)。dotnet new list maui 能列出模板即安装成功。 |
| 编辑器 | Visual Studio(支持 .NET 10 的版本)或 VS Code + .NET MAUI 扩展。所有命令都用 CLI 给出,不依赖 IDE。 |
| C# 基础 | 类、属性、async/await、Task、LINQ、可空引用类型。源生成器部分不需要提前了解。 |
| MAUI 基础 | 至少读过官方 Notes 教程,知道 XAML、BindingContext、Shell 是什么。没读过也能跟,但会少一些“为什么要这样改”的共鸣。 |
🧭 时间不多?
只关心工具包:读 02~04,半小时就能把两个特性的用法与边界弄清楚。想完整做一个应用:按顺序读,每节都有可直接粘贴的完整文件。想直接看结果:跳到 11 拿源码清单。
📌 本节要点
- 官方教程解决“结构对不对”,本教程解决“代码写得累不累、测不测得了”。
- 源生成器在编译期生成代码,运行时没有反射开销,AOT/裁剪也更友好。
- 三个项目不是炫技,是为了让 ViewModel 能脱离 MAUI 运行时被测试。
01 项目蓝图:把 MVVM 拆成三个项目
官方教程是一个 MAUI 项目装下所有东西。我们改成三个:应用、可测试的核心、测试。这不是为了“架构好看”,而是为了让 ViewModel 能在没有模拟器、没有 MAUI 运行时的情况下被验证。
谁负责什么
| 项目 | 目标框架 | 内容 | 依赖 |
|---|---|---|---|
Notes.Core |
net10.0 |
模型(Note)、服务抽象(INoteStore、INavigationService)、全部 ViewModel |
只依赖 CommunityToolkit.Mvvm |
Notes |
net10.0-android 等 |
XAML 页面、AppShell、MauiProgram、Shell 导航实现、关于页 ViewModel |
Notes.Core、MAUI |
Notes.Tests |
net10.0 |
xUnit 测试与两个假实现(内存存储、记录式导航) | Notes.Core、xUnit |
依赖方向是单向的:App → Core ← Tests。Core 不知道 MAUI 的存在,所以 dotnet test 不需要 MAUI 工作负载、不需要 Android 模拟器,几百毫秒就能跑完。
创建项目
先装工作负载,再建三个项目并串起来。下面的命令在解决方案根目录执行:
终端 / PowerShell
dotnet workload install maui
dotnet new maui -n Notes
dotnet new classlib -n Notes.Core -f net10.0
dotnet new xunit -n Notes.Tests -f net10.0
dotnet new sln -n Notes
dotnet sln add Notes/Notes.csproj Notes.Core/Notes.Core.csproj Notes.Tests/Notes.Tests.csproj
# 工具包与项目引用
dotnet add Notes.Core package CommunityToolkit.Mvvm
dotnet add Notes package CommunityToolkit.Mvvm
dotnet add Notes reference Notes.Core
dotnet add Notes.Tests reference Notes.Core
⚠️ 别把 ViewModel 留在 MAUI 项目里
如果把
NotesViewModel放在Notes项目里,测试项目就必须引用一个多目标的 MAUI 项目,在不同平台上跑测试会变得非常麻烦。把 ViewModel 放进Notes.Core,测试才真的跑得起来。
最终的目录结构
maui-mvvm-notes/
├─ Notes.Core/ # net10.0,零 MAUI 依赖
│ ├─ Models/
│ │ ├─ Note.cs
│ │ └─ NoteSummary.cs
│ ├─ Services/
│ │ ├─ INoteStore.cs
│ │ ├─ FileNoteStore.cs
│ │ ├─ INavigationService.cs
│ │ └─ Routes.cs
│ └─ ViewModels/
│ ├─ NotesViewModel.cs
│ └─ NoteViewModel.cs
├─ Notes/ # MAUI 应用
│ ├─ Views/
│ │ ├─ NotesPage.xaml (+ .cs)
│ │ ├─ NotePage.xaml (+ .cs)
│ │ └─ AboutPage.xaml (+ .cs)
│ ├─ ViewModels/AboutViewModel.cs
│ ├─ Services/ShellNavigationService.cs
│ ├─ AppShell.xaml (+ .cs)
│ ├─ App.xaml (+ .cs)
│ ├─ MauiProgram.cs
│ ├─ Notes.csproj
│ └─ Resources/ # 模板自带:图标、字体、样式
└─ Notes.Tests/ # net10.0,xUnit
├─ FakeNoteStore.cs
├─ FakeNavigationService.cs
├─ FileNoteStoreTests.cs
├─ NotesViewModelTests.cs
└─ NoteViewModelTests.cs
两个设计决定,先说清楚
1. 查询参数由页面解析,而不是 ViewModel
官方教程让 ViewModel 实现 IQueryAttributable。这很方便,但它会给 ViewModel 引入 Microsoft.Maui.Controls 依赖。我们的做法是让 NotePage 实现接口,解析出 id 后调用 viewModel.Load(id)。导航是“视图的事”,ViewModel 只负责“给我一条笔记的 id,我把状态准备好”。
2. Shell 被隔离在一个 20 行的类里
Shell.Current.GoToAsync() 是静态 API,测试里既不能替换也不好断言。INavigationService 把它抽象掉,ViewModel 只看到 GoToAsync(route) 与 GoBackAsync()。于是测试可以断言“保存后确实返回了”。
官方结构
Notes
├─ Models/ Views/ ViewModels/
└─ Notes.csproj (net10.0-android ...)
ViewModel 依赖 MAUI
测试需要 MAUI 运行时
本教程结构
Notes (MAUI UI)
└─▶ Notes.Core (纯 .NET)
▲
Notes.Tests ──┘
dotnet test 42ms 跑完 17 个测试
💡 这不是唯一解
单项目结构在小型应用里完全够用,官方教程的选择没有问题。当你开始担心“改 ViewModel 会不会碰坏界面”、或者想给保存逻辑写测试时,再拆也不迟。拆分的成本主要是多一个项目引用,收益是可测试性与更快的反馈。
📌 本节要点
- Core 只依赖工具包,不依赖 MAUI;App 与 Tests 都只依赖 Core。
- 导航与查询参数属于视图层,
INavigationService与IQueryAttributable都放在 App 项目。 - 结构服务于反馈速度:测试跑得快,你才愿意多写。
02 认识工具箱与源生成器
CommunityToolkit.Mvvm 不是一个“MVVM 框架”,它是一组小而硬的积木:可观察对象、命令、消息、校验。其中两个源生成器,正好对应 MVVM 里最啰嗦的两件事——属性和命令。
这个包里有什么
| 类型 / 特性 | 作用 | 本教程是否使用 |
|---|---|---|
ObservableObject |
实现 INotifyPropertyChanged / INotifyPropertyChanging,提供 SetProperty、OnPropertyChanged |
✅ 所有 ViewModel 的基类 |
[ObservableProperty] |
从字段或 partial 属性生成可观察属性 | ✅ 核心 |
RelayCommand / AsyncRelayCommand |
ICommand 的轻量实现 |
✅ 由 [RelayCommand] 生成 |
[RelayCommand] |
从方法生成命令属性 | ✅ 核心 |
ObservableValidator |
基于 DataAnnotations 的属性校验,支持 INotifyDataErrorInfo |
⭕ 进阶一节演示 |
WeakReferenceMessenger |
发布/订阅,替代 MessagingCenter |
⭕ 延伸阅读 |
Ioc 容器 |
极简 DI,可选用;MAUI 自带 DI,本教程不用它 | — |
安装
工具包是纯托管库,支持 netstandard2.0 及以上,MAUI 与测试项目都能直接用:
终端
dotnet add package CommunityToolkit.Mvvm --version 8.4.2
项目文件里会多出一行:
Notes.Core.csproj
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>
💡 为什么锁定版本号
源生成器与运行时库版本必须一致,也建议在团队内固定版本。8.4.x 才完整支持本文使用的 partial 属性写法;更老的 8.0/8.1 只能给字段加特性。
源生成器是怎么工作的
你在代码里写一个“只有声明、没有实现”的 partial 属性,编译器在编译过程中调用工具包的生成器,生成另一份 partial 声明来补齐实现。两份声明在同一个程序集里合并,最终产物和你手写完全一样。
你写的
using CommunityToolkit.Mvvm.ComponentModel;
public partial class NoteViewModel : ObservableObject
{
[ObservableProperty]
public partial string Text { get; set; }
}
编译器看到的(略作简化)
partial class NoteViewModel
{
public partial string Text
{
get => field;
set
{
if (!EqualityComparer<string>.Default.Equals(field, value))
{
string oldValue = field;
OnTextChanging(value);
OnTextChanging(oldValue, value);
OnPropertyChanging();
field = value;
OnTextChanged(value);
OnTextChanged(oldValue, value);
OnPropertyChanged();
}
}
}
partial void OnTextChanging(string value);
partial void OnTextChanged(string value);
partial void OnTextChanging(string oldValue, string newValue);
partial void OnTextChanged(string oldValue, string newValue);
}
注意 get => field;:生成器用的是 C# 14 的 field 上下文关键字,也就是“属性自己的隐式后备字段”。这解释了为什么 partial 属性写法要求较新的编译器—— .NET 10 默认 C# 14,开箱即用;如果项目还在 net9.0,需要 <LangVersion>preview</LangVersion>(C# 13 中 field 还是预览特性)。
三个前提,缺一不可
-
类必须是
partial:生成器要往同一个类型里再写一份声明,没有partial就没有地方放。 -
属性必须是
partial(字段写法不受此限):属性写法:public partial string Text { get; set; }。字段写法:private string _text;,生成器负责生成Text。 -
类型要有属性通知的“基础设施”:继承
ObservableObject(本教程的选择),或ObservableValidator/ObservableRecipient。否则生成器不会工作,字段写法会直接报MVVMTK0019,属性写法则表现为“缺少实现部分”(CS9248)。
想看生成出来的代码?
两种方式,都很实用:
- IDE:Visual Studio 的“依赖项 → 分析器 → CommunityToolkit.Mvvm.SourceGenerators”里可以直接展开看到生成的文件。
- 命令行:给项目加上两个属性,生成结果会落盘到
generated/,方便对比与排错:
Notes.Core.csproj
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
记得把 generated/ 加进 .gitignore,否则这些文件会被当成源码再次编译,出现“类型重复定义”的怪错误。
⚠️ “生成器没反应”先查基类
最常见的三个原因:类忘了
partial、属性忘了partial(或字段名不符合lowerCamel/_lowerCamel/m_lowerCamel)、类型没继承ObservableObject。按这个顺序查,比盯着报错猜快得多。
📌 本节要点
- 生成器在编译期补齐实现,运行时零反射,Native AOT 与裁剪都友好。
- 类
partial+ 属性partial+ 继承ObservableObject,是本文所有属性的三件套。 - 生成结果可落盘检查,排错时先看它,比看 IDE 波浪线高效。
03 用 [ObservableProperty] 声明属性
把“字段 + 手写 get/set + OnPropertyChanged”压缩成一行声明。这一节把写法、命名、钩子、联动通知和校验一次讲完。
两种写法,选一种就好
8.4 起推荐 partial 属性写法:声明即属性,IDE 的转到定义、重命名、可空性分析都按属性对待。字段写法仍然支持,适合还在 .NET 8/9、或团队暂时不想升级语言版本的代码库。
推荐:partial 属性(C# 14)
[ObservableProperty]
public partial string Text { get; set; }
[ObservableProperty]
public partial bool IsExisting { get; set; }
兼容:字段(8.0 起)
[ObservableProperty]
private string _text;
[ObservableProperty]
private bool _isExisting;
两种写法生成同样的公开属性。字段写法里字段可以是 private,生成器负责补一个 public 属性;属性写法里你自己就是 public,生成器只补实现体。
📌 命名怎么映射
字段
_text、m_text、text都会生成Text;_isExisting生成IsExisting。属性写法直接用你写的名字,没有映射。别在同一类型里同时声明Text字段与Text属性——那会直接撞名。
默认值直接写在声明上
partial 属性可以带初始化器,效果与字段初始化器一致——在构造函数里赋值。下面的 SearchText 一开始就是空字符串,绑定到 SearchBar 也不会出现 null:
Notes.Core/ViewModels/NotesViewModel.cs
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
值变化时跑一段代码:OnXxxChanged
生成器会为每个属性生成两对 partial 方法,你可以按需实现任意一个(不实现就完全不会有开销):
| 钩子 | 时机 | 参数 |
|---|---|---|
OnXxxChanging |
赋值之前 | 新值,或 (旧值, 新值) |
OnXxxChanged |
赋值之后、PropertyChanged 之前 |
新值,或 (旧值, 新值) |
列表页的搜索就是靠它实现的:SearchText 一变,立即重新过滤。注意方法签名要写 partial、不能加访问修饰符、参数类型必须完全一致:
Notes.Core/ViewModels/NotesViewModel.cs
partial void OnSearchTextChanged(string value) => ApplyFilter();
void ApplyFilter()
{
var keyword = SearchText.Trim();
IEnumerable<NoteSummary> filtered = keyword.Length == 0
? _allNotes
: _allNotes.Where(note => note.Text.Contains(keyword, StringComparison.OrdinalIgnoreCase));
Notes.Clear();
foreach (var note in filtered)
Notes.Add(note);
}
联动通知:一个属性变了,另一个也要刷新
编辑页的标题来自 Header,而 Header 的值取决于 IsExisting。Header 是只读计算属性,不会自己发通知,所以让 IsExisting 变化时顺带通知它:
Notes.Core/ViewModels/NoteViewModel.cs
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Header))]
[NotifyCanExecuteChangedFor(nameof(DeleteCommand))]
public partial bool IsExisting { get; set; }
public string Header => IsExisting ? "编辑笔记" : "新建笔记";
联动命令:属性变了,按钮可用状态跟着变
保存按钮只在文本非空时可用。与其在 setter 里手动调用 SaveCommand.NotifyCanExecuteChanged(),不如声明出来:
Notes.Core/ViewModels/NoteViewModel.cs
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
public partial string Text { get; set; }
这样当用户清空编辑器时,按钮会自动变灰;重新输入又会亮起。界面里不需要任何“手动刷新按钮状态”的代码。
进阶:属性校验
如果 ViewModel 继承 ObservableValidator,可以给属性加 DataAnnotations,再用 [NotifyDataErrorInfo] 让每次赋值都触发校验。校验结果通过 INotifyDataErrorInfo 暴露给界面:
示例:带校验的标题
using System.ComponentModel.DataAnnotations;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class NoteDraftViewModel : ObservableValidator
{
[ObservableProperty]
[NotifyDataErrorInfo]
[Required(ErrorMessage = "标题不能为空")]
[MinLength(2, ErrorMessage = "至少 2 个字符")]
public partial string Title { get; set; } = string.Empty;
[RelayCommand]
void Submit()
{
ValidateAllProperties();
if (HasErrors)
return;
// 通过校验,继续保存
}
}
💡 校验显示的边界
工具包只负责“算”出错误集合,不负责“画”错误提示。MAUI 的
Entry/Editor不会自动展示INotifyDataErrorInfo的错误,你需要在 XAML 里绑定Errors或自己写一个小控件。对多数应用,在命令里用CanExecute控制按钮,比全套校验更划算。
把特性加到生成的属性上
用 partial 属性写法时,特性本来就写在属性上,直接加即可;[property: ...] 主要给 字段写法用——它把特性“转发”给生成出来的属性,而不是留在私有字段上:
示例:字段写法
[ObservableProperty]
[property: JsonPropertyName("content")]
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
private string? _content;
生成后,Content 属性自带 [JsonPropertyName("content")] 与 [JsonIgnore]。注意只有 DataAnnotations 校验特性会被自动转发,其它特性都要显式写 [property: ...]。
什么时候别用它
- 需要自定义 get/set 逻辑:比如 setter 里要
await或做多步副作用。手写属性更直白。 - 属性需要
init或只写:生成器生成的是get; set;,不合用。 - 对外 API 需要精确控制特性顺序:虽然能转发,但手写更可控。
- 只读计算属性:不用特性,手写
=>,需要刷新时用[NotifyPropertyChangedFor]。
📌 本节要点
- 首选
public partial string Text { get; set; },一行一个属性。 OnXxxChanged放副作用,[NotifyPropertyChangedFor]联动计算属性,[NotifyCanExecuteChangedFor]联动按钮。- 校验用
ObservableValidator+[NotifyDataErrorInfo],但 UI 提示要自己接。
04 用 [RelayCommand] 声明命令
方法还在,命令自动出现。这一节覆盖同步/异步、带参数、CanExecute、执行中禁用、取消与异常,基本就是 ICommand 在日常开发里的全部用法。
从方法到命令
你写的
[RelayCommand]
async Task SaveAsync()
{
_note.Text = Text;
await Task.Run(() => _store.Save(_note));
await _navigation.GoBackAsync();
}
生成的
public IAsyncRelayCommand SaveCommand =>
saveCommand ??= new AsyncRelayCommand(SaveAsync);
命名规则很简单:方法名去掉 Async 后缀,再加 Command。SaveAsync → SaveCommand,Open → OpenCommand。原来的方法是私有的,生成属性是公开的;XAML 里绑定生成的那个属性。
| 方法签名 | 生成的属性 | 说明 |
|---|---|---|
void Load() |
IRelayCommand LoadCommand |
同步命令 |
void Open(NoteSummary note) |
IRelayCommand<NoteSummary> OpenCommand |
带一个参数 |
Task LoadAsync() |
IAsyncRelayCommand LoadCommand |
异步命令,可等待 |
Task LoadAsync(string id) |
IAsyncRelayCommand<string> LoadCommand |
异步 + 参数 |
Task WorkAsync(CancellationToken token) |
IAsyncRelayCommand WorkCommand |
令牌由命令注入,可从外部取消 |
CanExecute:按钮什么时候可点
指向一个方法或属性。方法签名要与命令参数一致,没有参数的命令用无参方法:
Notes.Core/ViewModels/NoteViewModel.cs
[RelayCommand(CanExecute = nameof(CanSave))]
async Task SaveAsync()
{
// ...
}
bool CanSave() => !string.IsNullOrWhiteSpace(Text);
[RelayCommand(CanExecute = nameof(CanDelete))]
async Task DeleteAsync()
{
// ...
}
bool CanDelete() => IsExisting;
CanExecute 只在命令创建时调用一次,之后要靠 NotifyCanExecuteChanged() 通知它重新评估。上一节的 [NotifyCanExecuteChangedFor] 就是在属性变化时替你做这件事。
⚠️ 忘了通知,按钮就“卡住”
最常见现象:输入框已经有内容了,保存按钮还是灰的。原因不是绑定错了,而是
CanSave没有被重新调用。检查两点:属性上有没有[NotifyCanExecuteChangedFor(nameof(SaveCommand))],或者命令的CanExecute有没有指向一个真实存在的方法(拼错名字在编译期就会报错)。
执行期间自动禁用,不需要 IsBusy
AsyncRelayCommand 默认 AllowConcurrentExecutions = false:任务没结束时命令不可执行,按钮自动变灰。这正是“双击保存”问题的正解,不用自己维护 IsBusy 再绑定 IsEnabled。
确实需要并发(比如批量上传)时再显式打开:
示例
[RelayCommand(AllowConcurrentExecutions = true)]
async Task UploadAsync(string path)
{
await _uploader.SendAsync(path);
}
带参数的命令
方法最多接受一个参数,类型即命令的泛型参数。XAML 用 CommandParameter 传值:
Notes.Core/ViewModels/NotesViewModel.cs
[RelayCommand]
Task OpenNoteAsync(NoteSummary? note) =>
note is null
? Task.CompletedTask
: _navigation.GoToAsync($"{Routes.NotePage}?id={note.Id}");
XAML
<Button Text="打开"
Command="{Binding OpenNoteCommand}"
CommandParameter="{Binding SelectedNote}" />
值类型参数要注意:RelayCommand<int> 收到 null 会抛异常。要么在 CanExecute 里挡掉,要么把参数改成可空类型。
取消:长任务的标准姿势
方法最后一个参数是 CancellationToken 时,命令会把令牌传进来;再加上 IncludeCancelCommand = true,还会额外生成一个 XxxCancelCommand:
示例:导出笔记
[ObservableProperty]
public partial double Progress { get; set; }
[RelayCommand(IncludeCancelCommand = true)]
async Task ExportAsync(CancellationToken token)
{
try
{
for (var i = 0; i <= 100; i += 10)
{
await Task.Delay(200, token);
Progress = i / 100d;
}
}
catch (OperationCanceledException)
{
// 用户点了取消:安静退出,不要当异常往上抛
}
}
XAML
<ProgressBar Progress="{Binding Progress}" />
<Button Text="开始导出" Command="{Binding ExportCommand}" />
<Button Text="取消" Command="{Binding ExportCancelCommand}" />
异常怎么处理
默认行为是“等待并重新抛出”:await 命令的执行结果时,异常会原样抛回来。在 UI 里直接 Command="{Binding SaveCommand}" 的情况下没人接这个异常,应用就会崩。
两种处理方式,选一种:
- 在方法内部
try/catch,把错误转成可展示的状态(本教程的做法:ErrorMessage属性)。 - 让异常流向
TaskScheduler,不崩应用,但需要你自己订阅UnobservedTaskException,更复杂。
Notes.Core/ViewModels/NoteViewModel.cs(节选)
[RelayCommand(CanExecute = nameof(CanSave))]
async Task SaveAsync()
{
ErrorMessage = null;
try
{
_note.Text = Text;
await Task.Run(() => _store.Save(_note));
UpdatedAt = _note.UpdatedAt;
await _navigation.GoBackAsync();
}
catch (Exception ex)
{
ErrorMessage = $"保存失败:{ex.Message}";
}
}
给它加特性
与方法上的 [ObservableProperty] 一样,命令属性也支持 [property: ...] 转发:
示例
[RelayCommand]
[property: JsonIgnore]
void Reset() { /* ... */ }
什么时候手写命令
- 命令需要在运行时整体替换(例如依赖某个服务实例),生成的是只读属性。
- 需要把命令当数据传递、或实现自己的
ICommand变体。 - 方法有多个参数,而
ICommand只接受一个——这种情况通常应该封装成参数对象,而不是手写命令。
📌 本节要点
[RelayCommand]从方法生成命令属性,命名是“去 Async + Command”。CanExecute负责可用性,但必须有人通知它重新评估。- 异步命令默认禁止并发执行,天然解决重复点击。
- 取消令牌 +
IncludeCancelCommand,长任务两行搞定。
05 模型与存储服务
ViewModel 之前,先把“数据长什么样”和“数据存在哪”分清楚。模型只描述数据,存储服务只负责读写,两者都不认识界面,也不认识彼此之外的任何东西。
模型:一条笔记
Note 是领域模型:唯一标识、文本、更新时间。标识同时是磁盘上的文件名,因此不需要数据库,也不需要自增主键。
Notes.Core/Models/Note.cs
namespace Notes.Models;
/// <summary>
/// 一条笔记的领域模型:只描述数据,不关心界面,也不关心数据存在哪里。
/// </summary>
public sealed class Note
{
/// <summary>笔记的唯一标识,同时也是磁盘上的文件名。</summary>
public string Id { get; init; } = $"{Path.GetRandomFileName()}.notes.txt";
public string Text { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; } = DateTime.Now;
public NoteSummary ToSummary() => new(Id, Text, UpdatedAt);
}
Path.GetRandomFileName() 生成类似 yk2l0m.abc 的名字,拼上 .notes.txt 就是最终文件名。这比 Guid 更短,也方便肉眼识别;代价是“可预测性”,但对本地笔记应用无所谓。
摘要:列表只需要的字段
列表页不需要完整模型,它只要预览文本和时间。用一个 record 投影,顺便把“空笔记怎么显示”“时间怎么格式化”这类展示逻辑收在这一层:
Notes.Core/Models/NoteSummary.cs
namespace Notes.Models;
/// <summary>
/// 列表页需要的笔记摘要。视图模型把 <see cref="Note"/> 投影成它,
/// 这样列表项不必暴露整个模型,也方便附加只读的展示属性。
/// </summary>
public sealed record NoteSummary(string Id, string Text, DateTime UpdatedAt)
{
public string Preview => string.IsNullOrWhiteSpace(Text) ? "(空笔记)" : Text;
public string TimeText => $"更新于 {UpdatedAt:MM-dd HH:mm}";
}
📌 为什么不让列表直接绑
Note可以,但那样“空笔记显示什么”“日期格式是什么”就会散落到 XAML 的
StringFormat和转换器里。放进NoteSummary后,这些规则可测试、可复用,XAML 只负责排版。
存储抽象:一个接口,两种实现
ViewModel 只认 INoteStore。应用运行时用文件实现,单元测试里用内存实现,两边互不干扰:
Notes.Core/Services/INoteStore.cs
using Notes.Models;
namespace Notes.Services;
/// <summary>
/// 笔记持久化抽象。视图模型只依赖这个接口,
/// 因此单元测试里可以换成内存实现,完全不碰文件系统。
/// </summary>
public interface INoteStore
{
IReadOnlyList<Note> LoadAll();
Note? Load(string id);
void Save(Note note);
void Delete(string id);
}
文件实现
注意构造函数接收根目录,而不是直接调用 FileSystem.AppDataDirectory:这样 Core 项目不需要引用 MAUI,测试也可以把根目录指向临时文件夹。
Notes.Core/Services/FileNoteStore.cs
using Notes.Models;
namespace Notes.Services;
/// <summary>
/// 文件系统实现:每条笔记是应用数据目录下的一个 *.notes.txt 文件。
/// 根目录由构造函数传入,App 里传 FileSystem.AppDataDirectory,
/// 单元测试里传临时目录。
/// </summary>
public sealed class FileNoteStore : INoteStore
{
const string SearchPattern = "*.notes.txt";
readonly string _rootDirectory;
public FileNoteStore(string rootDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory);
_rootDirectory = rootDirectory;
Directory.CreateDirectory(_rootDirectory);
}
public IReadOnlyList<Note> LoadAll() =>
Directory.EnumerateFiles(_rootDirectory, SearchPattern)
.Select(path => Load(Path.GetFileName(path)))
.OfType<Note>()
.OrderByDescending(note => note.UpdatedAt)
.ToList();
public Note? Load(string id)
{
var path = GetPath(id);
if (!File.Exists(path))
return null;
return new Note
{
Id = Path.GetFileName(path),
Text = File.ReadAllText(path),
UpdatedAt = File.GetLastWriteTime(path)
};
}
public void Save(Note note)
{
var path = GetPath(note.Id);
File.WriteAllText(path, note.Text);
note.UpdatedAt = File.GetLastWriteTime(path);
}
public void Delete(string id)
{
var path = GetPath(id);
if (File.Exists(path))
File.Delete(path);
}
// 只用文件名,避免外部传入的 id 里带 ../ 逃出应用数据目录。
string GetPath(string id) => Path.Combine(_rootDirectory, Path.GetFileName(id));
}
实现里的三个细节
| 细节 | 为什么这样做 |
|---|---|
LoadAll() 按 UpdatedAt 倒序 |
列表天然“最近更新在最上面”,ViewModel 不需要再排序。文件时间由操作系统写入,Save 之后回读一次,保证内存模型与磁盘一致。 |
GetPath 里用 Path.GetFileName(id) |
防止外部传入 ../secret 这类标识把读写带到应用数据目录之外。笔记 id 是内部生成的,但防御性代码不贵。 |
Delete 先判断存在 |
删除不存在的文件不应该抛异常——用户连点两次删除是很常见的事。 |
⚠️ 文件 IO 不是 UI 线程的活
这里的
Save/LoadAll都是同步 API。ViewModel 调用时会包一层Task.Run,避免大文件或慢磁盘卡住界面。如果你把存储换成数据库,也建议保持“接口同步、调用方异步”或“接口异步”二者之一,不要混着来。
目录里的位置
Notes.Core/
├─ Models/
│ ├─ Note.cs # 领域模型
│ └─ NoteSummary.cs # 列表投影
└─ Services/
├─ INoteStore.cs # 抽象
├─ FileNoteStore.cs # 文件实现
├─ INavigationService.cs
└─ Routes.cs
📌 本节要点
- 模型只描述数据;展示规则放进
NoteSummary,不要散落到 XAML。 - 抽象出
INoteStore,让 ViewModel 与文件系统解耦,测试才能用内存实现。 - 根目录由外部注入,Core 因此不需要认识
FileSystem。
06 列表页:集合、搜索与下拉刷新
第一个完整页面。没有一行 OnPropertyChanged,也没有一个事件处理器:搜索、刷新、新增、打开,全部由属性与命令驱动。
先把导航抽象出来
列表页要做两件事:跳到编辑页、从编辑页返回。ViewModel 不应该知道 Shell.Current 的存在,于是先定义接口和路由常量:
Notes.Core/Services/INavigationService.cs
namespace Notes.Services;
/// <summary>
/// 导航抽象。视图模型不认识 Shell,也不需要认识它:
/// 真正的 Shell 导航由 App 项目里的 ShellNavigationService 完成。
/// </summary>
public interface INavigationService
{
Task GoToAsync(string route);
Task GoBackAsync();
}
Notes.Core/Services/Routes.cs
namespace Notes.Services;
/// <summary>集中管理路由名,避免各处写魔术字符串。</summary>
public static class Routes
{
public const string NotePage = "note";
}
列表页 ViewModel
整个类只有四个属性、三个命令和一个过滤方法。建议先通读一遍,再看下面的逐段解释:
Notes.Core/ViewModels/NotesViewModel.cs
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Notes.Models;
using Notes.Services;
namespace Notes.ViewModels;
/// <summary>
/// 列表页的视图模型:负责加载、搜索、以及“新增 / 打开某条笔记”两个动作。
/// 属性用 [ObservableProperty] 生成,命令用 [RelayCommand] 生成。
/// </summary>
public partial class NotesViewModel : ObservableObject
{
readonly INoteStore _store;
readonly INavigationService _navigation;
readonly List<NoteSummary> _allNotes = [];
public NotesViewModel(INoteStore store, INavigationService navigation)
{
_store = store;
_navigation = navigation;
}
/// <summary>界面绑定的集合,增删会实时反映到 CollectionView。</summary>
public ObservableCollection<NoteSummary> Notes { get; } = [];
[ObservableProperty]
public partial string SearchText { get; set; } = string.Empty;
[ObservableProperty]
public partial NoteSummary? SelectedNote { get; set; }
[ObservableProperty]
public partial bool IsRefreshing { get; set; }
/// <summary>页面出现或下拉刷新时调用:读磁盘、重建列表。</summary>
[RelayCommand]
async Task LoadAsync()
{
IsRefreshing = true;
try
{
// 文件 IO 放到线程池,避免卡住 UI 线程。
var notes = await Task.Run(_store.LoadAll);
_allNotes.Clear();
_allNotes.AddRange(notes.Select(note => note.ToSummary()));
ApplyFilter();
}
finally
{
IsRefreshing = false;
}
}
[RelayCommand]
Task NewNoteAsync() => _navigation.GoToAsync(Routes.NotePage);
[RelayCommand]
Task OpenNoteAsync(NoteSummary? note) =>
note is null
? Task.CompletedTask
: _navigation.GoToAsync($"{Routes.NotePage}?id={note.Id}");
// 选中即打开;打开后由页面在 OnAppearing 里把选中项清空,方便再次点同一条。
partial void OnSelectedNoteChanged(NoteSummary? value)
{
if (value is null)
return;
OpenNoteCommand.Execute(value);
}
// SearchText 一变化就重新过滤——这是 [ObservableProperty] 的“钩子”能力。
partial void OnSearchTextChanged(string value) => ApplyFilter();
void ApplyFilter()
{
var keyword = SearchText.Trim();
IEnumerable<NoteSummary> filtered = keyword.Length == 0
? _allNotes
: _allNotes.Where(note => note.Text.Contains(keyword, StringComparison.OrdinalIgnoreCase));
Notes.Clear();
foreach (var note in filtered)
Notes.Add(note);
}
}
1. 两个集合:全量与过滤结果
_allNotes 保存加载后的完整列表,Notes 是界面绑定的 ObservableCollection。搜索时只重建 Notes,不动 _allNotes,这样清空关键字就能立刻恢复全部数据,不需要重新读磁盘。
2. 加载:异步 + 刷新指示
LoadAsync 用 Task.Run 把文件 IO 挪到线程池;await 之后回到 UI 线程,再操作集合,避免跨线程异常。IsRefreshing 用 try/finally 复位,哪怕中途抛异常,下拉刷新的转圈也不会卡住。
Notes.Core/ViewModels/NotesViewModel.cs(节选)
[RelayCommand]
async Task LoadAsync()
{
IsRefreshing = true;
try
{
var notes = await Task.Run(_store.LoadAll);
_allNotes.Clear();
_allNotes.AddRange(notes.Select(note => note.ToSummary()));
ApplyFilter();
}
finally
{
IsRefreshing = false;
}
}
3. 搜索:一个钩子方法
OnSearchTextChanged 是生成器预留的 partial 方法,实现它即可。过滤用 OrdinalIgnoreCase,中英文都不受区域设置影响;关键字首尾空格会先被裁掉。
4. 选中即打开
SelectedNote 变化时触发导航。之所以在 OnSelectedNoteChanged 里调用 OpenNoteCommand 而不是直接 await,是因为钩子必须是 void;交给命令执行,异常与并发策略也统一由命令管理。
Notes.Core/ViewModels/NotesViewModel.cs(节选)
partial void OnSelectedNoteChanged(NoteSummary? value)
{
if (value is null)
return;
OpenNoteCommand.Execute(value);
}
📌 为什么不用
SelectionChangedCommand官方教程用
SelectionChangedCommandParameter="{Binding Source={RelativeSource Self}, Path=SelectedItem}"传选中项。这个写法要求项目开启<MauiEnableXamlCBindingWithSourceCompilation>true</MauiEnableXamlCBindingWithSourceCompilation>,否则带Source的那条绑定不会被编译,只能退回运行时反射。绑SelectedItem更直接:ViewModel 里已有SelectedNote,XAML 不用再和RelativeSource较劲。
列表页 XAML
三个绑定源要分清:页面级 x:DataType 是 ViewModel,DataTemplate 里的 x:DataType 是 NoteSummary,两者都在编译期校验。
Notes/Views/NotesPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:models="clr-namespace:Notes.Models;assembly=Notes.Core"
xmlns:vm="clr-namespace:Notes.ViewModels;assembly=Notes.Core"
x:Class="Notes.Views.NotesPage"
x:DataType="vm:NotesViewModel"
Title="我的笔记">
<ContentPage.ToolbarItems>
<ToolbarItem Text="新增" Command="{Binding NewNoteCommand}" />
</ContentPage.ToolbarItems>
<Grid RowDefinitions="Auto,*" RowSpacing="8" Padding="16,8,16,16">
<SearchBar Placeholder="搜索笔记…"
Text="{Binding SearchText}" />
<RefreshView Grid.Row="1"
Command="{Binding LoadCommand}"
IsRefreshing="{Binding IsRefreshing, Mode=TwoWay}">
<CollectionView x:Name="NotesList"
ItemsSource="{Binding Notes}"
SelectionMode="Single"
SelectedItem="{Binding SelectedNote, Mode=TwoWay}">
<CollectionView.EmptyView>
<VerticalStackLayout Padding="24" Spacing="6" VerticalOptions="Center">
<Label Text="这里还没有笔记"
FontSize="18"
HorizontalTextAlignment="Center" />
<Label Text="点右上角「新增」写一条吧。"
FontSize="14"
HorizontalTextAlignment="Center"
TextColor="{AppThemeBinding Light=#6B7280, Dark=#9CA3AF}" />
</VerticalStackLayout>
</CollectionView.EmptyView>
<CollectionView.ItemsLayout>
<LinearItemsLayout Orientation="Vertical" ItemSpacing="10" />
</CollectionView.ItemsLayout>
<CollectionView.ItemTemplate>
<DataTemplate x:DataType="models:NoteSummary">
<Border StrokeThickness="0"
StrokeShape="RoundRectangle 14"
Padding="14,12"
BackgroundColor="{AppThemeBinding Light=#F2F3F7, Dark=#23262E}">
<VerticalStackLayout Spacing="4">
<Label Text="{Binding Preview}"
FontSize="17"
MaxLines="2"
LineBreakMode="TailTruncation" />
<Label Text="{Binding TimeText}"
FontSize="12"
TextColor="{AppThemeBinding Light=#6B7280, Dark=#9CA3AF}" />
</VerticalStackLayout>
</Border>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</RefreshView>
</Grid>
</ContentPage>
几个容易忽略的点
- 跨程序集的命名空间:ViewModel 在
Notes.Core里,所以xmlns:vm="clr-namespace:Notes.ViewModels;assembly=Notes.Core"。少了assembly=会报“找不到类型”。 DataTemplate必须有自己的x:DataType,否则模板里的绑定无法编译,运行期才报错。AppThemeBinding让卡片颜色跟随系统深浅色,不需要写转换器。EmptyView在集合为空时自动显示;搜索无结果时也走这里,文案保持中性(“这里还没有笔记”)。
页面代码后置:只做视图该做的事
OnAppearing 里做两件事:清空选中项、重新加载。前者是纯视图状态(否则从编辑页返回后无法再次点击同一条笔记),后者是页面生命周期驱动的数据刷新。
Notes/Views/NotesPage.xaml.cs
using Notes.ViewModels;
namespace Notes.Views;
public partial class NotesPage : ContentPage
{
readonly NotesViewModel _viewModel;
public NotesPage(NotesViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
BindingContext = viewModel;
}
protected override void OnAppearing()
{
base.OnAppearing();
// 清空选中项,否则从编辑页返回后无法再次点同一条笔记。
NotesList.SelectedItem = null;
// 每次出现都重新读一遍,保存/删除后的列表与排序自然是最新的。
_viewModel.LoadCommand.Execute(null);
}
}
💡 “每次出现都重载”是不是偷懒?
对本地文件笔记来说,这是最省心且正确的策略:保存、删除、排序一次到位,不需要
?saved=/?deleted=之类的查询参数来回传状态。数据量大时再改成增量更新或缓存,不必现在就为性能过度设计。
这一页跑起来是什么样
- 首次进入:读完应用数据目录,按更新时间倒序显示卡片;没有笔记时显示空状态。
- 输入关键字:列表实时收窄,匹配不区分大小写;清空后恢复全部。
- 下拉:转圈出现又消失,
IsRefreshing与界面双向同步。 - 点击卡片:进入编辑页;返回后列表已刷新,选中态已清空,可以继续点同一条。
📌 本节要点
ObservableCollection只承载界面需要的投影,全量数据留在普通List。- 搜索靠
OnSearchTextChanged钩子,不需要在 XAML 里挂事件。 - 选中项用两向绑定驱动导航,避开
Source编译绑定的坑。
07 编辑页:路由参数、保存与删除
新建和编辑共用一个页面、一个 ViewModel。区别只在“进来的方式是空路由,还是带一个 id”。这一节把导航参数、可用性联动、异常处理和返回时机串起来。
编辑页 ViewModel
Notes.Core/ViewModels/NoteViewModel.cs
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Notes.Models;
using Notes.Services;
namespace Notes.ViewModels;
/// <summary>
/// 编辑页的视图模型:新建或编辑一条笔记,并负责保存、删除。
/// </summary>
public partial class NoteViewModel : ObservableObject
{
readonly INoteStore _store;
readonly INavigationService _navigation;
Note _note;
public NoteViewModel(INoteStore store, INavigationService navigation)
{
_store = store;
_navigation = navigation;
_note = new Note();
Text = _note.Text;
UpdatedAt = _note.UpdatedAt;
}
// 文本变化时让“保存”按钮的可用状态跟着变。
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
public partial string Text { get; set; }
[ObservableProperty]
public partial DateTime UpdatedAt { get; set; }
// 是否在编辑已有笔记:决定标题文案与删除按钮是否可用。
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Header))]
[NotifyCanExecuteChangedFor(nameof(DeleteCommand))]
public partial bool IsExisting { get; set; }
public string Header => IsExisting ? "编辑笔记" : "新建笔记";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasError))]
public partial string? ErrorMessage { get; set; }
public bool HasError => !string.IsNullOrEmpty(ErrorMessage);
[RelayCommand(CanExecute = nameof(CanSave))]
async Task SaveAsync()
{
ErrorMessage = null;
try
{
_note.Text = Text;
await Task.Run(() => _store.Save(_note));
UpdatedAt = _note.UpdatedAt;
await _navigation.GoBackAsync();
}
catch (Exception ex)
{
ErrorMessage = $"保存失败:{ex.Message}";
}
}
bool CanSave() => !string.IsNullOrWhiteSpace(Text);
[RelayCommand(CanExecute = nameof(CanDelete))]
async Task DeleteAsync()
{
ErrorMessage = null;
try
{
await Task.Run(() => _store.Delete(_note.Id));
await _navigation.GoBackAsync();
}
catch (Exception ex)
{
ErrorMessage = $"删除失败:{ex.Message}";
}
}
bool CanDelete() => IsExisting;
/// <summary>
/// 由页面在收到导航参数后调用;视图模型本身不实现 IQueryAttributable,
/// 因此这一层完全不依赖 MAUI,可以直接单元测试。
/// </summary>
public void Load(string id)
{
var note = _store.Load(id);
if (note is null)
return;
_note = note;
Text = note.Text;
UpdatedAt = note.UpdatedAt;
IsExisting = true;
}
}
一个类,两种入口
构造函数先建一条空笔记,页面显示“新建笔记”。如果导航带了 id,页面会调用 Load(id),用存储里的笔记替换掉它,同时把 Text、UpdatedAt、IsExisting 填好。Header 与删除按钮都跟着 IsExisting 走。
| 状态 | 界面表现 | 由谁驱动 |
|---|---|---|
| 文本为空 | 保存按钮禁用 | CanSave() + [NotifyCanExecuteChangedFor] |
| 在编辑已有笔记 | 标题“编辑笔记”,删除按钮可见且可用 | IsExisting → Header、CanDelete() |
| 新建笔记 | 标题“新建笔记”,删除按钮隐藏 | 同上 |
| 保存失败 | 红色错误文案出现 | ErrorMessage → HasError |
保存:先落盘,再返回
SaveAsync 做四件事:写模型、异步落盘、回读更新时间、导航返回。ErrorMessage 在开始时清空,失败时填入,界面用 HasError 控制显示。注意这里没有手动禁用按钮——异步命令执行期间本来就是不可用的。
🛑 别把异常留给 UI 线程
异步命令默认会把异常重新抛到调用它的同步上下文。在 MAUI 里,这通常意味着应用直接崩溃。本教程在方法内捕获并转成
ErrorMessage;如果你更希望全局处理,可以改用[RelayCommand(FlowExceptionsToTaskScheduler = true)],但要自己订阅TaskScheduler.UnobservedTaskException,成本更高。
为什么 Load 是公开方法,而不是实现 IQueryAttributable
IQueryAttributable 来自 Microsoft.Maui.Controls。让 ViewModel 实现它,这个类就再也无法脱离 MAUI 编译与测试。把它放在页面里,ViewModel 只暴露一个 Load(string id),职责更干净,测试也能直接调用。
编辑页 XAML
Notes/Views/NotePage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:Notes.ViewModels;assembly=Notes.Core"
x:Class="Notes.Views.NotePage"
x:DataType="vm:NoteViewModel"
Title="{Binding Header}">
<Grid RowDefinitions="Auto,*,Auto,Auto" RowSpacing="10" Padding="16">
<Label Text="{Binding UpdatedAt, StringFormat='最后更新 {0:yyyy-MM-dd HH:mm}'}"
FontSize="12"
TextColor="{AppThemeBinding Light=#6B7280, Dark=#9CA3AF}" />
<Editor Grid.Row="1"
Text="{Binding Text}"
Placeholder="写点什么吧…"
AutoSize="TextChanges"
BackgroundColor="Transparent" />
<Label Grid.Row="2"
Text="{Binding ErrorMessage}"
IsVisible="{Binding HasError}"
FontSize="13"
TextColor="#DC2626" />
<HorizontalStackLayout Grid.Row="3" Spacing="12" HorizontalOptions="End">
<Button Text="删除"
Command="{Binding DeleteCommand}"
IsVisible="{Binding IsExisting}"
BackgroundColor="#DC2626"
TextColor="White" />
<Button Text="保存" Command="{Binding SaveCommand}" />
</HorizontalStackLayout>
</Grid>
</ContentPage>
Title="{Binding Header}":标题随新建/编辑切换,不需要代码。Editor的Text默认两向绑定,AutoSize="TextChanges"让编辑区随内容增长。Button的IsVisible与Command都绑定到IsExisting体系,新建时删除按钮不出现。StringFormat直接写在绑定里做时间格式化,避免为这一处写转换器。
页面代码后置:解析查询参数
Notes/Views/NotePage.xaml.cs
using Notes.ViewModels;
namespace Notes.Views;
public partial class NotePage : ContentPage, IQueryAttributable
{
readonly NoteViewModel _viewModel;
public NotePage(NoteViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
BindingContext = viewModel;
}
// 查询参数只在页面这一层解析,视图模型保持与 MAUI 无关,方便单元测试。
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
if (query.TryGetValue("id", out var value) && value?.ToString() is { Length: > 0 } id)
_viewModel.Load(id);
}
}
Shell 在导航时会检查页面(或页面的 BindingContext)是否实现 IQueryAttributable,然后把查询字符串解析成字典传进来。键名 id 由列表页的 $"{Routes.NotePage}?id={note.Id}" 决定,两边必须一致。
⚠️ id 里不要放需要转义的字符
这里直接拼接文件名(字母、数字、点),没有做 URL 编码。如果你把 id 换成用户输入或含空格、
&、?的字符串,务必先Uri.EscapeDataString,再在页面里解码,否则查询字符串会被截断。
导航与返回
列表页用 GoToAsync("note?id=...") 进入编辑页,编辑页用 GoToAsync("..") 返回上一页。两个字符串都被封装在 ShellNavigationService 里,ViewModel 只看到 GoToAsync(route) 和 GoBackAsync()。
| 动作 | 实际路由 | 结果 |
|---|---|---|
| 新增 | note |
压入编辑页,显示空编辑器 |
| 打开 | note?id=xxx.notes.txt |
压入编辑页,ApplyQueryAttributes 收到 id |
| 保存 / 删除 | .. |
弹出编辑页,列表页 OnAppearing 重新加载 |
页面实例与生命周期
NotePage 与 NoteViewModel 都注册为 transient:每次导航都会创建新实例,编辑状态不会串到下一次。返回时 Shell 弹出并释放页面,列表页是 singleton,状态保留。
📌 如果注册成 singleton 会怎样
第一次打开笔记 A,第二次打开笔记 B,页面还是同一个实例,
Text仍是 A 的内容,直到ApplyQueryAttributes再次覆盖它。更糟的是,某些导航顺序下ApplyQueryAttributes不一定会按你预期触发。编辑类页面默认 transient,省心。
📌 本节要点
- 新建/编辑共用一个 ViewModel,靠
Load(id)切换状态。 - 查询参数由页面解析,ViewModel 保持零 MAUI 依赖。
- 编辑页用 transient,避免状态串页;保存与删除都在方法内处理异常。
08 装配:依赖注入、Shell 与启动
ViewModel 写完了,接下来把它们和界面接起来。.NET MAUI 自带 Microsoft.Extensions.DependencyInjection,不需要引入第三方容器;关键是生命周期选对、页面能被容器解析。
Shell 导航实现:整个方案里唯一认识 Shell 的类
Notes/Services/ShellNavigationService.cs
using Notes.Services;
namespace Notes.Services;
/// <summary>
/// 把 <see cref="INavigationService"/> 落到 Shell 上。
/// 这是整个解决方案里唯一知道 Shell 存在的地方。
/// </summary>
public sealed class ShellNavigationService : INavigationService
{
public Task GoToAsync(string route) => Shell.Current.GoToAsync(route);
public Task GoBackAsync() => Shell.Current.GoToAsync("..");
}
AppShell:把页面塞进 Shell
XAML 里只声明两个空壳 ShellContent,真正的页面实例由构造函数注入后在代码里赋值。这样不依赖 DataTemplate 的解析时机,页面一定能拿到依赖:
Notes/AppShell.xaml
<?xml version="1.0" encoding="UTF-8" ?>
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Notes.AppShell"
Shell.FlyoutBehavior="Disabled"
Title="Notes">
<TabBar>
<!-- Content 在 AppShell 构造函数里由依赖注入填入 -->
<ShellContent x:Name="NotesTab" Title="笔记" Route="notes" />
<ShellContent x:Name="AboutTab" Title="关于" Route="about" />
</TabBar>
</Shell>
Notes/AppShell.xaml.cs
using Notes.Services;
using Notes.Views;
namespace Notes;
public partial class AppShell : Shell
{
public AppShell(NotesPage notesPage, AboutPage aboutPage)
{
InitializeComponent();
NotesTab.Content = notesPage;
AboutTab.Content = aboutPage;
// 编辑页不在 Shell 的可视化树里,需要注册路由才能导航过去。
Routing.RegisterRoute(Routes.NotePage, typeof(NotePage));
}
}
Routing.RegisterRoute 注册的页面不在 Shell 的可视化树里,但 Shell 导航时会向 DI 容器请求页面实例,所以 NotePage 的构造函数注入同样生效。
App:在资源初始化之后再解析 Shell
.NET 10 模板使用 CreateWindow 而不是已弃用的 MainPage。这里注入 IServiceProvider,延迟到 CreateWindow 时才解析 AppShell——此时 App.xaml 的资源字典已经加载完毕,页面里的 StaticResource 不会找不到。
Notes/App.xaml.cs
using Microsoft.Extensions.DependencyInjection;
namespace Notes;
public partial class App : Application
{
readonly IServiceProvider _services;
public App(IServiceProvider services)
{
InitializeComponent();
_services = services;
}
// 在 App 的资源初始化之后才解析 AppShell,避免页面里的 StaticResource 找不到。
protected override Window CreateWindow(IActivationState? activationState) =>
new(_services.GetRequiredService<AppShell>());
}
MauiProgram:注册所有类型
Notes/MauiProgram.cs
using Microsoft.Extensions.Logging;
using Microsoft.Maui.Storage;
using Notes.Services;
using Notes.ViewModels;
using Notes.Views;
namespace Notes;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.ConfigureFonts(fonts =>
{
fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
});
// ---- 服务 ----
builder.Services.AddSingleton<INoteStore>(_ => new FileNoteStore(FileSystem.AppDataDirectory));
builder.Services.AddSingleton<INavigationService, ShellNavigationService>();
// ---- ViewModel ----
builder.Services.AddSingleton<NotesViewModel>();
builder.Services.AddSingleton<AboutViewModel>();
builder.Services.AddTransient<NoteViewModel>(); // 每次进入编辑页都要新实例
// ---- 页面 ----
builder.Services.AddSingleton<NotesPage>();
builder.Services.AddSingleton<AboutPage>();
builder.Services.AddTransient<NotePage>();
builder.Services.AddSingleton<AppShell>();
#if DEBUG
builder.Logging.AddDebug();
#endif
return builder.Build();
}
}
生命周期怎么选
| 类型 | 注册 | 理由 |
|---|---|---|
INoteStore |
Singleton | 无状态,共享应用数据目录 |
INavigationService |
Singleton | 无状态,包一层 Shell 静态 API |
NotesViewModel |
Singleton | 列表页唯一实例,保留搜索关键字 |
NotesPage / AboutPage |
Singleton | 对应 Tab 只创建一次,与 ShellContent 的缓存行为一致 |
NoteViewModel / NotePage |
Transient | 每次进编辑页都要干净状态 |
AppShell |
Singleton | 应用只有一个 Shell |
⚠️ 别把编辑页注册成 Singleton
Shell每次导航都会创建新的路由页面实例,但如果容器返回的是同一个NoteViewModel,第二次进入时Text还停在上一条笔记。页面 transient、ViewModel transient,二者要一起改。
关于页:不需要基类的命令
这个页面没有会变化的属性,所以 ViewModel 连 ObservableObject 都不用继承,只用一个 [RelayCommand] 打开文档。它说明了一件事:两个源生成器是独立的,你不需要为了用命令而引入属性通知。
Notes/ViewModels/AboutViewModel.cs
using CommunityToolkit.Mvvm.Input;
using Microsoft.Maui.ApplicationModel;
namespace Notes.ViewModels;
/// <summary>
/// 关于页的视图模型。这里只演示 [RelayCommand]:
/// 命令不需要基类,只要类声明为 partial。
/// </summary>
public partial class AboutViewModel
{
public string AppName => AppInfo.Name;
public string Version => $"版本 {AppInfo.VersionString}";
public string Description => "示例应用:.NET MAUI 10 + MVVM + CommunityToolkit.Mvvm 源生成器。";
public string MoreInfoUrl => "https://learn.microsoft.com/dotnet/maui";
[RelayCommand]
async Task OpenMoreInfoAsync() => await Launcher.Default.OpenAsync(new Uri(MoreInfoUrl));
}
Notes/Views/AboutPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:vm="clr-namespace:Notes.ViewModels"
x:Class="Notes.Views.AboutPage"
x:DataType="vm:AboutViewModel"
Title="关于">
<ScrollView>
<VerticalStackLayout Padding="24,32" Spacing="14" MaximumWidthRequest="480">
<Image Source="dotnet_bot.png"
HeightRequest="140"
Aspect="AspectFit"
SemanticProperties.Description=".NET 机器人" />
<Label Text="{Binding AppName}"
FontSize="26"
FontAttributes="Bold"
HorizontalOptions="Center" />
<Label Text="{Binding Version}"
HorizontalOptions="Center"
TextColor="{AppThemeBinding Light=#6B7280, Dark=#9CA3AF}" />
<Label Text="{Binding Description}"
HorizontalTextAlignment="Center"
Margin="0,8" />
<Button Text="打开 .NET MAUI 文档"
Command="{Binding OpenMoreInfoCommand}"
Margin="0,8" />
</VerticalStackLayout>
</ScrollView>
</ContentPage>
Notes/Views/AboutPage.xaml.cs
using Notes.ViewModels;
namespace Notes.Views;
public partial class AboutPage : ContentPage
{
public AboutPage(AboutViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}
AboutPage.xaml 的 xmlns:vm 没有 assembly=,因为这个 ViewModel 就在 Notes 项目里;而 NotesPage、NotePage 的 ViewModel 在 Notes.Core,必须写 assembly=Notes.Core。
启动流程
-
MauiProgram.CreateMauiApp():注册服务、ViewModel、页面,构建MauiApp。 -
容器解析
App:InitializeComponent()加载应用级资源与样式。 -
CreateWindow:此时解析AppShell,构造函数注入两个 Tab 页面,注册编辑页路由。 -
Shell 显示列表页:
NotesPage的OnAppearing触发LoadCommand,读取磁盘并填充列表。
📌 本节要点
- Shell 的根页面用构造函数注入最稳;路由页面由 Shell + DI 自动解析。
- 编辑页与它的 ViewModel 都用 transient,其余用 singleton。
App延迟解析AppShell,避开StaticResource初始化顺序问题。- 命令不要求
ObservableObject基类,纯页面照样能用。
09 单元测试:没有界面也能验证行为
这是拆分项目真正兑现价值的地方:保存有没有写盘、按钮什么时候可用、保存后有没有返回——全部可以在 dotnet test 里断言,不需要模拟器。
两个假实现
INoteStore 换成字典,INavigationService 换成记录器。两个类都不到 25 行,却足以覆盖 ViewModel 的所有分支:
Notes.Tests/FakeNoteStore.cs
using Notes.Models;
using Notes.Services;
namespace Notes.Tests;
/// <summary>内存版笔记存储,让视图模型测试完全不碰磁盘。</summary>
internal sealed class FakeNoteStore : INoteStore
{
readonly Dictionary<string, Note> _notes = [];
public IReadOnlyList<Note> LoadAll() =>
_notes.Values.OrderByDescending(note => note.UpdatedAt).ToList();
public Note? Load(string id) => _notes.GetValueOrDefault(id);
public void Save(Note note) => _notes[note.Id] = note;
public void Delete(string id) => _notes.Remove(id);
public bool Contains(string id) => _notes.ContainsKey(id);
public Note Seed(string text, DateTime updatedAt)
{
var note = new Note { Text = text, UpdatedAt = updatedAt };
_notes[note.Id] = note;
return note;
}
}
Notes.Tests/FakeNavigationService.cs
using Notes.Services;
namespace Notes.Tests;
/// <summary>记录导航请求的假实现,代替真实的 Shell。</summary>
internal sealed class FakeNavigationService : INavigationService
{
public List<string> Routes { get; } = [];
public int GoBackCount { get; private set; }
public Task GoToAsync(string route)
{
Routes.Add(route);
return Task.CompletedTask;
}
public Task GoBackAsync()
{
GoBackCount++;
return Task.CompletedTask;
}
}
测 ViewModel
六个测试覆盖了编辑页的核心行为。注意异步命令的写法:SaveCommand 的类型是 IAsyncRelayCommand,用扩展方法 ExecuteAsync 才能真正等待执行完成。
Notes.Tests/NoteViewModelTests.cs
using Notes.Models;
using Notes.ViewModels;
namespace Notes.Tests;
public sealed class NoteViewModelTests
{
[Fact]
public void SaveCommand_IsDisabled_UntilTextIsNotBlank()
{
var viewModel = new NoteViewModel(new FakeNoteStore(), new FakeNavigationService());
Assert.False(viewModel.SaveCommand.CanExecute(null));
viewModel.Text = "写点东西";
Assert.True(viewModel.SaveCommand.CanExecute(null));
viewModel.Text = " ";
Assert.False(viewModel.SaveCommand.CanExecute(null));
}
[Fact]
public async Task SaveCommand_PersistsNote_AndNavigatesBack()
{
var store = new FakeNoteStore();
var navigation = new FakeNavigationService();
var viewModel = new NoteViewModel(store, navigation) { Text = "今天学了 MVVM" };
await viewModel.SaveCommand.ExecuteAsync(null);
var saved = Assert.Single(store.LoadAll());
Assert.Equal("今天学了 MVVM", saved.Text);
Assert.Equal(1, navigation.GoBackCount);
}
[Fact]
public void Load_ExistingNote_PopulatesProperties_AndEnablesDelete()
{
var store = new FakeNoteStore();
var note = store.Seed("已有内容", DateTime.Now.AddDays(-1));
var viewModel = new NoteViewModel(store, new FakeNavigationService());
Assert.False(viewModel.IsExisting);
Assert.False(viewModel.DeleteCommand.CanExecute(null));
Assert.Equal("新建笔记", viewModel.Header);
viewModel.Load(note.Id);
Assert.Equal("已有内容", viewModel.Text);
Assert.Equal(note.UpdatedAt, viewModel.UpdatedAt);
Assert.True(viewModel.IsExisting);
Assert.True(viewModel.DeleteCommand.CanExecute(null));
Assert.Equal("编辑笔记", viewModel.Header);
}
[Fact]
public async Task DeleteCommand_RemovesNote_AndNavigatesBack()
{
var store = new FakeNoteStore();
var note = store.Seed("待删除", DateTime.Now);
var navigation = new FakeNavigationService();
var viewModel = new NoteViewModel(store, navigation);
viewModel.Load(note.Id);
await viewModel.DeleteCommand.ExecuteAsync(null);
Assert.False(store.Contains(note.Id));
Assert.Equal(1, navigation.GoBackCount);
}
[Fact]
public void SettingText_RaisesCanExecuteChanged_ForSaveCommand()
{
var viewModel = new NoteViewModel(new FakeNoteStore(), new FakeNavigationService());
var raised = 0;
viewModel.SaveCommand.CanExecuteChanged += (_, _) => raised++;
viewModel.Text = "abc";
Assert.True(raised > 0);
}
[Fact]
public void Load_MissingNote_KeepsEditingStateUnchanged()
{
var viewModel = new NoteViewModel(new FakeNoteStore(), new FakeNavigationService());
viewModel.Load("missing.notes.txt");
Assert.False(viewModel.IsExisting);
Assert.Equal("新建笔记", viewModel.Header);
}
}
测列表与搜索
Notes.Tests/NotesViewModelTests.cs
using Notes.Models;
using Notes.ViewModels;
namespace Notes.Tests;
public sealed class NotesViewModelTests
{
static NotesViewModel CreateSut(FakeNoteStore store, FakeNavigationService navigation) =>
new(store, navigation);
[Fact]
public async Task LoadCommand_PopulatesNotes_SortedByUpdatedAtDescending()
{
var store = new FakeNoteStore();
store.Seed("旧笔记", DateTime.Now.AddDays(-2));
store.Seed("新笔记", DateTime.Now);
var viewModel = CreateSut(store, new FakeNavigationService());
await viewModel.LoadCommand.ExecuteAsync(null);
Assert.Equal(2, viewModel.Notes.Count);
Assert.Equal("新笔记", viewModel.Notes[0].Text);
Assert.False(viewModel.IsRefreshing);
}
[Fact]
public async Task SearchText_FiltersNotes_CaseInsensitively()
{
var store = new FakeNoteStore();
store.Seed("学习 MAUI 的 MVVM", DateTime.Now);
store.Seed("买菜", DateTime.Now.AddMinutes(-1));
var viewModel = CreateSut(store, new FakeNavigationService());
await viewModel.LoadCommand.ExecuteAsync(null);
viewModel.SearchText = "maui";
var match = Assert.Single(viewModel.Notes);
Assert.Contains("MAUI", match.Text);
viewModel.SearchText = string.Empty;
Assert.Equal(2, viewModel.Notes.Count);
}
[Fact]
public void SearchText_WithNoLoadedNotes_DoesNotThrow()
{
var viewModel = CreateSut(new FakeNoteStore(), new FakeNavigationService());
viewModel.SearchText = "任意关键字";
Assert.Empty(viewModel.Notes);
}
[Fact]
public async Task SelectingNote_NavigatesToEditorWithId()
{
var store = new FakeNoteStore();
var note = store.Seed("要打开的笔记", DateTime.Now);
var navigation = new FakeNavigationService();
var viewModel = CreateSut(store, navigation);
await viewModel.LoadCommand.ExecuteAsync(null);
viewModel.SelectedNote = viewModel.Notes[0];
Assert.Equal($"note?id={note.Id}", Assert.Single(navigation.Routes));
}
[Fact]
public async Task NewNoteCommand_NavigatesToEmptyEditor()
{
var navigation = new FakeNavigationService();
var viewModel = CreateSut(new FakeNoteStore(), navigation);
await viewModel.NewNoteCommand.ExecuteAsync(null);
Assert.Equal("note", Assert.Single(navigation.Routes));
}
}
也测真实文件存储
FileNoteStore 不依赖 MAUI(根目录是参数),所以可以用临时目录直接测真实文件读写。排序测试里用 File.SetLastWriteTime 伪造时间,避免 Thread.Sleep 带来的不稳定:
Notes.Tests/FileNoteStoreTests.cs
using Notes.Models;
using Notes.Services;
namespace Notes.Tests;
public sealed class FileNoteStoreTests : IDisposable
{
readonly string _root = Path.Combine(Path.GetTempPath(), $"notes-tests-{Guid.NewGuid():N}");
[Fact]
public void Save_Then_Load_RoundTripsText()
{
var store = new FileNoteStore(_root);
var note = new Note { Text = "第一条笔记" };
store.Save(note);
var loaded = store.Load(note.Id);
Assert.NotNull(loaded);
Assert.Equal("第一条笔记", loaded.Text);
Assert.Equal(note.Id, loaded.Id);
Assert.True(loaded.UpdatedAt > DateTime.Now.AddMinutes(-1));
}
[Fact]
public void Load_MissingId_ReturnsNull()
{
var store = new FileNoteStore(_root);
Assert.Null(store.Load("does-not-exist.notes.txt"));
}
[Fact]
public void Delete_RemovesNoteFromDisk()
{
var store = new FileNoteStore(_root);
var note = new Note { Text = "待删除" };
store.Save(note);
store.Delete(note.Id);
Assert.Null(store.Load(note.Id));
Assert.Empty(store.LoadAll());
}
[Fact]
public void LoadAll_OrdersByLastWriteTimeDescending()
{
var store = new FileNoteStore(_root);
var older = new Note { Text = "旧笔记" };
var newer = new Note { Text = "新笔记" };
store.Save(older);
store.Save(newer);
File.SetLastWriteTime(Path.Combine(_root, older.Id), DateTime.Now.AddHours(-2));
var all = store.LoadAll();
Assert.Equal(new[] { "新笔记", "旧笔记" }, all.Select(note => note.Text));
}
[Fact]
public void Load_IgnoresPathTraversalInId()
{
var store = new FileNoteStore(_root);
var note = new Note { Text = "内容" };
store.Save(note);
var loaded = store.Load(Path.Combine("..", note.Id));
Assert.NotNull(loaded);
Assert.Equal("内容", loaded.Text);
}
public void Dispose()
{
if (Directory.Exists(_root))
Directory.Delete(_root, recursive: true);
}
}
跑起来
终端
dotnet test Notes.Tests/Notes.Tests.csproj
输出
已通过! - 失败: 0,通过: 17,已跳过: 0,总计: 17,持续时间: 42 ms - Notes.Tests.dll (net10.0)
这些测试到底在保护什么
| 测试 | 保护的规则 |
|---|---|
SaveCommand_IsDisabled_UntilTextIsNotBlank |
空文本不允许保存,这是产品规则,不是界面细节 |
SaveCommand_PersistsNote_AndNavigatesBack |
保存必须写存储,并且返回上一页 |
Load_ExistingNote_PopulatesProperties_AndEnablesDelete |
路由参数进入后,状态、标题、删除可用性全对 |
DeleteCommand_RemovesNote_AndNavigatesBack |
删除必须真的删掉,并返回 |
SettingText_RaisesCanExecuteChanged_ForSaveCommand |
[NotifyCanExecuteChangedFor] 真的在发通知 |
SearchText_FiltersNotes_CaseInsensitively |
搜索不区分大小写,清空后恢复全量 |
SelectingNote_NavigatesToEditorWithId |
路由拼接格式正确(note?id=...) |
FileNoteStoreTests |
真实文件读写、删除、倒序、路径穿越防护 |
📌 测试没覆盖什么
XAML 绑定是否拼错、
Shell真实导航、文件系统权限、平台差异,这些它都测不到。单元测试的定位是“业务规则与状态流转”,界面问题交给真机/模拟器与 UI 测试。不要因为有了 17 个绿点,就跳过运行一次应用。
📌 本节要点
- ViewModel 不依赖 MAUI,测试才能用纯 .NET 跑,毫秒级反馈。
- 接口抽象(
INoteStore、INavigationService)是测试友好的前提。 - 异步命令用
ExecuteAsync等待;可用性用CanExecute断言;通知用事件断言。
10 避坑与诊断清单
源生成器、编译绑定、Shell 导航、依赖注入,这四样东西出错时的报错都不太直观。这一节把最常见的现象、原因和处理方式列成表,方便你按图索骥。
源生成器
| 现象 | 原因 | 处理 |
|---|---|---|
CS9248:分布属性必须具有实现部分 |
生成器跳过了这个属性:类没写 partial、属性没写 partial,或类型没有属性通知基础设施 |
三件套补齐:partial class、public partial string Text { get; set; }、继承 ObservableObject |
MVVMTK0019 |
字段写法的类型既不继承 ObservableObject,也没用相关特性 |
改为继承 ObservableObject(或 ObservableValidator) |
MVVMTK0033 警告 |
用了 [ObservableObject] 特性,但类型没有基类 |
删掉特性,改成 : ObservableObject;继承比特性更清晰 |
| 属性名或命令名和预期不一致 | 字段名不符合 lowerCamel / _lowerCamel / m_lowerCamel;方法名里的 Async 没被正确去掉 |
改用 partial 属性写法,名字完全由你决定;命令名按“去 Async + Command”核对 |
CS0102:类型已包含某成员的定义 |
类里已经手写了同名属性,生成器又生成一份 | 二选一:删掉手写属性,或去掉 [ObservableProperty] |
| “类型重复定义”的奇怪错误 | EmitCompilerGeneratedFiles 的输出目录被项目当成源码再次编译 |
把生成目录加入 .gitignore,并确保它不在 Compile 项里 |
field 被当成普通标识符 |
项目是 C# 13 或更低,field 还是预览特性 |
升级到 .NET 10 / C# 14,或加 <LangVersion>preview</LangVersion> |
命令与可用性
| 现象 | 原因 | 处理 |
|---|---|---|
| 按钮一直是灰的 | CanExecute 只评估一次,之后没人通知它重新评估 |
在相关属性上加 [NotifyCanExecuteChangedFor(nameof(SaveCommand))],或在代码里调用 NotifyCanExecuteChanged() |
| 按钮一直可点,重复提交 | 用了同步方法,或设置了 AllowConcurrentExecutions = true |
改成 async Task 方法;保持默认的禁止并发 |
| 保存失败直接闪退 | 异步命令默认把异常抛回同步上下文 | 方法内 try/catch 转成错误状态,或设置 FlowExceptionsToTaskScheduler 并全局处理 |
| 带参数命令执行时报空引用/类型转换错误 | CommandParameter 是 null,而泛型参数是值类型 |
CanExecute 里挡掉 null,或把参数声明为可空 |
XAML 与绑定
| 现象 | 原因 | 处理 |
|---|---|---|
| 运行时绑定静默失败,界面空白 | 漏写 x:DataType,或 DataTemplate 里没写自己的 x:DataType |
页面级与模板级都标上;编译绑定能在构建期提示拼写错误 |
| 提示找不到 ViewModel 类型 | 类型在另一个程序集,xmlns 少了 assembly= |
clr-namespace:Notes.ViewModels;assembly=Notes.Core |
带 Source / RelativeSource 的绑定没有编译检查 |
项目未开启 Source 绑定编译 | 加 <MauiEnableXamlCBindingWithSourceCompilation>true</MauiEnableXamlCBindingWithSourceCompilation>,并在绑定上标注 x:DataType |
Command 绑定不触发 |
命令属性名拼错(生成的是 SaveCommand,不是 SaveAsyncCommand) |
以生成器命名为准;用 IDE 的自动补全而不是手打 |
Shell 与依赖注入
| 现象 | 原因 | 处理 |
|---|---|---|
| 导航报“找不到路由” | 没有 Routing.RegisterRoute,或注册名与 GoToAsync 不一致 |
路由常量集中管理;注册与跳转都用同一个常量 |
页面构造函数注入的依赖是 null |
页面没注册到 DI,Shell 退回 Activator.CreateInstance |
在 MauiProgram 注册页面;路由页面用 transient |
CreateWindow 里抛 StaticResource not found |
页面在 App.xaml 资源初始化之前被解析 |
注入 IServiceProvider,在 CreateWindow 里再解析 Shell/页面 |
| 第二次进入编辑页显示上一条内容 | ViewModel 或页面注册成了 singleton | 编辑页与它的 ViewModel 都改为 transient |
| 查询参数一直是空 | 页面与 BindingContext 都没实现 IQueryAttributable,或键名拼错 |
由页面实现接口;发送与接收使用同一个键名 |
运行时与线程
| 现象 | 原因 | 处理 |
|---|---|---|
偶发 InvalidOperationException(集合被修改) |
在后台线程操作 ObservableCollection |
只在 await 回到 UI 线程后增删;或用 MainThread.InvokeOnMainThreadAsync |
| 界面卡顿 | 文件 IO、序列化、网络请求跑在 UI 线程 | await Task.Run(...) 或改用异步 API |
| 真机上找不到文件 | 硬编码了桌面路径 | 统一用 FileSystem.AppDataDirectory,由 App 项目注入给 Core |
五分钟排查流程
-
先编译:
dotnet build看完整错误列表。源生成器的问题几乎都会在编译期暴露,不要靠运行猜。 -
看生成代码:打开
generated/或 IDE 的分析器节点,确认属性/命令真的生成了,名字对不对。 -
查绑定上下文:在页面构造末尾打断点,确认
BindingContext不是null,类型对不对。 -
查 DI 注册:导航失败、构造参数为空,先看
MauiProgram里有没有注册,生命周期对不对。 -
再跑一次测试:如果 ViewModel 逻辑有测试覆盖,
dotnet test往往比启动模拟器更快定位问题。
💡 关于
MessagingCenter从 .NET 10 起
MessagingCenter已改为内部使用,官方建议迁移到WeakReferenceMessenger——它就在 CommunityToolkit.Mvvm 里。需要跨 ViewModel 广播“笔记已保存”时,它是比查询字符串更松耦合的选择。
📌 本节要点
- 源生成器问题优先看编译错误与生成文件,不要从运行时现象倒推。
- “按钮不亮”九成是
CanExecute没有被通知,而不是绑定写错。 - Shell 导航问题先查路由注册,再查 DI 注册,最后查查询参数键名。
11 源码清单与延伸阅读
全部代码都在配套仓库 maui-mvvm-notes/ 里,可以直接 dotnet test。这一节给出文件索引、速查表,以及继续深入的方向。
文件索引
| 文件 | 作用 | 出现在 |
|---|---|---|
Notes.Core/Models/Note.cs |
领域模型 | 05 |
Notes.Core/Models/NoteSummary.cs |
列表投影 | 05 |
Notes.Core/Services/INoteStore.cs |
存储抽象 | 05 |
Notes.Core/Services/FileNoteStore.cs |
文件实现 | 05 |
Notes.Core/Services/INavigationService.cs |
导航抽象 | 06 |
Notes.Core/Services/Routes.cs |
路由常量 | 06 |
Notes.Core/ViewModels/NotesViewModel.cs |
列表页逻辑 | 06 |
Notes.Core/ViewModels/NoteViewModel.cs |
编辑页逻辑 | 07 |
Notes/Views/NotesPage.xaml(.cs) |
列表界面 | 06 |
Notes/Views/NotePage.xaml(.cs) |
编辑界面 + 查询参数 | 07 |
Notes/Views/AboutPage.xaml(.cs) |
关于界面 | 08 |
Notes/ViewModels/AboutViewModel.cs |
关于页命令 | 08 |
Notes/Services/ShellNavigationService.cs |
Shell 导航实现 | 08 |
Notes/AppShell.xaml(.cs) |
Shell 结构 + 路由注册 | 08 |
Notes/App.xaml(.cs) |
窗口与资源初始化 | 08 |
Notes/MauiProgram.cs |
DI 注册 | 08 |
Notes.Tests/*.cs |
假实现与 17 个测试 | 09 |
本地运行
终端
# 单元测试(无需 MAUI 工作负载)
dotnet test maui-mvvm-notes/Notes.Tests/Notes.Tests.csproj
# 运行 Windows 目标(需要已安装 maui 工作负载)
dotnet build maui-mvvm-notes/Notes/Notes.csproj -t:Run -f net10.0-windows10.0.19041.0
Notes 项目里的 Resources/(图标、启动图、字体、dotnet_bot.png)与 Resources/Styles/*.xaml 来自 dotnet new maui 模板,没有随源码提供二进制文件;用模板新建一个项目,把这些文件复制过去即可。
速查表
| 你写的 | 生成的公开成员 | 类型 |
|---|---|---|
[ObservableProperty] public partial string Text { get; set; } |
Text |
string |
[ObservableProperty] private bool _isExisting; |
IsExisting |
bool |
[RelayCommand] void Save() |
SaveCommand |
IRelayCommand |
[RelayCommand] Task SaveAsync() |
SaveCommand |
IAsyncRelayCommand |
[RelayCommand] void Open(Note note) |
OpenCommand |
IRelayCommand<Note> |
[RelayCommand(CanExecute = nameof(CanSave))] |
SaveCommand.CanExecute 接到 CanSave() |
— |
[RelayCommand(IncludeCancelCommand = true)] |
WorkCommand + WorkCancelCommand |
IAsyncRelayCommand + ICommand |
[NotifyPropertyChangedFor(nameof(Header))] |
属性变化时额外通知 Header |
— |
[NotifyCanExecuteChangedFor(nameof(SaveCommand))] |
属性变化时刷新命令可用性 | — |
[NotifyDataErrorInfo] + DataAnnotations |
赋值时校验,触发 ErrorsChanged |
INotifyDataErrorInfo |
还能往哪走
- 删除确认:加一个
IDialogService,封装DisplayAlertAsync,让“确认删除”也能被测试。 - 搜索防抖:在
OnSearchTextChanged里用CancellationTokenSource延迟过滤,避免大列表每次按键都重建集合。 - 换存储:把
FileNoteStore换成 SQLite 实现,INoteStore不动,ViewModel 与测试也不动。 - 跨页面消息:用
WeakReferenceMessenger替代“返回后重载”,适合多页面共享数据的场景。 - UI 测试:用 Appium 或 MAUI 官方测试方案补齐界面层,覆盖单元测试够不到的部分。
延伸阅读
| 主题 | 链接 |
|---|---|
| 官方 Notes MVVM 教程(本教程的起点) | learn.microsoft.com/…/notes-mvvm |
| 数据绑定与 MVVM | learn.microsoft.com/…/xaml/fundamentals/mvvm |
| CommunityToolkit.Mvvm 文档 | learn.microsoft.com/…/communitytoolkit/mvvm |
[ObservableProperty] 详解 |
…/generators/observableproperty |
[RelayCommand] 详解 |
…/generators/relaycommand |
| Shell 导航与查询参数 | learn.microsoft.com/…/shell/navigation |
| .NET MAUI 依赖注入 | learn.microsoft.com/…/dependency-injection |
| 企业级 MVVM 模式 | learn.microsoft.com/…/architecture/maui/mvvm |
| .NET 10 MAUI 新增功能 | learn.microsoft.com/…/whats-new/dotnet-10 |
| 工具包源码与示例 | github.com/CommunityToolkit/dotnet |
| 官方 MAUI 示例(含 Notes) | github.com/dotnet/maui-samples |
最后一句
MVVM 的难处从来不是“要不要用”,而是边界画在哪:哪些状态属于视图,哪些属于 ViewModel,哪些该被抽成服务。源生成器只是把重复劳动拿掉,让你有精力去想这些边界。
如果这份教程只能留下一句话,那就是:让 ViewModel 不需要界面也能被完整地跑一遍。 做到这一点,测试、重构和换 UI 都会变得轻松。
📌 本节要点
- 源码在
maui-mvvm-notes/,测试可直接跑,应用需 MAUI 工作负载与模板资源。 - 速查表可以贴在显示器边上:属性一行、命令一行、联动两行。
- 下一步优先做“删除确认”和“搜索防抖”,它们是真实应用里最先遇到的体验问题。
示例工程:Notes(MAUI)、Notes.Core(纯 .NET ViewModel)、Notes.Tests(17 个 xUnit 测试)三个项目。
本文为原创教程,转载请注明作者与原文链接;文中代码可自由用于学习与商业项目。

浙公网安备 33010602011771号