.NET MAUI 10 クリーンアーキテクチャ実践:MVVM・DI・Shell Navigationガイド

.NET MAUI 10でCommunityToolkit.Mvvm 8.4のソースジェネレーター、MauiProgram.csでの依存性注入、Shell Navigationによる型安全なページ遷移を組み合わせたクリーンアーキテクチャの実践ガイド。コード例とユニットテスト付き。

はじめに:なぜアプリ設計にこだわるべきなのか

「動けばいい」——正直、最初はそれで十分だと思ってました。でもアプリの規模が大きくなるにつれ、コードビハインドにロジックが散らばって、テストが書けなくなり、機能追加のたびに既存コードが壊れる。そんな経験、ありませんか?

筆者自身、何度もこの壁にぶつかってきました。

.NET MAUI 10(LTS)は、Android・iOS・Windows・macOSを単一コードベースで開発できる強力なフレームワークです。ただ、フレームワークがいくら優れていても、アプリの「設計」がしっかりしていなければ、その力を十分に引き出すことはできません。ここが意外と見落とされがちなポイントだったりします。

この記事では、.NET MAUIアプリケーションにおけるクリーンアーキテクチャの実践方法を、3つの柱を軸に解説していきます。

  • MVVM(Model-View-ViewModel):CommunityToolkit.Mvvmのソースジェネレーターを活用した効率的な実装
  • 依存性注入(DI):MauiProgram.csでのサービス登録とライフタイム管理
  • Shell Navigation:GoToAsyncとIQueryAttributableによる型安全なページ遷移

すべてのコード例は.NET 10とCommunityToolkit.Mvvm 8.4に対応しています。タスク管理アプリを題材にして、プロジェクト構造からユニットテストまで一気通貫で見ていきましょう。

プロジェクト構造の設計

クリーンなアーキテクチャの第一歩は、フォルダ構成です。機能ごとではなくレイヤーごとに分離する。これだけで依存関係が明確になって、テストもぐっとしやすくなります。

MyTaskApp/
├── Models/
│   └── TaskItem.cs
├── Services/
│   ├── ITaskRepository.cs
│   ├── INavigationService.cs
│   └── Impl/
│       ├── SqliteTaskRepository.cs
│       └── ShellNavigationService.cs
├── ViewModels/
│   ├── TaskListViewModel.cs
│   └── TaskDetailViewModel.cs
├── Views/
│   ├── TaskListPage.xaml
│   ├── TaskListPage.xaml.cs
│   └── TaskDetailPage.xaml(.cs)
├── App.xaml(.cs)
├── AppShell.xaml(.cs)
└── MauiProgram.cs

ポイントをまとめると、こんな感じです。

  • Models:ビジネスロジックに依存しない純粋なデータクラス
  • Services:インターフェースと実装を分離。テスト時にモックに差し替え可能
  • ViewModels:UIロジックを担当。ViewやFrameworkへの直接依存を持たない
  • Views:XAMLとコードビハインド。コードビハインドはDIによるViewModel注入のみ

CommunityToolkit.Mvvm によるMVVMパターンの実装

CommunityToolkit.Mvvm(バージョン8.4)は、.NET MAUI開発における事実上の標準MVVMライブラリです。ソースジェネレーターのおかげで、あのうんざりするボイラープレートコードを劇的に削減できます。

まずNuGetパッケージをインストールしましょう。

dotnet add package CommunityToolkit.Mvvm --version 8.4.2

ObservableProperty とPartial Properties

CommunityToolkit.Mvvm 8.4以降、C#のPartial Properties機能を活用した新しい構文が使えるようになりました。従来のフィールドベースの書き方と比較してみると、違いがはっきり分かります。

// 従来の書き方(フィールドベース)
public partial class TaskListViewModel : ObservableObject
{
    [ObservableProperty]
    private string? _searchText;
}

// 新しい書き方(Partial Properties、推奨)
public partial class TaskListViewModel : ObservableObject
{
    [ObservableProperty]
    public partial string? SearchText { get; set; }
}

