.NET MAUI AndroidでMVVM完全ガイド|CommunityToolkit.MvvmとDIで作る実装手順とサンプルコード

.NET Android(.NET MAUI for Android)でMVVMを採用すると、UIとロジックの責務分離・テスト容易性・再利用性が一気に高まります。本記事は、CommunityToolkit.Mvvmのソースジェネレーターを活用し、DI・ナビゲーション・検証・非同期・キャンセル・メッセンジャーまで含めた「すぐ動く」具体例で、現場投入レベルの実装指針を一気通貫で解説します。

目次

.NET AndroidでMVVMを採用する目的と要点

Android向けの.NET MAUIアプリでは、MVVM(Model–View–ViewModel)により画面(View)と状態・振る舞い(ViewModel)を分離し、ビジネスルール(Model/Service)を独立させます。これにより、UI改修に強く、ユニットテストが容易で、機能拡張のスピードと品質が両立します。以下の実装原則を軸に進めます。

  • 画面単位で専用のViewModelを用意(単一責務)
  • ViewModelは ObservableObject 基底(または INotifyPropertyChanged 実装)
  • XAMLのデータバインディングを「ページの BindingContext」に対して記述
  • ボタンクリック等のイベントは ICommand(例:RelayCommand)へ集約
  • Modelとの変換はViewModelにまとめるか、専用Mapper/Serviceを挟む
  • DI(依存性注入)でViewModel/Serviceのライフサイクルを管理

最小プロジェクト構成(提案)

MyMauiApp/
 ├─ App.xaml
 ├─ App.xaml.cs
 ├─ AppShell.xaml
 ├─ AppShell.xaml.cs
 ├─ MauiProgram.cs
 ├─ Models/
 │   ├─ User.cs
 │   └─ Note.cs
 ├─ Services/
 │   ├─ IAuthService.cs
 │   ├─ AuthService.cs
 │   ├─ INoteService.cs
 │   ├─ NoteService.cs
 │   └─ IMapper.cs (任意: 複雑な変換が多い場合)
 ├─ ViewModels/
 │   ├─ LoginViewModel.cs
 │   ├─ NotesViewModel.cs
 │   └─ NoteDetailViewModel.cs
 ├─ Views/
 │   ├─ LoginPage.xaml(.cs)
 │   ├─ NotesPage.xaml(.cs)
 │   └─ NoteDetailPage.xaml(.cs)
 └─ Converters/
     └─ InverseBoolConverter.cs

セットアップ:CommunityToolkit.Mvvm と DI 登録

ボイラープレートを減らすため、MVVM Community Toolkitを採用します。プロパティやコマンドはソースジェネレーターで自動生成され、記述量とミスが激減します。DIでサービスとViewModelを登録し、ページ側でコンストラクターインジェクションを使います。

MauiProgram.cs(DI・Toolkit・ページ登録)

using CommunityToolkit.Maui;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.Extensions.Logging;

namespace MyMauiApp;

public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();


    builder
        .UseMauiApp<App>()
        .UseMauiCommunityToolkit(); // UI系(トースト等)も利用可能

    // Services
    builder.Services.AddSingleton<IAuthService, AuthService>();
    builder.Services.AddSingleton<INoteService, NoteService>();
    builder.Services.AddSingleton<IMessenger>(WeakReferenceMessenger.Default);

    // ViewModels
    builder.Services.AddTransient<LoginViewModel>();
    builder.Services.AddTransient<NotesViewModel>();
    builder.Services.AddTransient<NoteDetailViewModel>();

    // Views
    builder.Services.AddTransient<LoginPage>();
    builder.Services.AddTransient<NotesPage>();
    builder.Services.AddTransient<NoteDetailPage>();


#if DEBUG
builder.Logging.AddDebug();
#endif
return builder.Build();
}
} 

AppShell と ルート登録

<Shell
    x:Class="MyMauiApp.AppShell"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml">


<ShellContent Route="LoginPage" ContentTemplate="{DataTemplate views:LoginPage}" />
<ShellContent Route="NotesPage" ContentTemplate="{DataTemplate views:NotesPage}" />
<ShellContent Route="NoteDetailPage" ContentTemplate="{DataTemplate views:NoteDetailPage}" />


 
public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();
        Routing.RegisterRoute(nameof(LoginPage), typeof(LoginPage));
        Routing.RegisterRoute(nameof(NotesPage), typeof(NotesPage));
        Routing.RegisterRoute(nameof(NoteDetailPage), typeof(NoteDetailPage));
    }
}

