.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 分钟


目录

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 还是预览特性)。

三个前提,缺一不可

  1. 类必须是 partial:生成器要往同一个类型里再写一份声明,没有 partial 就没有地方放。

  2. 属性必须是 partial(字段写法不受此限):属性写法:public partial string Text { get; set; }。字段写法:private string _text;,生成器负责生成 Text。

  3. 类型要有属性通知的“基础设施”:继承 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。

启动流程

  1. MauiProgram.CreateMauiApp():注册服务、ViewModel、页面,构建 MauiApp。

  2. 容器解析 App:InitializeComponent() 加载应用级资源与样式。

  3. CreateWindow:此时解析 AppShell,构造函数注入两个 Tab 页面,注册编辑页路由。

  4. 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

五分钟排查流程

  1. 先编译:dotnet build 看完整错误列表。源生成器的问题几乎都会在编译期暴露,不要靠运行猜。

  2. 看生成代码:打开 generated/ 或 IDE 的分析器节点,确认属性/命令真的生成了,名字对不对。

  3. 查绑定上下文:在页面构造末尾打断点,确认 BindingContext 不是 null,类型对不对。

  4. 查 DI 注册:导航失败、构造参数为空,先看 MauiProgram 里有没有注册,生命周期对不对。

  5. 再跑一次测试:如果 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 测试)三个项目。

本文为原创教程,转载请注明作者与原文链接;文中代码可自由用于学习与商业项目。

posted @ 2026-10-28 06:22  孤独的小苗  阅读(15)  评论(0)    收藏  举报