Partial Propertiesを使うメリットはいくつかあります。

  • アクセス修飾子のカスタマイズpublic partial string? Name { get; protected set; }のように、setterだけを制限できる
  • Native AOT対応:トリミング安全で、WinUI 3/CsWinRT環境でも問題なく動作する
  • コード補完の向上:フィールド名とプロパティ名の混乱がなくなる(地味にありがたい)
  • 修飾子のサポートrequiredsealedoverrideなどが使える

プロパティの変更を検知するフックメソッドも自動生成されます。

public partial class TaskDetailViewModel : ObservableObject
{
    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(CanSave))]
    [NotifyCanExecuteChangedFor(nameof(SaveCommand))]
    public partial string? Title { get; set; }

    [ObservableProperty]
    public partial string? Description { get; set; }

    public bool CanSave => !string.IsNullOrWhiteSpace(Title);

    // Titleが変更されるときに自動的に呼ばれる
    partial void OnTitleChanging(string? oldValue, string? newValue)
    {
        Debug.WriteLine($"Title: {oldValue} → {newValue}");
    }

    // Titleが変更された後に呼ばれる
    partial void OnTitleChanged(string? value)
    {
        Debug.WriteLine($"Titleが更新されました: {value}");
    }
}

NotifyPropertyChangedForは依存プロパティ(ここではCanSave)の変更通知を自動発行し、NotifyCanExecuteChangedForはコマンドの実行可否を再評価させます。手動でPropertyChangedを呼ぶ必要がなくなるのは、本当にありがたいですね。

RelayCommand によるコマンドの実装

[RelayCommand]属性を使うと、メソッドから自動的にICommandプロパティが生成されます。非同期メソッドの場合はAsyncRelayCommandが生成されて、IsRunningプロパティでローディング状態も取得できるという優れものです。

public partial class TaskListViewModel : ObservableObject
{
    private readonly ITaskRepository _repository;

    [ObservableProperty]
    public partial ObservableCollection<TaskItem>? Tasks { get; set; }

    [ObservableProperty]
    public partial bool IsRefreshing { get; set; }

    public TaskListViewModel(ITaskRepository repository)
    {
        _repository = repository;
    }

    [RelayCommand]
    private async Task LoadTasksAsync()
    {
        try
        {
            var tasks = await _repository.GetAllAsync();
            Tasks = new ObservableCollection<TaskItem>(tasks);
        }
        finally
        {
            IsRefreshing = false;
        }
    }

    [RelayCommand]
    private async Task DeleteTaskAsync(TaskItem task)
    {
        await _repository.DeleteAsync(task.Id);
        Tasks?.Remove(task);
    }

    [RelayCommand(CanExecute = nameof(CanSave))]
    private async Task SaveTaskAsync()
    {
        // CanSaveがtrueの場合のみ実行される
        await _repository.SaveAsync(CurrentTask);
    }

    private bool CanSave() => CurrentTask?.Title?.Length > 0;
}

XAMLでのバインディングはこのようになります。

<!-- ローディングインジケーターの表示 -->
<ActivityIndicator IsRunning="{Binding LoadTasksCommand.IsRunning}"
                   IsVisible="{Binding LoadTasksCommand.IsRunning}" />

<!-- Pull-to-Refresh -->
<RefreshView Command="{Binding LoadTasksCommand}"
             IsRefreshing="{Binding IsRefreshing}">
    <CollectionView ItemsSource="{Binding Tasks}">
        <CollectionView.ItemTemplate>
            <DataTemplate x:DataType="models:TaskItem">
                <SwipeView>
                    <SwipeView.RightItems>
                        <SwipeItems>
                            <SwipeItem Text="削除"
                                       BackgroundColor="Red"
                                       Command="{Binding Source={RelativeSource AncestorType={x:Type viewmodels:TaskListViewModel}}, Path=DeleteTaskCommand}"
                                       CommandParameter="{Binding .}" />
                        </SwipeItems>
                    </SwipeView.RightItems>
                    <Grid Padding="16">
                        <Label Text="{Binding Title}" FontSize="16" />
                    </Grid>
                </SwipeView>
            </DataTemplate>
        </CollectionView.ItemTemplate>
    </CollectionView>