ModelとServiceの最小実装

// Models/User.cs
namespace MyMauiApp.Models;
public sealed class User
{
    public string Id { get; init; } = string.Empty;
    public string Name { get; init; } = string.Empty;
}

// Models/Note.cs
namespace MyMauiApp.Models;
public sealed class Note
{
public string Id { get; init; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
} 
// Services/IAuthService.cs
using System.Threading;

public interface IAuthService
{
    Task&lt;User?&gt; SignInAsync(string userName, string password, CancellationToken ct);
    Task SignOutAsync();
    bool IsAuthenticated { get; }
}

// Services/AuthService.cs(デモ用: 実運用ではAPI呼び出し)
public sealed class AuthService : IAuthService
{
    private User? _current;
    public bool IsAuthenticated =&gt; _current is not null;

    public async Task&lt;User?&gt; SignInAsync(string userName, string password, CancellationToken ct)
    {
        await Task.Delay(500, ct); // 疑似IO
        if (string.IsNullOrWhiteSpace(userName) || password != "pass") return null;

        _current = new User { Id = Guid.NewGuid().ToString(), Name = userName };
        return _current;
    }

    public Task SignOutAsync()
    {
        _current = null;
        return Task.CompletedTask;
    }
}
// Services/INoteService.cs
public interface INoteService
{
    Task&lt;IReadOnlyList&lt;Note&gt;&gt; GetNotesAsync(CancellationToken ct);
    Task&lt;Note?&gt; GetNoteAsync(string id, CancellationToken ct);
    Task&lt;Note&gt; UpsertAsync(Note note, CancellationToken ct);
    Task DeleteAsync(string id, CancellationToken ct);
}

// Services/NoteService.cs(デモ: メモリ内)
public sealed class NoteService : INoteService
{
    private readonly Dictionary&lt;string, Note&gt; _store = new();

    public Task&lt;IReadOnlyList&lt;Note&gt;&gt; GetNotesAsync(CancellationToken ct)
        =&gt; Task.FromResult&lt;IReadOnlyList&lt;Note&gt;&gt;(_store.Values.OrderByDescending(n =&gt; n.UpdatedAt).ToList());

    public Task&lt;Note?&gt; GetNoteAsync(string id, CancellationToken ct)
        =&gt; Task.FromResult(_store.TryGetValue(id, out var n) ? n : null);

    public Task&lt;Note&gt; UpsertAsync(Note note, CancellationToken ct)
    {
        if (string.IsNullOrWhiteSpace(note.Id))
            note = note with { Id = Guid.NewGuid().ToString(), UpdatedAt = DateTimeOffset.UtcNow };
        else
            note.UpdatedAt = DateTimeOffset.UtcNow;

        _store[note.Id] = note;
        return Task.FromResult(note);
    }

    public Task DeleteAsync(string id, CancellationToken ct)
    {
        _store.Remove(id);
        return Task.CompletedTask;
    }
}

ViewModel:ObservableProperty と RelayCommand

Toolkitの属性を使うと、INotifyPropertyChangedやICommandの実装を自動生成できます。以下はログインの典型例です。

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using MyMauiApp.Models;

public partial class LoginViewModel : ObservableObject
{
private readonly IAuthService _auth;
private readonly IMessenger _messenger;


public LoginViewModel(IAuthService auth, IMessenger messenger)
{
    _auth = auth;
    _messenger = messenger;
}

[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SignInCommand))]
private string userName = string.Empty;

[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SignInCommand))]
private string password = string.Empty;

[ObservableProperty]
private bool isBusy;

[ObservableProperty]
private string? errorMessage;

private bool CanSignIn() =>
    !IsBusy && !string.IsNullOrWhiteSpace(UserName) && !string.IsNullOrWhiteSpace(Password);

[RelayCommand(CanExecute = nameof(CanSignIn))]
private async Task SignInAsync()
{
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
    try
    {
        ErrorMessage = null;
        IsBusy = true;

        var user = await _auth.SignInAsync(UserName.Trim(), Password, cts.Token);
        if (user is null)
        {
            ErrorMessage = "ユーザー名またはパスワードが正しくありません。";
            return;
        }

        // 成功時はメッセージ発行(ページ側で購読して画面遷移も可)
        _messenger.Send(new SignedInMessage(user));

        // Shellナビゲーションに直接進む場合
        await Shell.Current.GoToAsync("//NotesPage");
    }
    catch (OperationCanceledException)
    {
        ErrorMessage = "サインインがタイムアウトしました。";
    }
    catch (Exception ex)
    {
        ErrorMessage = $"サインインに失敗しました: {ex.Message}";
    }
    finally
    {
        IsBusy = false;
    }
}


}

