关于 maui-dependency-injection
为 .NET MAUI 开发者提供依赖注入设计与配置指导,涵盖服务注册、生命周期、构造函数注入和 Shell 导航。内容还说明平台条件注册及常见解析问题。
主要功能
- 01在 MauiProgram 中注册服务、页面和 ViewModel
- 02选择 Singleton、Transient 或 Scoped 生命周期
- 03通过构造函数注入依赖
- 04结合 Shell 路由解析页面
- 05按平台注册实现并覆盖目标平台
适用场景
- 01为 MAUI 页面和 ViewModel 配置依赖注入
- 02为共享服务选择生命周期
- 03让 Shell 导航创建含依赖的页面
- 04为不同平台注册服务实现
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dotnet/ skills --skill maui-dependency-injection安装后在 Claude Code 中输入/maui-dependency-injection确认已加载。
附带文件references/dependency-injection-api.md
详细介绍
.NET MAUI 中的依赖注入
.NET MAUI 使用与 ASP.NET Core 相同的 Microsoft.Extensions.DependencyInjection 容器。所有服务注册都在 MauiProgram.CreateMauiApp() 中通过 builder.Services 完成。容器在启动时构建,此后不可更改。
适用场景
- 在
MauiProgram.cs中注册服务、ViewModel 和 Page - 在
AddSingleton、AddTransient和AddScoped之间进行选择 - 为 Page 和 ViewModel 配置构造函数注入
- 利用 Shell 导航自动解析已通过 DI 注册的 Page
- 使用
#if指令注册特定平台的服务实现 - 设计接口,以便对服务层进行测试
不适用场景
- XAML 数据绑定语法或编译绑定——请使用 maui-data-binding 技能
- Shell 路由注册和查询参数——请使用 maui-shell-navigation 技能
- 模拟框架或测试运行器——请使用标准 .NET 测试工具(xUnit、NUnit、MSTest)和模拟库(NSubstitute、Moq)
输入
- 包含
MauiProgram.cs文件的 .NET MAUI 项目 - 了解哪些服务、ViewModel 和 Page 需要注册
- 条件注册所针对的平台(Android、iOS、Mac Catalyst、Windows)
会影响答案的规则
| 情况 | 操作 | 原因 |
|---|---|---|
| 注册 Page 或 ViewModel | 优先使用 AddTransient | 每次导航都创建新实例可避免状态过时;Singleton Page 从视觉树中移除后无法重新添加。对于确实只需单实例的 Page(例如希望保持热状态的根选项卡),使用 Singleton 也说得通 |
| 注册共享或开销较大的状态 | 使用 AddSingleton | 整个应用共用一个实例(设置、数据库连接、HttpClient 处理程序) |
考虑使用 AddScoped | 使用 AddTransient(若需要共享则使用 AddSingleton) | MAUI 没有 ASP.NET Core HTTP 管道中那种内置的请求作用域。MAUI 确实会为每个窗口创建一个 IServiceScope,因此 Scoped 服务的生命周期与该窗口相同;而从根提供程序解析时,它的行为与 Singleton 一样。这两种方式都无法保证每次导航都获得新实例 |
| 导航到已通过 DI 注册的 Page | 同时注册 Page 和其 ViewModel,然后调用 Routing.RegisterRoute | Shell.Current.GoToAsync 会通过 DI 解析 Page,并注入其构造函数依赖项 |
| 特定平台的实现 | 使用针对各平台的 #if,并覆盖所有平台 | 如果缺少某个平台分支,服务就不会注册,并会在解析时抛出异常 |
不要在尚未使用 DI 的项目中引入 DI,不要更改当前有效的服务生命周期,也不要仅为保持形式一致而添加接口——只有在用户提出要求或这样做能修复实际缺陷时才这么做。
回答要有针对性,但务必完整。 建议更改生命周期时,请展示注册代码,并给出切实可行的替代方案,而非只给出单一结论——对于工作单元或 DbContext 问题,这意味着要说明 AddTransient、显式调用 IServiceScopeFactory.CreateScope(),以及工厂模式(AddDbContextFactory),并说明各自适用的情形。与其只给出一句处方,通常不如简要列出选项并说明权衡。
// Explicit scope when you genuinely need unit-of-work semantics
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MyDbContext>();
工作流
- 确定所有需要参与依赖注入的服务、ViewModel 和页面。
- 为每种类型选择合适的生命周期——共享服务使用
AddSingleton,页面和 ViewModel 使用AddTransient。 - 在
MauiProgram.CreateMauiApp()中的builder.Services上注册所有类型,并按类别分组(服务、HTTP、ViewModel、页面)。 - 在
AppShell.xaml.cs中将页面注册为 Shell 路由,这样 Shell 导航时会自动解析完整的依赖关系图。 - 通过构造函数注入将每个页面与其 ViewModel 关联,并将 ViewModel 赋值给
BindingContext。 - 使用
#if指令添加平台特定的注册,确保涵盖每个目标平台,或提供回退实现。 - 运行应用以验证解析是否正常,并确认运行时没有
null依赖项或缺少注册的异常。
生命周期选择
| 生命周期 | 使用时机 | 典型类型 |
|---|---|---|
AddSingleton<T>() | 共享状态、创建开销大、全应用范围的配置 | HttpClient 工厂、设置服务、数据库连接 |
AddTransient<T>() | 轻量、无状态,或每次使用都需要新实例 | 页面、ViewModel、按调用创建的 API 包装器 |
AddScoped<T>() | 每个窗口的生命周期,或手动创建的 IServiceScope | 作用域工作单元(在 MAUI 中很少见) |
关键规则: 默认将页面和 ViewModel 注册为 Transient。将共享服务注册为 Singleton。
⚠️ 除非手动管理
IServiceScope,否则请避免使用AddScoped。 MAUI 没有像 ASP.NET Core 那样内置的请求作用域。MAUI 为每个窗口创建一个IServiceScope,因此 Scoped 服务的生命周期与该窗口相同;若从根提供程序解析,则会静默地表现为 Singleton。两种方式都无法确保每次导航时创建新实例。
MauiProgram.cs 中的注册模式
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder.UseMauiApp<App>();
// Services — Singleton for shared state
builder.Services.AddSingleton<IDataService, DataService>();
builder.Services.AddSingleton<ISettingsService, SettingsService>();
// HTTP — use typed or named clients via IHttpClientFactory
// Requires NuGet: Microsoft.Extensions.Http
builder.Services.AddHttpClient<IApiClient, ApiClient>();
// ViewModels — Transient for fresh state per navigation
builder.Services.AddTransient<MainViewModel>();
builder.Services.AddTransient<DetailViewModel>();
// Pages — Transient so constructor injection fires each time
builder.Services.AddTransient<MainPage>();
builder.Services.AddTransient<DetailPage>();
return builder.Build();
}
构造函数注入
通过构造函数参数注入依赖项。当该类型本身由 DI 解析时,容器会自动解析这些依赖项。
public class MainViewModel
{
private readonly IDataService _dataService;
public MainViewModel(IDataService dataService)
{
_dataService = dataService;
}
public async Task LoadAsync() => Items = await _dataService.GetItemsAsync();
}
ViewModel → 页面关联
同时注册页面和 ViewModel。将 ViewModel 注入页面,并将其赋值给 BindingContext:
public partial class MainPage : ContentPage
{
public MainPage(MainViewModel viewModel)
{
InitializeComponent();
BindingContext = viewModel;
}
}
Shell 导航自动解析
当页面已在 DI 和 Shell 路由中注册时,Shell 会在导航时自动解析该页面及其完整的依赖关系图:
// MauiProgram.cs
builder.Services.AddTransient<DetailPage>();
builder.Services.AddTransient<DetailViewModel>();
// AppShell.xaml.cs
Routing.RegisterRoute(nameof(DetailPage), typeof(DetailPage));
// Navigate — DI resolves DetailPage + DetailViewModel
await Shell.Current.GoToAsync(nameof(DetailPage));
向 DI 解析的 ViewModel 传递参数
DI 提供 ViewModel 的依赖项;导航参数则会单独传入。
不要尝试通过构造函数注入这些参数——在 ViewModel 上实现 IQueryAttributable,
使其能够接收两者:
public class DetailViewModel : ObservableObject, IQueryAttributable
{
readonly IDataService _data; // ← injected by DI
public DetailViewModel(IDataService data) => _data = data;
public void ApplyQueryAttributes(IDictionary<string, object> query)
{
// ← supplied by navigation
if (query.TryGetValue("id", out var id))
LoadAsync(id.ToString()!);
}
}
// Navigate with a parameter — the page and its ViewModel still come from DI
await Shell.Current.GoToAsync($"{nameof(DetailPage)}?id={product.Id}");
Shell 会将查询属性应用于页面和其 BindingContext,因此
ViewModel 无需在页面中进行任何关联即可接收这些属性。
平台特定注册
使用预处理器指令注册平台实现。始终涵盖每个目标平台,或提供空操作回退实现,以避免运行时出现 null。
#if ANDROID
builder.Services.AddSingleton<INotificationService, AndroidNotificationService>();
#elif IOS || MACCATALYST
builder.Services.AddSingleton<INotificationService, AppleNotificationService>();
#elif WINDOWS
builder.Services.AddSingleton<INotificationService, WindowsNotificationService>();
#else
builder.Services.AddSingleton<INotificationService, NoOpNotificationService>();
#endif
显式解析(最后手段)
优先使用构造函数注入。仅在确实无法注入的场合(自定义处理程序、平台回调)使用显式解析:
// From any Element with a Handler
var service = this.Handler.MauiContext.Services.GetService<IDataService>();
对于动态解析,请注入 IServiceProvider:
public class NavigationService(IServiceProvider serviceProvider)
{
public T ResolvePage<T>() where T : Page
=> serviceProvider.GetRequiredService<T>();
}
面向接口的可测试性模式
为每个服务定义接口,以便在测试中替换实现:
public interface IDataService
{
Task<List<Item>> GetItemsAsync();
}
// Production registration
builder.Services.AddSingleton<IDataService, DataService>();
// Test registration — swap without touching production code
var services = new ServiceCollection();
services.AddSingleton<IDataService, FakeDataService>();
常见问题
1. Singleton ViewModel 会导致数据过时
// ❌ ViewModel keeps stale state across navigations
builder.Services.AddSingleton<DetailViewModel>();
// ✅ Fresh instance each navigation
builder.Services.AddTransient<DetailViewModel>();
2. 通过 DI 不会创建 ContentTemplate 页面
通过 <ShellContent ContentTemplate="{DataTemplate views:DetailPage}"> 在 Shell XAML 中声明的页面,是通过 Activator.CreateInstance(ElementTemplate.cs)实例化的,而不是通过服务提供程序实例化。此路径不会执行构造函数注入:如果页面只有一个接收依赖项的构造函数,就会出现 MissingMethodException,而不是依赖项悄然变成 null。
通过 Routing.RegisterRoute + GoToAsync 访问的页面则不同:它们会经过 ActivatorUtilities.GetServiceOrCreateInstance(Routing.cs),即使页面类型本身从未注册,也会注入已注册的依赖项;如果无法解析必需的依赖项,则会抛出异常。
// Registering the page and its dependencies keeps both paths working
builder.Services.AddTransient<DetailPage>();
builder.Services.AddTransient<DetailViewModel>();
如果需要为选项卡页或飞出页使用 DI,请为其提供一个能够解析所需依赖项的无参构造函数,或者通过路由导航到该页面,而不是将其嵌入 ContentTemplate。
3. XAML 资源解析与 DI 时机
App.xaml 中的 XAML 资源会在 InitializeComponent() 期间解析,此时容器尚未完全可用。请将依赖服务的操作延迟到 CreateWindow() 中:
public partial class App : Application
{
private readonly IServiceProvider _services;
public App(IServiceProvider services)
{
_services = services;
InitializeComponent();
}
protected override Window CreateWindow(IActivationState? activationState)
{
// Safe — container is fully built
// Requires: builder.Services.AddTransient<AppShell>() in MauiProgram.cs
var appShell = _services.GetRequiredService<AppShell>();
return new Window(appShell);
}
}
4. 服务定位器反模式
// ❌ Hides dependencies, hard to test
var svc = this.Handler.MauiContext.Services.GetService<IDataService>();
// ✅ Constructor injection — explicit and testable
public class MyViewModel(IDataService dataService) { }
5. 条件注册时遗漏平台
在 #if 块中忘记某个平台,意味着在该平台上运行时 GetService<T>() 会返回 null。请始终提供 #else 回退,或覆盖所有目标平台。
6. 不手动创建作用域时使用 AddScoped
请参阅上方的规则表:AddScoped 提供的要么是窗口生命周期,要么是 Singleton 行为,绝不会按导航重新创建实例。除非你明确创建并管理 IServiceScope,否则请使用 AddTransient 或 AddSingleton。
检查清单
- 在
MauiProgram.cs中注册所有需要注入的 Page 和 ViewModel - Page 和 ViewModel 使用
AddTransient;共享服务使用AddSingleton - 尽可能使用构造函数注入;仅在最后才使用服务定位器
- 为需要测试替换的服务定义接口
- 平台专属的
#if注册覆盖所有目标平台,或包含回退方案 - 将依赖服务的操作延迟到
CreateWindow()中,而不是在解析 XAML 时执行 - 仅在需要窗口生命周期时使用
AddScoped,或者同时手动创建IServiceScope