mcpField
M
maui-dependency-injection

maui-dependency-injection

github.com/dotnet開発5.8k3.4k 回インストール1 日前に更新
リポジトリ

maui-dependency-injection について

.NET MAUI 開発者向けに、サービス登録やライフタイムからコンストラクターインジェクション、Shell ナビゲーションまでを案内するスキルです。プラットフォーム別登録や解決時の注意点も扱います。

主な機能

  1. 01MauiProgram でサービス、ページ、ViewModel を登録
  2. 02Singleton、Transient、Scoped の選択
  3. 03コンストラクターによる依存性注入
  4. 04Shell ルート経由でページを解決
  5. 05対象プラットフォームごとの実装登録

活用シーン

  1. 01MAUI ページと ViewModel の注入設定
  2. 02共有サービスのライフタイム選択
  3. 03Shell ナビゲーションで依存ページを生成
  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 コンテナーを使用します。すべてのサービス登録は、builder.Services を使って MauiProgram.CreateMauiApp() で行います。コンテナーは起動時に一度だけ構築され、その後は変更できません。

使用する場面

  • 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 を使うアプリ全体でインスタンスを1つだけ使います(設定、DB 接続、HttpClient ハンドラーなど)
AddScoped を使いたくなった場合AddTransient を使います(共有が意図された動作なら AddSingleton を使います)MAUI には、ASP.NET Core の HTTP パイプラインのような組み込みのリクエストスコープはありません。MAUI はウィンドウごとに1つの IServiceScope を作成するため、Scoped サービスはそのウィンドウが存続する間有効です。また、ルートプロバイダーから解決すると Singleton のように動作します。どちらもナビゲーションごとに新しいインスタンスを提供するものではありません
DI に登録された Page に移動するPage とその ViewModel を登録し、Routing.RegisterRoute を呼び出すShell.Current.GoToAsync は DI を通じて Page を解決し、コンストラクターの依存関係を注入します
プラットフォーム固有の実装すべてのプラットフォームを網羅したうえで、プラットフォームごとに #if を使ういずれかのプラットフォームの分岐が欠けていると、そのサービスは登録されず、解決時に例外がスローされます

DI を使っていないプロジェクトに DI を導入したり、問題なく動作しているサービスのライフタイムを変更したり、単に統一感を持たせるためだけにインターフェイスを追加したりしないでください。ユーザーから依頼された場合、または実際の不具合を解決する場合に限ってください。

回答は要点を絞りつつ、必要な内容を漏らさないでください。 ライフタイムの変更を提案する場合は、登録コードを示し、現実的な選択肢を1つだけでなく提示してください。ユニットオブワークや DbContext に関する質問であれば、AddTransient、明示的な IServiceScopeFactory.CreateScope()、そしてファクトリーパターン(AddDbContextFactory)を取り上げ、それぞれが適している場面について説明してください。通常、1行で決めつけるよりも、トレードオフを添えた簡潔な選択肢の提示のほうが適切です。

// 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はウィンドウごとに1つの IServiceScope を作成するため、スコープ付きサービスはそのウィンドウが存続する間有効です。ルートプロバイダーから解決すると、暗黙的に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. ContentTemplate ページは DI 経由では作成されません

Shell XAML で <ShellContent ContentTemplate="{DataTemplate views:DetailPage}"> として宣言されたページは、サービスプロバイダー経由ではなく、Activator.CreateInstance(ElementTemplate.cs)でインスタンス化されます。この経路ではコンストラクターインジェクションは実行されません。ページのコンストラクターが依存関係を受け取るものだけの場合、依存関係が黙って null になるのではなく、MissingMethodException が発生します。

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 を使用してください。


チェックリスト

  • 注入が必要なすべての Page と ViewModel が MauiProgram.cs に登録されている
  • Page と ViewModel には AddTransient、共有サービスには AddSingleton を使用している
  • 可能な限りすべての箇所でコンストラクターインジェクションを使用し、サービスロケーターは最後の手段としてのみ使用している
  • テスト時に差し替える必要があるサービスにはインターフェイスを定義している
  • プラットフォーム固有の #if 登録がすべてのターゲットプラットフォームを対象にしているか、フォールバックを含んでいる
  • サービスに依存する処理を XAML の解析中に実行せず、CreateWindow() まで遅らせている
  • AddScoped はウィンドウ単位の有効期間を意図する場合にのみ使用しているか、手動で作成した IServiceScope と併用している

参考資料

コメント