说实话,在.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);
}
这样用户改TodoTitle或Description的时候,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);
}
生成器会同时创建SyncDataCommand和SyncDataCancelCommand,分别绑到"同步"和"取消"按钮上就完事了。
依赖注入: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%以上,而且生成器自动提供OnPropertyChanging和OnPropertyChanged钩子,比手写不容易出错。说实在的,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()发消息验证接收逻辑。