.NET MAUI 10 MVVM架构完全指南:CommunityToolkit.Mvvm、依赖注入与Shell导航实战

从零搭建.NET MAUI 10标准MVVM架构:CommunityToolkit.Mvvm 8.4源代码生成器、内置依赖注入配置、Shell导航参数传递和WeakReferenceMessenger组件通信,附完整待办事项应用代码。

.NET MAUI 10 MVVM架构完全指南 2026

说实话,在.NET MAUI 10中搭建一个靠谱的跨平台应用架构,选型这一步就能决定后面几个月的开发体验。MVVM模式搭配CommunityToolkit.Mvvm源代码生成器、内置依赖注入和Shell导航,基本上已经是2026年MAUI项目的标配了。这篇指南会从头带你把这套架构体系走通一遍,代码都是可以直接拿去用的。

为什么.NET MAUI 10必须用MVVM

MVVM把界面(View)、业务逻辑(ViewModel)和数据模型(Model)彻底拆开。在MAUI 10里,这个模式的优势特别明显:

  • 可测试性:ViewModel完全不依赖UI框架,xUnit直接就能写单元测试
  • 跨平台复用:一套ViewModel在Android、iOS、macOS、Windows上通用,不用改一行代码
  • 团队协作:设计师改XAML,开发者写逻辑,各干各的互不影响
  • 维护性:需求变了只改对应的那一层,影响范围很可控

这里有个关键变化必须提一下——.NET MAUI 10把MessagingCenter标记为internal了,也就是说你没法直接用了。官方的意思很明确:赶紧迁移到CommunityToolkit.Mvvm的WeakReferenceMessenger。所以现在CommunityToolkit.Mvvm不是可选项,它就是.NET MAUI 10的事实标准。

项目搭建与安装CommunityToolkit.Mvvm 8.4

先创建项目,然后装包。截至2026年4月,CommunityToolkit.Mvvm最新稳定版是8.4.2:

dotnet new maui -n MyMauiApp
cd MyMauiApp
dotnet add package CommunityToolkit.Mvvm --version 8.4.2

项目结构我个人比较推荐这样组织(当然你也可以根据团队习惯调整):

MyMauiApp/
├── Models/
│   └── TodoItem.cs
├── ViewModels/
│   ├── BaseViewModel.cs
│   ├── TodoListViewModel.cs
│   └── TodoDetailViewModel.cs
├── Views/
│   ├── TodoListPage.xaml
│   └── TodoDetailPage.xaml
├── Services/
│   ├── ITodoService.cs
│   └── TodoService.cs
├── Messages/
│   └── TodoUpdatedMessage.cs
├── App.xaml
├── AppShell.xaml
└── MauiProgram.cs

ObservableProperty源代码生成器:跟样板代码说再见

写过传统MVVM的人都知道那种痛苦——每个属性都要写一堆OnPropertyChanged通知代码,重复到怀疑人生。CommunityToolkit.Mvvm的[ObservableProperty]特性用C#源代码生成器把这事儿全自动化了。

基础用法

先来个BaseViewModel作为基类:

using CommunityToolkit.Mvvm.ComponentModel;

namespace MyMauiApp.ViewModels;

public partial class BaseViewModel : ObservableObject
{
    [ObservableProperty]
    private bool _isBusy;

    [ObservableProperty]
    private string _title = string.Empty;

    public bool IsNotBusy => !IsBusy;
}

注意:类必须声明为partial,因为源代码生成器要在编译时帮你生成配对的分部类。字段命名用下划线前缀(比如_isBusy),生成器会自动创建对应的公开属性IsBusy,包含完整的INotifyPropertyChanged通知逻辑。

属性联动与命令状态通知

实际开发中经常会遇到这种情况:一个属性变了,另外几个属性也得跟着刷新,同时某个命令的可执行状态也要更新。这时候就用[NotifyPropertyChangedFor][NotifyCanExecuteChangedFor]

public partial class TodoDetailViewModel : BaseViewModel
{
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsValid))]
    [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
    private string _todoTitle = string.Empty;

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsValid))]
    [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
    private string _description = string.Empty;

    public bool IsValid =>
        !string.IsNullOrWhiteSpace(TodoTitle) &&
        !string.IsNullOrWhiteSpace(Description);
}