</RefreshView>

AsyncRelayCommandが提供するIsRunningプロパティを活用すれば、わざわざ手動でIsBusyフラグを管理する必要がなくなります。コード量がかなり減るので、ぜひ使ってみてください。

ObservableValidator によるバリデーション

入力フォームのバリデーションにはObservableValidatorを使います。標準のDataAnnotations属性がそのまま使えるのが嬉しいところ。

public partial class TaskDetailViewModel : ObservableValidator
{
    [ObservableProperty]
    [NotifyDataErrorInfo]
    [Required(ErrorMessage = "タイトルは必須です")]
    [MinLength(2, ErrorMessage = "タイトルは2文字以上で入力してください")]
    public partial string? Title { get; set; }

    [ObservableProperty]
    [NotifyDataErrorInfo]
    [MaxLength(500, ErrorMessage = "説明は500文字以内で入力してください")]
    public partial string? Description { get; set; }

    [RelayCommand(CanExecute = nameof(CanSave))]
    private async Task SaveAsync()
    {
        ValidateAllProperties();
        if (HasErrors) return;
        // 保存処理
    }

    private bool CanSave() => !HasErrors;
}

XAML側ではエラーメッセージをこのように表示できます。

<Entry Text="{Binding Title}" Placeholder="タスクのタイトル" />
<Label Text="{Binding (validation:DataErrorsChangedHelper.Errors)[Title]}"
       TextColor="Red" FontSize="12" />

MauiProgram.cs での依存性注入(DI)

.NET MAUIは、ASP.NET Coreと同じMicrosoft.Extensions.DependencyInjectionコンテナを内蔵しています。ASP.NET Core経験者なら、すぐに馴染めるはず。すべてのサービス、ViewModel、ページをDIコンテナに登録すれば、依存関係が自動的に解決されます。

基本的なサービス登録

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<ITaskRepository, SqliteTaskRepository>();
        builder.Services.AddSingleton<INavigationService, ShellNavigationService>();
        builder.Services.AddSingleton<IMessenger>(
            WeakReferenceMessenger.Default);

        // ViewModel(Transientで毎回新しいインスタンスを生成)
        builder.Services.AddTransient<TaskListViewModel>();
        builder.Services.AddTransient<TaskDetailViewModel>();

        // ページ
        builder.Services.AddTransient<TaskListPage>();
        builder.Services.AddTransient<TaskDetailPage>();

#if DEBUG
        builder.Logging.AddDebug();
#endif

        return builder.Build();
    }
}

ライフタイムの使い分け

.NET MAUIのDIコンテナには3つのライフタイムがあります。ここをちゃんと理解しておかないと、後で地味にハマります。

メソッド動作主な用途
AddSingleton<T>アプリ全体で1つのインスタンスを共有リポジトリ、HttpClient、設定サービス
AddTransient<T>要求のたびに新しいインスタンスを生成ViewModel、ページ
AddScoped<T>スコープ内で1つのインスタンスを共有Blazor Hybridコンポーネント以外ではSingletonと同じ動作

ちょっと注意が必要なのが、.NET MAUIの通常のShellアプリではAddScopedに自然なスコープ境界がないため、事実上AddSingletonと同じ動作になること。明示的にスコープを作る必要がある場合はIServiceScopeFactoryを使ってください。

拡張メソッドパターン(大規模アプリ向け)

アプリの規模が大きくなると、MauiProgram.csがどんどん肥大化していきます。拡張メソッドパターンで分割すると、だいぶ見通しがよくなりますよ。

public static class MauiProgram
{
    public static MauiApp CreateMauiApp()
        => MauiApp.CreateBuilder()
            .UseMauiApp<App>()
            .ConfigureFonts(fonts =>
            {
                fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            })
            .RegisterServices()
            .RegisterViewModels()
            .RegisterViews()
            .Build();
}

