.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<User?> 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 => _current is not null;
public async Task<User?> 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<IReadOnlyList<Note>> GetNotesAsync(CancellationToken ct);
Task<Note?> GetNoteAsync(string id, CancellationToken ct);
Task<Note> UpsertAsync(Note note, CancellationToken ct);
Task DeleteAsync(string id, CancellationToken ct);
}
// Services/NoteService.cs(デモ: メモリ内)
public sealed class NoteService : INoteService
{
private readonly Dictionary<string, Note> _store = new();
public Task<IReadOnlyList<Note>> GetNotesAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<Note>>(_store.Values.OrderByDescending(n => n.UpdatedAt).ToList());
public Task<Note?> GetNoteAsync(string id, CancellationToken ct)
=> Task.FromResult(_store.TryGetValue(id, out var n) ? n : null);
public Task<Note> 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) => _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<bool> AuthenticateAsync(string reason, CancellationToken ct);
}
// Platforms/Android/FingerprintService.cs(概念例)
public sealed class FingerprintService : IFingerprintService
{
public Task<bool> AuthenticateAsync(string reason, CancellationToken ct)
{
// AndroidX BiometricPrompt のラッパーを実装(省略)
return Task.FromResult(true);
}
}
// DI登録(MauiProgram.cs)
builder.Services.AddSingleton<IFingerprintService, FingerprintService>();
ValueConverter・表示のちょい足し
public sealed class InverseBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is bool b ? !b : value;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> 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・コードビハインドの比較(ざっくり)
| 手法 | メリット | デメリット | 向き |
|---|---|---|---|
| MVVM | UIとロジック分離、テスト容易、XAML資産を活用 | 学習コスト、Bindingの間接性 | 画面多・業務アプリ |
| MVU | 状態遷移が明確、単方向データフロー | XAML資産が使いにくい場合、学習コスト | リアクティブに強いチーム |
| コードビハインド | 学習容易、プロトタイピング迅速 | 可読性/保守性課題、テスト困難 | 超小規模・検証用途 |
パフォーマンス・保守性の実践Tips
- コンパイル済みバインディング(
x:DataType)で早期エラー検出と速度向上 ObservableCollectionを乱用せず、差分更新や仮想化(CollectionViewの再利用)を活用OnPropertyChangedの発火回数を最小化(SetPropertyや複合プロパティの設計)- 画像や大きなデータはキャッシュ層(Service/Repository)で節約
- 非UIスレッド処理+UIスレッド戻し(
MainThread.BeginInvokeOnMainThread)を厳密に - イベント購読は解除(またはWeakパターン)。Toolkitのメッセンジャーは弱参照で安全側。
- ページ遷移の引数はクエリ文字列または
IMessengerで強結合を避ける
「小さく始めて大きく育てる」フォルダー設計
| 層 | 役割 | 中〜大規模での分割例 |
|---|---|---|
| Views | 画面定義(XAML) | ページ単位フォルダー、共通テンプレート |
| ViewModels | 画面の状態/振る舞い | 機能モジュールごとに名前空間を分ける |
| Models | ドメイン/DTO | API DTOとドメインを分離、Mapper層挿入 |
| Services | 永続化/通信/OS連携 | インターフェースで抽象化しモック容易に |
| Converters | UI変換 | 共通変換はライブラリ化 |
よくある落とし穴と回避策
- 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;
}
}
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
x:Class="App.Views.LoginPage">
<VerticalStackLayout>
<Entry Text="{Binding UserName}" Placeholder="ユーザー名" />
<Entry Text="{Binding Password}" Placeholder="パスワード" IsPassword="True" />
<Button Text="サインイン" Command="{Binding SignInCommand}" />
</VerticalStackLayout>
</ContentPage>
public partial class LoginPage : ContentPage
{
public LoginPage()
{
InitializeComponent();
BindingContext = new LoginViewModel();
}
}
チェックリスト(配布用)
- 各ページに専用ViewModelがある
- ViewModelは
ObservableObject基底、プロパティは[ObservableProperty] - イベントは
[RelayCommand]に集約、CanExecute定義済み - Bindingは
x:DataTypeを指定して型安全化 - サービス層はインターフェースで抽象化、DI登録済み
- エラーとローディングは状態プロパティに集約
- 非同期は
CancellationToken対応、タイムアウトと再試行を設計 - 画面遷移はShellルート+クエリで疎結合
- メッセンジャーは弱参照でメモリリーク回避
- プラットフォーム依存機能はServiceに閉じ込め

コメント