这样用户改TodoTitleDescription的时候,IsValid会自动通知UI更新,SaveCommand的可执行状态也同步刷新。不用自己写一行通知代码,省心。

属性变更回调

源代码生成器还会为每个属性生成两个可选的分部方法,你可以在属性变更前后插入自定义逻辑:

public partial class TodoDetailViewModel : BaseViewModel
{
    [ObservableProperty]
    private bool _isCompleted;

    // 属性变更前调用
    partial void OnIsCompletedChanging(bool value)
    {
        System.Diagnostics.Debug.WriteLine($"即将变更为: {value}");
    }

    // 属性变更后调用
    partial void OnIsCompletedChanged(bool value)
    {
        System.Diagnostics.Debug.WriteLine($"已变更为: {value}");
    }
}

这在调试的时候特别有用,上线前删掉就行。

RelayCommand与AsyncRelayCommand:命令绑定实战

[RelayCommand]能自动把方法包装成可绑定的命令属性。同步、异步、带参数、支持取消——基本上你能想到的场景它都覆盖了。

同步命令

using CommunityToolkit.Mvvm.Input;

public partial class TodoListViewModel : BaseViewModel
{
    [ObservableProperty]
    private ObservableCollection<TodoItem> _todos = new();

    [RelayCommand]
    private void RemoveTodo(TodoItem item)
    {
        Todos.Remove(item);
    }
}

生成器会自动创建RemoveTodoCommand属性(就是方法名加个Command后缀)。XAML里这样绑定:

<Button Text="删除"
        Command="{Binding RemoveTodoCommand}"
        CommandParameter="{Binding .}" />

异步命令与并发控制

网络请求这类耗时操作,用异步方法配合[RelayCommand]就对了:

public partial class TodoListViewModel : BaseViewModel
{
    private readonly ITodoService _todoService;

    public TodoListViewModel(ITodoService todoService)
    {
        _todoService = todoService;
    }

    [RelayCommand]
    private async Task LoadTodosAsync(CancellationToken cancellationToken)
    {
        if (IsBusy) return;

        try
        {
            IsBusy = true;
            var items = await _todoService.GetAllAsync(cancellationToken);
            Todos.Clear();
            foreach (var item in items)
            {
                Todos.Add(item);
            }
        }
        finally
        {
            IsBusy = false;
        }
    }
}

有个很贴心的默认行为:AsyncRelayCommand不允许并发执行。任务跑着的时候命令会自动禁用,用户想疯狂点刷新也不会重复发请求。如果确实需要并发(这种场景其实不多),设置AllowConcurrentExecutions = true就行。

带取消功能的命令

有些操作跑起来比较久,给用户一个取消按钮是基本的体验保障:

[RelayCommand(IncludeCancelCommand = true)]
private async Task SyncDataAsync(CancellationToken cancellationToken)
{
    await _todoService.SyncAsync(cancellationToken);
}

生成器会同时创建SyncDataCommandSyncDataCancelCommand,分别绑到"同步"和"取消"按钮上就完事了。

依赖注入:MauiProgram.cs配置详解

.NET MAUI内置的DI容器跟ASP.NET Core是同一套,用过ASP.NET Core的会非常熟悉。所有服务和ViewModel都在MauiProgram.cs里统一注册。

完整配置示例

using Microsoft.Extensions.Logging;
using MyMauiApp.Services;
using MyMauiApp.ViewModels;
using MyMauiApp.Views;

namespace MyMauiApp;

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<ITodoService, TodoService>();
        builder.Services.AddSingleton<HttpClient>(sp =>
        {
            var client = new HttpClient();
            client.BaseAddress = new Uri("https://api.example.com");
            return client;
        });

        // 注册ViewModel
        builder.Services.AddSingleton<TodoListViewModel>();
        builder.Services.AddTransient<TodoDetailViewModel>();

        // 注册页面
        builder.Services.AddSingleton<TodoListPage>();
        builder.Services.AddTransient<TodoDetailPage>();