public static class ServiceRegistration
{
    public static MauiAppBuilder RegisterServices(
        this MauiAppBuilder builder)
    {
        builder.Services.AddSingleton<ITaskRepository, SqliteTaskRepository>();
        builder.Services.AddSingleton<INavigationService, ShellNavigationService>();
        builder.Services.AddSingleton<IMessenger>(
            WeakReferenceMessenger.Default);

        // HttpClientの登録
        builder.Services.AddHttpClient("TaskApi", client =>
        {
            client.BaseAddress = new Uri("https://api.example.com/");
            client.DefaultRequestHeaders.Add("Accept", "application/json");
        });

        return builder;
    }

    public static MauiAppBuilder RegisterViewModels(
        this MauiAppBuilder builder)
    {
        builder.Services.AddTransient<TaskListViewModel>();
        builder.Services.AddTransient<TaskDetailViewModel>();
        return builder;
    }

    public static MauiAppBuilder RegisterViews(
        this MauiAppBuilder builder)
    {
        builder.Services.AddTransient<TaskListPage>();
        builder.Services.AddTransient<TaskDetailPage>();
        return builder;
    }
}

プラットフォーム固有サービスの注入

プラットフォーム固有の機能(通知、センサーなど)を注入したい場面もありますよね。インターフェースを共有コードに定義して、各プラットフォームの実装を条件付きコンパイルで登録するのが定番です。

// Services/IDeviceNotificationService.cs(共有コード)
public interface IDeviceNotificationService
{
    Task<bool> RequestPermissionAsync();
    Task ShowLocalNotificationAsync(string title, string body);
}

// MauiProgram.cs での条件付き登録
#if ANDROID
builder.Services.AddSingleton<IDeviceNotificationService,
    Platforms.Android.AndroidNotificationService>();
#elif IOS
builder.Services.AddSingleton<IDeviceNotificationService,
    Platforms.iOS.iOSNotificationService>();
#endif

ViewModelはインターフェース経由でアクセスするため、プラットフォーム固有のコードには一切依存しません。これがテスタビリティの鍵になります。

Shell Navigationによるページ遷移

.NET MAUI Shellは、URIベースのナビゲーション機構を提供します。タブバー、フライアウトメニュー、ページスタック管理を統一的に扱えるので、大規模アプリでも見通しのよいナビゲーションが実現できます。

ルート登録の基本

Shellのルート登録には、XAMLでの宣言的登録とコードでのプログラム的登録の2種類があります。使い分けが大事です。

<!-- AppShell.xaml -->
<Shell xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       xmlns:pages="clr-namespace:MyTaskApp.Views"
       x:Class="MyTaskApp.AppShell">

    <TabBar>
        <ShellContent Route="tasks"
                      Title="タスク"
                      Icon="icon_tasks.png"
                      ContentTemplate="{DataTemplate pages:TaskListPage}" />
        <ShellContent Route="settings"
                      Title="設定"
                      Icon="icon_settings.png"
                      ContentTemplate="{DataTemplate pages:SettingsPage}" />
    </TabBar>
</Shell>
// AppShell.xaml.cs — 詳細ページのルート登録
public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();

        // 詳細ページはプログラム的に登録
        Routing.RegisterRoute("taskdetail", typeof(TaskDetailPage));
    }
}

基本パターンとしては、トップレベルのページ(タブバーやフライアウトに表示されるもの)はXAMLで宣言し、そこから遷移する詳細ページはコードで登録します。

GoToAsync によるナビゲーション

Shell.GoToAsyncは、文字列ベースのルーティングでページ遷移を行います。絶対ルートと相対ルートの両方をサポートしていて、かなり柔軟に使えます。

// 相対ルート(現在のスタックにプッシュ)
await Shell.Current.GoToAsync("taskdetail");

// 絶対ルート(ナビゲーションスタックをリセット)
await Shell.Current.GoToAsync("//tasks");

// 戻る
await Shell.Current.GoToAsync("..");

// 戻ってから別のページに遷移
await Shell.Current.GoToAsync("../taskdetail");

// 文字列クエリパラメータ
await Shell.Current.GoToAsync(
    $"taskdetail?taskId={task.Id}");