public sealed record SignedInMessage(User User); 

部分メソッドで変更検知をフック

[ObservableProperty]には自動生成される部分メソッド(例:OnUserNameChanged)があり、即時検証やサジェストに利用できます。

partial void OnUserNameChanged(string value)
{
    if (string.IsNullOrWhiteSpace(value))
        ErrorMessage = "ユーザー名を入力してください。";
    else
        ErrorMessage = null;
}

View:XAMLバインディング(コンパイル済みバインディング推奨)

バインディングの型安全性とパフォーマンス向上のため、x:DataTypeでコンパイル済みバインディングを有効化します。DIでViewModelを受け取り、BindingContextに設定します。

LoginPage.xaml

<ContentPage
    x:Class="MyMauiApp.Views.LoginPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:vm="clr-namespace:MyMauiApp"
    x:DataType="vm:LoginViewModel">


<VerticalStackLayout Padding="24" Spacing="12">
    <Label Text="サインイン" FontSize="24" />

    <Entry Placeholder="ユーザー名" Text="{Binding UserName}" />
    <Entry Placeholder="パスワード" Text="{Binding Password}" IsPassword="True" />

    <Button Text="サインイン" Command="{Binding SignInCommand}" />

    <ActivityIndicator IsRunning="{Binding IsBusy}" IsVisible="{Binding IsBusy}" />
    <Label Text="{Binding ErrorMessage}" TextColor="Red" />
</VerticalStackLayout>


 

LoginPage.xaml.cs(コンストラクターDI)

using MyMauiApp;

namespace MyMauiApp.Views;

public partial class LoginPage : ContentPage
{
public LoginPage(LoginViewModel vm)
{
InitializeComponent();
BindingContext = vm;
}
} 

一覧と詳細のViewModelと画面

using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MyMauiApp.Models;

public partial class NotesViewModel : ObservableObject
{
private readonly INoteService _service;


public NotesViewModel(INoteService service)
{
    _service = service;
}

public ObservableCollection<Note> Items { get; } = new();

[ObservableProperty] private bool isBusy;
[ObservableProperty] private string? errorMessage;

[RelayCommand]
private async Task LoadAsync()
{
    try
    {
        IsBusy = true;
        Items.Clear();
        var notes = await _service.GetNotesAsync(CancellationToken.None);
        foreach (var n in notes) Items.Add(n);
    }
    catch (Exception ex)
    {
        ErrorMessage = $"読み込みに失敗しました: {ex.Message}";
    }
    finally
    {
        IsBusy = false;
    }
}

[RelayCommand]
private Task GoToDetailAsync(Note note)
    => Shell.Current.GoToAsync($"{nameof(NoteDetailPage)}?id={note.Id}");


} 
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MyMauiApp.Models;

[QueryProperty(nameof(Id), "id")]
public partial class NoteDetailViewModel : ObservableObject
{
    private readonly INoteService _service;

    public NoteDetailViewModel(INoteService service) =&gt; _service = service;

    [ObservableProperty] private string id = string.Empty;
    [ObservableProperty] private string title = string.Empty;
    [ObservableProperty] private string content = string.Empty;
    [ObservableProperty] private bool isBusy;
    [ObservableProperty] private string? errorMessage;

    [RelayCommand]
    private async Task AppearingAsync()
    {
        if (string.IsNullOrWhiteSpace(Id)) return;
        try
        {
            IsBusy = true;
            var note = await _service.GetNoteAsync(Id, CancellationToken.None);
            if (note is null) { ErrorMessage = "ノートが見つかりません。"; return; }
            Title = note.Title; Content = note.Content;
        }
        catch (Exception ex)
        {
            ErrorMessage = $"読み込みに失敗しました: {ex.Message}";
        }
        finally { IsBusy = false; }
    }

    [RelayCommand]
    private async Task SaveAsync()
    {
        try
        {
            IsBusy = true;
            var note = new Note { Id = Id, Title = Title, Content = Content };
            var saved = await _service.UpsertAsync(note, CancellationToken.None);
            Id = saved.Id;
            await Shell.Current.DisplayAlert("保存", "ノートを保存しました。", "OK");
        }
        catch (Exception ex)
        {
            ErrorMessage = $"保存に失敗しました: {ex.Message}";
        }
        finally { IsBusy = false; }
    }
}