#if DEBUG
        builder.Logging.AddDebug();
#endif

        return builder.Build();
    }
}

生命周期怎么选

这个问题我见过很多人纠结,其实规则不复杂:

  • AddSingleton:全局一个实例,应用活着它就活着。适合HttpClient、设置服务这些全局共享的东西,还有首页等常驻页面的ViewModel
  • AddTransient:每次要都给新的。详情页ViewModel就该用这个,每次进去都是干净状态
  • AddScoped:在MAUI里基本等于Singleton(因为没有HTTP请求作用域),别用,容易给自己挖坑

简单记:列表页Singleton(数据加载一次够了),详情页Transient(每次要干净的)。实际项目中我一直这么用,没出过问题。

Shell导航:URI路由与参数传递

.NET MAUI Shell提供的是基于URI的导航系统,配合DI能自动帮你解析页面和ViewModel的依赖。说白了就是:你只管声明路由,剩下的框架搞定。

配置AppShell路由

<!-- AppShell.xaml -->
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:views="clr-namespace:MyMauiApp.Views"
       x:Class="MyMauiApp.AppShell">

    <TabBar>
        <ShellContent Title="待办列表"
                      Icon="list_icon.png"
                      ContentTemplate="{DataTemplate views:TodoListPage}" />
    </TabBar>
</Shell>

然后在AppShell.xaml.cs里注册详情页的路由:

public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();
        Routing.RegisterRoute(nameof(TodoDetailPage), typeof(TodoDetailPage));
    }
}

导航与参数传递

ViewModel里通过Shell.Current做导航,参数用IQueryAttributable接口来接收:

// TodoListViewModel - 导航到详情页
[RelayCommand]
private async Task GoToDetailAsync(TodoItem item)
{
    await Shell.Current.GoToAsync(nameof(TodoDetailPage), true,
        new Dictionary<string, object>
        {
            { "TodoItem", item }
        });
}

// TodoDetailViewModel - 接收参数
public partial class TodoDetailViewModel : BaseViewModel, IQueryAttributable
{
    [ObservableProperty]
    private TodoItem _currentItem;

    public void ApplyQueryAttributes(IDictionary<string, object> query)
    {
        if (query.TryGetValue("TodoItem", out var item) && item is TodoItem todo)
        {
            CurrentItem = todo;
            TodoTitle = todo.Title;
            Description = todo.Description;
        }
    }
}

页面构造函数注入

这里很简洁,Shell导航的时候MAUI会自动通过DI容器解析依赖。页面构造函数里接收ViewModel就行:

public partial class TodoListPage : ContentPage
{
    public TodoListPage(TodoListViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }
}

就这么几行,没有任何手动new的操作。

WeakReferenceMessenger:组件间通信的正确姿势

前面提到了,.NET MAUI 10把MessagingCenter干掉了。替代方案就是CommunityToolkit.Mvvm的WeakReferenceMessenger。坦白说这是好事——新方案性能提升超过100倍,而且用弱引用自动防止内存泄漏,不用你操心。

定义消息类型

using CommunityToolkit.Mvvm.Messaging.Messages;

namespace MyMauiApp.Messages;

// 值变更消息
public class TodoUpdatedMessage : ValueChangedMessage<TodoItem>
{
    public TodoUpdatedMessage(TodoItem value) : base(value) { }
}

// 简单通知消息(没有负载数据)
public class TodoListRefreshMessage { }

发送消息

using CommunityToolkit.Mvvm.Messaging;

// 在TodoDetailViewModel中保存后发送通知
[RelayCommand(CanExecute = nameof(IsValid))]
private async Task SaveAsync()
{
    var updatedItem = new TodoItem
    {
        Title = TodoTitle,
        Description = Description,
        IsCompleted = IsCompleted
    };

    await _todoService.SaveAsync(updatedItem);

    WeakReferenceMessenger.Default.Send(new TodoUpdatedMessage(updatedItem));
    await Shell.Current.GoToAsync("..");
}

接收消息