// オブジェクトパラメータ(複雑なデータの受け渡し)
var parameters = new Dictionary<string, object>
{
    { "Task", selectedTask }
};
await Shell.Current.GoToAsync("taskdetail", parameters);

// 使い捨てパラメータ(戻るときに再適用されない)
var parameters = new ShellNavigationQueryParameters
{
    { "Task", selectedTask }
};
await Shell.Current.GoToAsync("taskdetail", parameters);

ここで重要な注意点。GoToAsyncは必ずawaitしてください。Fire-and-forget(awaitなしの呼び出し)はレースコンディションの原因になります。これ、実際にやってしまうと原因が分かりにくいバグになるので要注意です。

IQueryAttributable によるデータ受け渡し

ナビゲーション先のViewModelでデータを受け取るには、IQueryAttributableインターフェースを実装します。[QueryProperty]属性もありますが、トリミング安全でないためNative AOT環境では避けたほうが無難です。

public partial class TaskDetailViewModel : ObservableObject,
    IQueryAttributable
{
    [ObservableProperty]
    public partial TaskItem? Task { get; set; }

    [ObservableProperty]
    public partial bool IsNewTask { get; set; }

    public void ApplyQueryAttributes(
        IDictionary<string, object> query)
    {
        if (query.TryGetValue("Task", out var obj)
            && obj is TaskItem task)
        {
            // 既存タスクの編集
            Task = task;
            IsNewTask = false;
        }
        else
        {
            // 新規タスク作成
            Task = new TaskItem();
            IsNewTask = true;
        }
    }
}

Dictionary<string, object>で渡されたデータは、ページのライフタイム中保持されて、戻るナビゲーション時にも再適用されます。これを避けたい場合はShellNavigationQueryParametersを使いましょう。

ナビゲーションガード(遷移制御)

未保存の変更がある状態で画面を離れようとしたとき、ユーザーに確認を求めたいケースってありますよね。ナビゲーションガードで実装できます。

public partial class AppShell : Shell
{
    protected override async void OnNavigating(
        ShellNavigatingEventArgs args)
    {
        base.OnNavigating(args);

        if (args.Source == ShellNavigationSource.Pop)
        {
            // 非同期処理のために遅延トークンを取得
            var deferral = args.GetDeferral();

            var result = await DisplayActionSheetAsync(
                "変更を破棄しますか?",
                "キャンセル", null,
                "破棄する", "保存して戻る");

            if (result == "キャンセル")
            {
                args.Cancel();
            }

            deferral.Complete();
        }
    }
}

ちなみに、.NET MAUI 10ではDisplayAlertDisplayActionSheetは非推奨になりました。代わりにDisplayAlertAsyncDisplayActionSheetAsyncを使ってください。

NavigationServiceパターンの実装

ViewModelから直接Shell.Current.GoToAsyncを呼ぶと、ViewModelがShellに依存してしまいテストが困難になります。そこで、NavigationServiceを介して抽象化するのがベストプラクティスです。

// インターフェース定義
public interface INavigationService
{
    Task NavigateToAsync(string route);
    Task NavigateToAsync(string route,
        IDictionary<string, object> parameters);
    Task GoBackAsync();
}

// Shell ベースの実装
public class ShellNavigationService : INavigationService
{
    public async Task NavigateToAsync(string route)
    {
        await Shell.Current.GoToAsync(route);
    }

    public async Task NavigateToAsync(string route,
        IDictionary<string, object> parameters)
    {
        await Shell.Current.GoToAsync(route, parameters);
    }

    public async Task GoBackAsync()
    {
        await Shell.Current.GoToAsync("..");
    }
}

ViewModelでの使い方はシンプルです。

public partial class TaskListViewModel : ObservableObject
{
    private readonly INavigationService _navigation;
    private readonly ITaskRepository _repository;

    public TaskListViewModel(
        INavigationService navigation,
        ITaskRepository repository)
    {
        _navigation = navigation;
        _repository = repository;
    }

    [RelayCommand]
    private async Task GoToDetailAsync(TaskItem task)
    {
        var parameters = new Dictionary<string, object>
        {
            { "Task", task }
        };
        await _navigation.NavigateToAsync("taskdetail", parameters);
    }