NotesPage.xaml(ListView/CollectionViewの例)

<ContentPage
    x:Class="MyMauiApp.Views.NotesPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:vm="clr-namespace:MyMauiApp"
    x:DataType="vm:NotesViewModel">


<VerticalStackLayout Padding="16" Spacing="8">
    <Button Text="更新" Command="{Binding LoadCommand}" />

    <CollectionView ItemsSource="{Binding Items}" SelectionMode="None">
        <CollectionView.ItemTemplate>
            <DataTemplate x:DataType="models:Note">
                <Grid ColumnDefinitions="2*,3*,Auto" Padding="8">
                    <Label Grid.Column="0" Text="{Binding Title}" FontAttributes="Bold" />
                    <Label Grid.Column="1" Text="{Binding UpdatedAt}" />
                    <Button Grid.Column="2" Text="開く"
                            Command="{Binding Source={RelativeSource AncestorType={x:Type vm:NotesViewModel}}, Path=GoToDetailCommand}"
                            CommandParameter="{Binding .}"/>
                </Grid>
            </DataTemplate>
        </CollectionView.ItemTemplate>
    </CollectionView>

    <ActivityIndicator IsRunning="{Binding IsBusy}" IsVisible="{Binding IsBusy}" />
    <Label Text="{Binding ErrorMessage}" TextColor="Red" />
</VerticalStackLayout>


 

NoteDetailPage.xaml(保存ボタン)

<ContentPage
    x:Class="MyMauiApp.Views.NoteDetailPage"
    xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
    xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    xmlns:vm="clr-namespace:MyMauiApp"
    x:DataType="vm:NoteDetailViewModel">


<VerticalStackLayout Padding="16" Spacing="8">
    <Entry Placeholder="タイトル" Text="{Binding Title}" />
    <Editor Placeholder="本文" Text="{Binding Content}" AutoSize="TextChanges"/>
    <Button Text="保存" Command="{Binding SaveCommand}" />
    <ActivityIndicator IsRunning="{Binding IsBusy}" IsVisible="{Binding IsBusy}" />
    <Label Text="{Binding ErrorMessage}" TextColor="Red" />
</VerticalStackLayout>


 

Mapper/変換の考え方

API DTOとドメインModelの相互変換が多い場合、ViewModelに肥大化した変換ロジックを持たせるのは避け、専用のMapper/Serviceに委譲します。

public interface IMapper<TIn, TOut>
{
    TOut Map(TIn input);
}

public sealed class NoteDtoToModel : IMapper
{
public Note Map(NoteDto dto) => new() { Id = dto.Id, Title = dto.Title, Content = dto.Body, UpdatedAt = dto.UpdatedAt };
} 

入力検証(軽量例)

シンプルな検証は部分メソッドやCanExecuteで十分です。より厳密には INotifyDataErrorInfo をViewModelに実装し、XAMLのVisualStateManagerと組み合わせます。

public partial class LoginViewModel : ObservableObject, INotifyDataErrorInfo
{
    private readonly Dictionary<string, List<string>> _errors = new();


public bool HasErrors => _errors.Count > 0;
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;

public IEnumerable GetErrors(string? propertyName)
    => propertyName is null ? Enumerable.Empty<string>()
                            : (_errors.TryGetValue(propertyName, out var errs) ? errs : Enumerable.Empty<string>());

partial void OnUserNameChanged(string value)
{
    SetErrors(nameof(UserName), string.IsNullOrWhiteSpace(value) ? new() { "ユーザー名は必須です。" } : new());
}

private void SetErrors(string property, List<string> messages)
{
    if (messages.Count == 0) _errors.Remove(property);
    else _errors[property] = messages;
    ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(property));
}


} 

非同期・キャンセル・再試行の設計

  • 長時間処理はCancellationTokenを受け付ける
  • ネットワークはタイムアウトと再試行方針(指数バックオフ等)を決める
  • UI状態はIsBusy、ErrorMessage、IsEmpty等のフラグで管理
[RelayCommand]
private async Task RetryableLoadAsync()
{
    var retries = 0;
    Exception? last = null;
    using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));


while (retries < 3)
{
    try
    {
        await LoadAsync();
        return;
    }
    catch (Exception ex)
    {
        last = ex; retries++;
        await Task.Delay(300 * retries, cts.Token); // 簡易バックオフ
    }
}
ErrorMessage = $"再試行に失敗しました: {last?.Message}";


} 