public partial class TodoListViewModel : BaseViewModel,
    IRecipient<TodoUpdatedMessage>
{
    public TodoListViewModel(ITodoService todoService)
    {
        _todoService = todoService;
        // 注册当前实例为消息接收者
        WeakReferenceMessenger.Default.Register<TodoUpdatedMessage>(this);
    }

    public void Receive(TodoUpdatedMessage message)
    {
        var updatedItem = message.Value;
        var existing = Todos.FirstOrDefault(t => t.Id == updatedItem.Id);
        if (existing != null)
        {
            var index = Todos.IndexOf(existing);
            Todos[index] = updatedItem;
        }
        else
        {
            Todos.Add(updatedItem);
        }
    }
}

IRecipient<T>接口比Lambda回调干净很多,而且写单元测试也方便。另外,因为是弱引用,ViewModel被回收的时候订阅会自动注销,不用手动调Unregister

完整实战:待办事项应用核心架构

好,到了把所有东西串起来的时候了。下面是Model和Service层的完整代码。

数据模型

namespace MyMauiApp.Models;

public class TodoItem
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
    public bool IsCompleted { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

服务接口与实现

namespace MyMauiApp.Services;

public interface ITodoService
{
    Task<List<TodoItem>> GetAllAsync(CancellationToken cancellationToken = default);
    Task SaveAsync(TodoItem item);
    Task DeleteAsync(int id);
    Task SyncAsync(CancellationToken cancellationToken = default);
}

public class TodoService : ITodoService
{
    private readonly HttpClient _httpClient;
    private readonly List<TodoItem> _cache = new();

    public TodoService(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<List<TodoItem>> GetAllAsync(
        CancellationToken cancellationToken = default)
    {
        if (_cache.Count == 0)
        {
            var items = await _httpClient
                .GetFromJsonAsync<List<TodoItem>>(
                    "/api/todos", cancellationToken);
            if (items != null)
                _cache.AddRange(items);
        }
        return _cache.ToList();
    }

    public Task SaveAsync(TodoItem item)
    {
        var existing = _cache.FirstOrDefault(t => t.Id == item.Id);
        if (existing != null)
        {
            var index = _cache.IndexOf(existing);
            _cache[index] = item;
        }
        else
        {
            item.Id = _cache.Count > 0 ? _cache.Max(t => t.Id) + 1 : 1;
            _cache.Add(item);
        }
        return Task.CompletedTask;
    }

    public Task DeleteAsync(int id)
    {
        _cache.RemoveAll(t => t.Id == id);
        return Task.CompletedTask;
    }

    public Task SyncAsync(CancellationToken cancellationToken = default)
    {
        return _httpClient.PostAsJsonAsync("/api/todos/sync", _cache, cancellationToken);
    }
}

XAML视图绑定

最后来看看View层怎么把ViewModel的数据呈现出来。这个TodoListPage用到了RefreshView下拉刷新、CollectionView列表、SwipeView滑动删除——都是MAUI里很常见的组合:

<!-- TodoListPage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:vm="clr-namespace:MyMauiApp.ViewModels"
             x:Class="MyMauiApp.Views.TodoListPage"
             x:DataType="vm:TodoListViewModel"
             Title="{Binding Title}">

    <RefreshView IsRefreshing="{Binding IsBusy}"
                 Command="{Binding LoadTodosCommand}">
        <CollectionView ItemsSource="{Binding Todos}"
                        SelectionMode="None">
            <CollectionView.EmptyView>
                <Label Text="暂无待办事项"
                       HorizontalOptions="Center"
                       VerticalOptions="Center" />
            </CollectionView.EmptyView>
            <CollectionView.ItemTemplate>
                <DataTemplate x:DataType="models:TodoItem">
                    <SwipeView>
                        <SwipeView.RightItems>
                            <SwipeItems>
                                <SwipeItem Text="删除"
                                           BackgroundColor="Red"
                                           Command="{Binding Source={RelativeSource
                                               AncestorType={x:Type vm:TodoListViewModel}},
                                               Path=RemoveTodoCommand}"
                                           CommandParameter="{Binding .}" />
                            </SwipeItems>
                        </SwipeView.RightItems>
                        <Grid Padding="16" ColumnDefinitions="*,Auto">
                            <Grid.GestureRecognizers>
                                <TapGestureRecognizer
                                    Command="{Binding Source={RelativeSource
                                        AncestorType={x:Type vm:TodoListViewModel}},
                                        Path=GoToDetailCommand}"
                                    CommandParameter="{Binding .}" />
                            </Grid.GestureRecognizers>
                            <VerticalStackLayout>
                                <Label Text="{Binding Title}"
                                       FontSize="18"
                                       FontAttributes="Bold" />
                                <Label Text="{Binding Description}"
                                       FontSize="14"
                                       TextColor="Gray" />
                            </VerticalStackLayout>
                            <CheckBox Grid.Column="1"
                                      IsChecked="{Binding IsCompleted}" />
                        </Grid>
                    </SwipeView>
                </DataTemplate>
            </CollectionView.ItemTemplate>
        </CollectionView>
    </RefreshView>