    [RelayCommand]
    private async Task AddNewTaskAsync()
    {
        await _navigation.NavigateToAsync("taskdetail");
    }
}

テスト時にはINavigationServiceをモックに差し替えるだけで、Shellへの依存なしにViewModelのロジックを検証できます。この「差し替え可能」というのが、設計のキモですね。

WeakReferenceMessenger によるViewModel間通信

ViewModel同士が直接参照し合うと、密結合の原因になります。CommunityToolkit.MvvmのWeakReferenceMessengerを使えば、疎結合なイベント通知が実現できます。

.NET MAUI 10では従来のMessagingCenterがinternal化されたので、CommunityToolkit.Mvvmのメッセンジャーへの移行は事実上必須です。

// メッセージの定義
public class TaskSavedMessage : ValueChangedMessage<TaskItem>
{
    public TaskSavedMessage(TaskItem task) : base(task) { }
}

public class TaskDeletedMessage : ValueChangedMessage<int>
{
    public TaskDeletedMessage(int taskId) : base(taskId) { }
}
// 送信側(TaskDetailViewModel)
[RelayCommand]
private async Task SaveAsync()
{
    ValidateAllProperties();
    if (HasErrors) return;

    await _repository.SaveAsync(Task!);

    // タスク一覧に保存完了を通知
    WeakReferenceMessenger.Default.Send(
        new TaskSavedMessage(Task!));

    await _navigation.GoBackAsync();
}
// 受信側(TaskListViewModel)
public partial class TaskListViewModel : ObservableRecipient,
    IRecipient<TaskSavedMessage>,
    IRecipient<TaskDeletedMessage>
{
    public TaskListViewModel(
        INavigationService navigation,
        ITaskRepository repository)
    {
        _navigation = navigation;
        _repository = repository;
        IsActive = true; // メッセージ受信を有効化
    }

    public void Receive(TaskSavedMessage message)
    {
        var existingTask = Tasks?.FirstOrDefault(
            t => t.Id == message.Value.Id);

        if (existingTask != null)
        {
            var index = Tasks!.IndexOf(existingTask);
            Tasks[index] = message.Value;
        }
        else
        {
            Tasks?.Add(message.Value);
        }
    }

    public void Receive(TaskDeletedMessage message)
    {
        var task = Tasks?.FirstOrDefault(
            t => t.Id == message.Value);
        if (task != null) Tasks?.Remove(task);
    }
}

ObservableRecipientを継承しIRecipient<T>を実装すると、IsActive = trueを設定した時点で自動的にメッセージ受信が登録されます。WeakReferenceを使用しているため、メモリリークの心配もありません。このあたりの設計は本当によくできてると思います。

Repositoryパターンとサービスレイヤー

データアクセス層をRepositoryパターンで抽象化しておくと、データソースの変更(たとえばSQLiteからAPIへの切り替え)がViewModelに影響を与えなくなります。将来の変更に強い設計です。

// リポジトリインターフェース
public interface ITaskRepository
{
    Task<IReadOnlyList<TaskItem>> GetAllAsync();
    Task<TaskItem?> GetByIdAsync(int id);
    Task SaveAsync(TaskItem task);
    Task DeleteAsync(int id);
}

// SQLite実装
public class SqliteTaskRepository : ITaskRepository
{
    private readonly SQLiteAsyncConnection _db;

    public SqliteTaskRepository()
    {
        var dbPath = Path.Combine(
            FileSystem.AppDataDirectory, "tasks.db3");
        _db = new SQLiteAsyncConnection(dbPath);
        _db.CreateTableAsync<TaskItem>().Wait();
    }

    public async Task<IReadOnlyList<TaskItem>> GetAllAsync()
    {
        var items = await _db.Table<TaskItem>()
            .OrderByDescending(t => t.CreatedAt)
            .ToListAsync();
        return items.AsReadOnly();
    }

    public async Task<TaskItem?> GetByIdAsync(int id)
    {
        return await _db.Table<TaskItem>()
            .FirstOrDefaultAsync(t => t.Id == id);
    }