メッセンジャーで画面間通信

ToolkitのWeakReferenceMessengerで疎結合にイベント連携します。ログイン完了後の「データ再読み込み」などに便利です。

public sealed class NotesPage : ContentPage
{
    private readonly NotesViewModel _vm;
    private readonly IMessenger _messenger;


public NotesPage(NotesViewModel vm, IMessenger messenger)
{
    InitializeComponent();
    BindingContext = _vm = vm;
    _messenger = messenger;

    _messenger.Register<SignedInMessage>(this, async (_, msg) => await _vm.LoadAsync());
}

protected override async void OnAppearing()
{
    base.OnAppearing();
    await _vm.LoadAsync();
}


} 

Android依存の機能をMVVMで扱う

生体認証や共有ストレージアクセスなど、プラットフォーム依存は抽象化してServiceに閉じ込めます。UIはViewModel経由で呼び出すだけにします。

public interface IFingerprintService
{
    Task&lt;bool&gt; AuthenticateAsync(string reason, CancellationToken ct);
}
// Platforms/Android/FingerprintService.cs(概念例)
public sealed class FingerprintService : IFingerprintService
{
    public Task&lt;bool&gt; AuthenticateAsync(string reason, CancellationToken ct)
    {
        // AndroidX BiometricPrompt のラッパーを実装(省略)
        return Task.FromResult(true);
    }
}
// DI登録(MauiProgram.cs)
builder.Services.AddSingleton&lt;IFingerprintService, FingerprintService&gt;();

ValueConverter・表示のちょい足し

public sealed class InverseBoolConverter : IValueConverter
{
    public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
        =&gt; value is bool b ? !b : value;
    public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
        =&gt; value is bool b ? !b : value;
}
<ContentPage.Resources>
    <ResourceDictionary>
        <converters:InverseBoolConverter x:Key="InverseBool" />
    </ResourceDictionary>
</ContentPage.Resources>

 

ユニットテスト(ViewModel単体)

UIに依存しないViewModelは純粋にテストできます。以下はxUnitを想定した最小例です。

public sealed class FakeAuthService : IAuthService
{
    public bool IsAuthenticated { get; private set; }
    public Task<User?> SignInAsync(string userName, string password, CancellationToken ct)
        => Task.FromResult<User?>(password == "pass" ? new User { Id = "1", Name = userName } : null);
    public Task SignOutAsync() { IsAuthenticated = false; return Task.CompletedTask; }
}

public sealed class NullMessenger : IMessenger
{
public void Register(object recipient, MessageHandler handler) where TMessage : class { }
public void Send(TMessage message) where TMessage : class { }
// 実務では WeakReferenceMessenger.Default を利用
}

public class LoginViewModelTests
{
[Fact]
public async Task 正しいパスワードならサインインできる()
{
var vm = new LoginViewModel(new FakeAuthService(), new NullMessenger())
{
UserName = "alice",
Password = "pass"
};


    await vm.SignInCommand.ExecuteAsync(null);
    Assert.Null(vm.ErrorMessage);
}

[Fact]
public async Task 間違いならエラーメッセージ()
{
    var vm = new LoginViewModel(new FakeAuthService(), new NullMessenger())
    {
        UserName = "alice",
        Password = "wrong"
    };

    await vm.SignInCommand.ExecuteAsync(null);
    Assert.Equal("ユーザー名またはパスワードが正しくありません。", vm.ErrorMessage);
}


} 

DIライフタイムと登録の指針

ライフタイム用途注意点
Singleton設定、キャッシュ、共有ストア、メッセンジャーアプリ寿命と同じ。状態を持つならスレッド安全性に注意。
Transientページ/VMなど短命の依存、軽量サービス都度生成。大きな依存グラフはGC負荷に留意。
Scoped手動スコープで短期のまとまり処理MAUIではリクエスト境界がないため、スコープの明示的運用が必要。

MVVM・MVU・コードビハインドの比較(ざっくり)

手法メリットデメリット向き
MVVMUIとロジック分離、テスト容易、XAML資産を活用学習コスト、Bindingの間接性画面多・業務アプリ
MVU状態遷移が明確、単方向データフローXAML資産が使いにくい場合、学習コストリアクティブに強いチーム
コードビハインド学習容易、プロトタイピング迅速可読性/保守性課題、テスト困難超小規模・検証用途

