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

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

MAUI 10 MVVM架构完整指南2026:DI+Shell导航

说实话,在.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()发消息验证接收逻辑。

关于作者 Devika Ramaswamy

Devika spent four years on the Xamarin team at Microsoft before the transition to .NET MAUI, where she worked on the iOS handler layer and shipped fixes that landed in the .NET 7 and .NET 8 release notes. She left Redmond in 2023 to run mobile engineering at a Series B logistics startup, porting their 600k-line Xamarin.Forms codebase to MAUI over eleven months. She writes mostly about the unglamorous parts of cross-platform work: handler internals, AOT trimming on iOS, MSBuild target customization, and why your hot reload keeps breaking. She holds the .NET MAUI MVP award (2024, 2025) and has spoken at .NET Conf and Xamarin Expert Day. Based in Bengaluru, she still pushes the occasional PR to the dotnet/maui repo on weekends.