    public async Task SaveAsync(TaskItem task)
    {
        if (task.Id == 0)
            await _db.InsertAsync(task);
        else
            await _db.UpdateAsync(task);
    }

    public async Task DeleteAsync(int id)
    {
        await _db.DeleteAsync<TaskItem>(id);
    }
}

コードビハインドでのDIとViewModel接続

ページのコードビハインドでは、コンストラクタインジェクションでViewModelを受け取って、BindingContextに設定するだけ。とてもシンプルです。

public partial class TaskListPage : ContentPage
{
    public TaskListPage(TaskListViewModel viewModel)
    {
        InitializeComponent();
        BindingContext = viewModel;
    }

    protected override async void OnAppearing()
    {
        base.OnAppearing();
        if (BindingContext is TaskListViewModel vm)
        {
            await vm.LoadTasksCommand.ExecuteAsync(null);
        }
    }
}

コードビハインドに書くロジックは、このViewModel注入と初期ロードの呼び出し程度に留めるのが理想です。UIロジックはすべてViewModelに、表示の定義はXAMLに。この役割分担を守ることが大切です。

ユニットテストの実装

ここまでの設計がちゃんとできていれば、ユニットテストは驚くほどスムーズに書けます。すべての依存関係がインターフェース経由で注入されているから、モックに差し替えるだけでOK。

// xUnit + NSubstitute の例
public class TaskListViewModelTests
{
    private readonly ITaskRepository _repository;
    private readonly INavigationService _navigation;
    private readonly TaskListViewModel _viewModel;

    public TaskListViewModelTests()
    {
        _repository = Substitute.For<ITaskRepository>();
        _navigation = Substitute.For<INavigationService>();
        _viewModel = new TaskListViewModel(
            _navigation, _repository);
    }

    [Fact]
    public async Task LoadTasks_ReturnsAllTasks()
    {
        // Arrange
        var expected = new List<TaskItem>
        {
            new() { Id = 1, Title = "タスク1" },
            new() { Id = 2, Title = "タスク2" }
        };
        _repository.GetAllAsync()
            .Returns(expected.AsReadOnly());

        // Act
        await _viewModel.LoadTasksCommand.ExecuteAsync(null);

        // Assert
        Assert.Equal(2, _viewModel.Tasks?.Count);
        Assert.Equal("タスク1", _viewModel.Tasks?[0].Title);
    }

    [Fact]
    public async Task GoToDetail_NavigatesWithTask()
    {
        // Arrange
        var task = new TaskItem { Id = 1, Title = "テスト" };

        // Act
        await _viewModel.GoToDetailCommand.ExecuteAsync(task);

        // Assert
        await _navigation.Received(1).NavigateToAsync(
            "taskdetail",
            Arg.Is<IDictionary<string, object>>(
                d => d.ContainsKey("Task")
                    && d["Task"] == task));
    }

    [Fact]
    public async Task DeleteTask_RemovesFromCollection()
    {
        // Arrange
        var task = new TaskItem { Id = 1, Title = "削除対象" };
        _viewModel.Tasks = new ObservableCollection<TaskItem>
            { task };

        // Act
        await _viewModel.DeleteTaskCommand.ExecuteAsync(task);

        // Assert
        Assert.Empty(_viewModel.Tasks);
        await _repository.Received(1).DeleteAsync(1);
    }
}

よくあるアンチパターンと対策

最後に、.NET MAUIアプリ設計で陥りがちなアンチパターンをまとめておきます。筆者もいくつかは身に覚えがあります…。

1. コードビハインドにビジネスロジックを書く

ボタンのクリックイベントで直接データベースにアクセスしたり、APIを呼んだり。最初は手っ取り早いんですが、テスト不可能で再利用もできません。すべてのロジックはViewModelに移動し、コードビハインドはViewModel注入と初期化のみにしましょう。

2. ViewModelからViewを直接参照する

ViewModelでNavigation.PushAsync(new DetailPage())のようにページクラスを直接生成すると、ViewModelがUI層に依存してしまいます。前述のNavigationServiceパターンで抽象化してください。