パフォーマンス・保守性の実践Tips

  • コンパイル済みバインディング(x:DataType)で早期エラー検出と速度向上
  • ObservableCollectionを乱用せず、差分更新や仮想化(CollectionViewの再利用)を活用
  • OnPropertyChangedの発火回数を最小化(SetPropertyや複合プロパティの設計)
  • 画像や大きなデータはキャッシュ層(Service/Repository)で節約
  • 非UIスレッド処理+UIスレッド戻し(MainThread.BeginInvokeOnMainThread)を厳密に
  • イベント購読は解除(またはWeakパターン)。Toolkitのメッセンジャーは弱参照で安全側。
  • ページ遷移の引数はクエリ文字列またはIMessengerで強結合を避ける

「小さく始めて大きく育てる」フォルダー設計

層役割中〜大規模での分割例
Views画面定義(XAML)ページ単位フォルダー、共通テンプレート
ViewModels画面の状態/振る舞い機能モジュールごとに名前空間を分ける
Modelsドメイン/DTOAPI DTOとドメインを分離、Mapper層挿入
Services永続化/通信/OS連携インターフェースで抽象化しモック容易に
ConvertersUI変換共通変換はライブラリ化

よくある落とし穴と回避策

  • Bindingが動かない:DataContext(BindingContext)/プロパティ名/可視化(IsVisible)が正しいか、出力ウィンドウのBindingエラーを確認。
  • コマンドが無効:CanExecute変更時にNotifyCanExecuteChangedForを付与する、または手動でNotifyCanExecuteChanged。
  • 画面遷移が重い:ページ・ViewModelはTransientで、重いサービスはSingleton。遷移前に必要データを先読み。
  • 検証と保存のタイミング:ローカル検証→保存→サーバー検証の順。エラーをErrorMessageに集約しUIへバインド。
  • Android権限:プラットフォーム依存はServiceに隔離し、権限要求を一箇所に集約。

完成度を上げるための追加アイデア

  • アプリ起動時にIAuthServiceでトークン再取得→ログイン済みなら直接NotesPageへ
  • Pull-to-RefreshでLoadCommandと連携
  • ダーク/ライトテーマの切替状態をViewModelで保持しAppThemeBindingと連動
  • クラッシュレポート/トレースの抽象化(ITelemetryService)
  • リソース文字列のローカライズ(Resx+OnCultureChanged)

まとめ:.NET AndroidでMVVMをクリーンに

本記事では、.NET MAUI for Androidを前提に、MVVM Community Toolkit・DI・Shellナビゲーション・検証・非同期・メッセンジャー・プラットフォーム連携までを実装レベルで通し解説しました。実案件では、ここにキャッシュ/トークン更新/API例外設計/ログ設計/アクセシビリティを積み増すと、運用現場の要件を幅広く満たせます。まずは最小構成から始め、ViewModelの単一責務と疎結合の徹底を合言葉に、保守性の高いAndroidアプリを育てていきましょう。

付録:冒頭の簡易コード例(再掲+α)

// LoginViewModel(Toolkit)
public partial class LoginViewModel : ObservableObject
{
    [ObservableProperty] private string userName;
    [ObservableProperty] private string password;


[RelayCommand]
private async Task SignInAsync()
{
    // 認証ロジック(本編のAuthService参照)
    await Task.CompletedTask;
}


} 
&lt;ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             x:Class="App.Views.LoginPage"&gt;
  &lt;VerticalStackLayout&gt;
    &lt;Entry Text="{Binding UserName}" Placeholder="ユーザー名" /&gt;
    &lt;Entry Text="{Binding Password}" Placeholder="パスワード" IsPassword="True" /&gt;
    &lt;Button Text="サインイン" Command="{Binding SignInCommand}" /&gt;
  &lt;/VerticalStackLayout&gt;
&lt;/ContentPage&gt;
public partial class LoginPage : ContentPage
{
    public LoginPage()
    {
        InitializeComponent();
        BindingContext = new LoginViewModel();
    }
}

チェックリスト(配布用)

  • 各ページに専用ViewModelがある
  • ViewModelはObservableObject基底、プロパティは[ObservableProperty]
  • イベントは[RelayCommand]に集約、CanExecute定義済み
  • Bindingはx:DataTypeを指定して型安全化
  • サービス層はインターフェースで抽象化、DI登録済み
  • エラーとローディングは状態プロパティに集約
  • 非同期はCancellationToken対応、タイムアウトと再試行を設計
  • 画面遷移はShellルート+クエリで疎結合
  • メッセンジャーは弱参照でメモリリーク回避
  • プラットフォーム依存機能はServiceに閉じ込め

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次