mcpField
M
maui-dependency-injection

maui-dependency-injection

github.com/dotnet开发5.8k3.4k 次安装1 天前更新
代码仓库

关于 maui-dependency-injection

为 .NET MAUI 开发者提供依赖注入设计与配置指导,涵盖服务注册、生命周期、构造函数注入和 Shell 导航。内容还说明平台条件注册及常见解析问题。

主要功能

  1. 01在 MauiProgram 中注册服务、页面和 ViewModel
  2. 02选择 Singleton、Transient 或 Scoped 生命周期
  3. 03通过构造函数注入依赖
  4. 04结合 Shell 路由解析页面
  5. 05按平台注册实现并覆盖目标平台

适用场景

  1. 01为 MAUI 页面和 ViewModel 配置依赖注入
  2. 02为共享服务选择生命周期
  3. 03让 Shell 导航创建含依赖的页面
  4. 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.RegisterRouteShell.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>();

工作流

  1. 确定所有需要参与依赖注入的服务、ViewModel 和页面。
  2. 为每种类型选择合适的生命周期——共享服务使用 AddSingleton,页面和 ViewModel 使用 AddTransient。
  3. 在 MauiProgram.CreateMauiApp() 中的 builder.Services 上注册所有类型,并按类别分组(服务、HTTP、ViewModel、页面)。
  4. 在 AppShell.xaml.cs 中将页面注册为 Shell 路由,这样 Shell 导航时会自动解析完整的依赖关系图。
  5. 通过构造函数注入将每个页面与其 ViewModel 关联,并将 ViewModel 赋值给 BindingContext。
  6. 使用 #if 指令添加平台特定的注册,确保涵盖每个目标平台,或提供回退实现。
  7. 运行应用以验证解析是否正常,并确认运行时没有 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

参考资料

评论

支持 Markdown