3. Singletonの過度な使用

ViewModelやページをSingletonにすると、前回の状態が残ってバグの温床になります。ページとViewModelは原則AddTransientで登録しましょう。Singletonにすべきなのは、状態を持たないサービスやリポジトリなどだけです。

4. GoToAsync を await しない

Shell.Current.GoToAsync("detail")をawaitせずに呼ぶと、ナビゲーション完了前に後続のコードが実行されて、予期しない動作になります。必ずawaitを付けてください。繰り返しになりますが、これは本当に大事です。

5. MessagingCenter の使用(.NET MAUI 10)

.NET MAUI 10でMessagingCenterはinternal化されました。CommunityToolkit.MvvmのWeakReferenceMessengerに移行してください。強い参照によるStrongReferenceMessengerも利用可能ですが、手動でのUnregisterが必要になる点には注意です。

まとめ

.NET MAUIアプリケーションにおけるクリーンアーキテクチャの実現は、MVVM・DI・Shell Navigationの3つの柱を正しく組み合わせることで達成できます。

  • CommunityToolkit.Mvvm 8.4のソースジェネレーター(ObservableProperty、RelayCommand)でボイラープレートを排除
  • MauiProgram.csでサービス・ViewModel・ページを適切なライフタイムで登録
  • Shell NavigationのGoToAsyncとIQueryAttributableで型安全なページ遷移を実現
  • NavigationServiceパターンでViewModelからのナビゲーションを抽象化
  • WeakReferenceMessengerでViewModel間の疎結合な通信を実現

この設計パターンに従えば、テスト可能で保守性の高い.NET MAUIアプリケーションが構築できます。まずは小さなプロジェクトでこのアーキテクチャを試してみて、自分やチームに合った形にカスタマイズしていくのがおすすめです。

よくある質問(FAQ)

.NET MAUIでMVVMパターンを使う最大のメリットは何ですか?

最大のメリットはテスタビリティです。UIロジックがViewModelに分離されているため、UIフレームワークに依存しないユニットテストが書けます。また、UIデザイナーとロジック開発者が並行して作業できるため、チーム開発の効率も向上します。CommunityToolkit.Mvvmのソースジェネレーターを使えば、ボイラープレートコードも最小限に抑えられます。

CommunityToolkit.MvvmのPartial Propertiesを使うにはどうすればいいですか?

CommunityToolkit.Mvvm 8.4以降をNuGetからインストールし、.NET 10 SDK以降を使用していれば、特別な設定なしで[ObservableProperty] public partial string? Name { get; set; }の構文が使えます。.NET 9 SDKを使用している場合は、.csprojに<LangVersion>preview</LangVersion>を追加してください。

Shell NavigationのGoToAsyncとNavigationPage.PushAsyncの違いは何ですか?

GoToAsyncはURIベースのルーティングで、文字列パスによるページ遷移を行います。Deep LinkingやURLスキームとの統合が容易で、タブバーやフライアウトメニューを含む複雑なナビゲーション構造に対応できます。一方、NavigationPage.PushAsyncはページインスタンスの直接プッシュで、シンプルなスタック操作に向いています。大規模アプリではShellの使用が推奨されます。

.NET MAUIのDIでViewModelはSingletonとTransientのどちらで登録すべきですか?

ほとんどの場合、ViewModelはAddTransientで登録すべきです。Singletonにすると前回のナビゲーション時の状態(フォーム入力値、選択状態など)が残り、予期しないバグの原因になります。ただし、アプリ全体で共有すべき状態を持つViewModel(設定画面など)は例外的にSingletonにすることもあります。

.NET MAUI 10でMessagingCenterが使えなくなったのですが、代替手段はありますか?

.NET MAUI 10ではMessagingCenterがinternal化されました。代替として、CommunityToolkit.MvvmのWeakReferenceMessengerを使用してください。WeakReferenceを使用しているためメモリリークのリスクが低く、IRecipient<T>インターフェースによる型安全なメッセージングが可能です。DIコンテナにはbuilder.Services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default)で登録します。

著者について Editorial Team

Our team of expert writers and editors.