</ContentPage>

注意x:DataType——这个是编译绑定的关键。加了它编译器会检查绑定路径是不是对的,运行时性能也有明显提升。坦白讲,不加的话功能也能跑,但你会丢失编译时检查的保障,后面排查绑定错误会很头疼。

架构最佳实践

最后聊几个我在实际项目中总结的经验:

  • 始终用编译绑定:每个XAML页面都加x:DataType,别偷懒。运行时反射开销不说,光是编译时就能帮你抓到一堆拼写错误
  • ViewModel绝对不引用View:ViewModel里不应该出现任何UI类型,导航用Shell.Current或者注入INavigationService
  • 服务必须接口化:通过接口定义、DI注册,测试时直接Mock替换。这不是洁癖,是真能救命的习惯
  • 构造函数里别搞异步:数据加载放Command里,页面OnAppearing的时候触发。构造函数里await会给你各种奇怪的问题
  • 到处传CancellationToken:所有异步操作都带上,页面退出时能及时取消请求,不浪费资源
  • 消息通信别滥用:WeakReferenceMessenger确实方便,但用多了数据流会变得很难追踪。能用DI共享服务解决的就别用消息

常见问题

MessagingCenter在.NET MAUI 10不能用了怎么办?

MessagingCenter在MAUI 10已经是internal了,官方推荐迁移到WeakReferenceMessenger。API更简洁,性能好100倍以上,还自动防内存泄漏。如果老项目代码量太大一时改不完,可以先用Plugin.Maui.MessagingCenter过渡一下,但尽快完成迁移才是正道。

ObservableProperty和手写INotifyPropertyChanged有什么区别?

最终效果完全一样——[ObservableProperty]是在编译时生成代码,没有运行时性能损失。好处是代码量能砍掉60%以上,而且生成器自动提供OnPropertyChangingOnPropertyChanged钩子,比手写不容易出错。说实在的,2026年了还在手写通知代码有点没必要。

MAUI里AddScoped和AddSingleton有啥区别?

简短回答:在MAUI里基本没区别。AddScoped在ASP.NET Core里是每个HTTP请求一个实例,但MAUI没有请求作用域这回事,所以AddScoped的表现和AddSingleton几乎一样。我的建议是MAUI项目里只用Singleton和Transient,不碰Scoped,免得给自己或者接手的同事造成困惑。

Shell导航怎么传复杂对象?

Dictionary<string, object>传,目标ViewModel实现IQueryAttributable接口来接收。简单类型(int、string之类的)也可以走URI查询字符串加[QueryProperty]特性。复杂对象还是推荐Dictionary方式,类型安全不用序列化。

怎么给CommunityToolkit.Mvvm的ViewModel写单元测试?

这可能是MVVM架构最大的好处之一了。ViewModel不依赖MAUI框架代码,xUnit加NSubstitute(或者Moq)直接就能测。Mock掉ITodoService这些接口,new一个ViewModel出来,调它的命令和属性就行。消息那块也好测——测试里直接用WeakReferenceMessenger.Default.Send()发消息验证接收逻辑。

关于作者 Editorial Team

Our team of expert writers and editors.