「ページが表示されたタイミングで DisplayAlert を出したい。でも MVVM を崩したくない」。.NET MAUI で最初に必ずぶつかるこの課題に対して、ViewModel 側から OnAppearing 相当の処理を安全・テスト容易・再利用性高く実行するための実装パターンを、実運用で使えるコードと設計判断の観点込みで徹底解説します。
.NET MAUI で ViewModel から「画面表示直後の処理」を走らせるには
.NET MAUI では ContentPage に Appearing/Disappearing イベントがありますが、ViewModel には OnAppearing が存在しません。MVVM を守るなら、Page のコードビハインドにロジックを直接書かず、ViewModel のコマンドに委譲するのが基本方針です。本記事では次の2案を中心に解説します。
| アプローチ | 概要 | MVVM 純度 | 導入コスト | テスト容易性 | 主なメリット | 注意点 |
|---|---|---|---|---|---|---|
A. CommunityToolkit の EventToCommandBehavior | XAML で Appearing/Disappearing を ViewModel コマンドにバインド | 高い(コードビハインド不要) | 中(Toolkit 追加が必要) | 高い(DI したサービスで UI 呼出を抽象化) | 最小改修、再利用容易、学習コスト低 | Toolkit への依存を受け入れる設計判断が必要 |
| B. 共通基底クラス(BasePage/BaseViewModel) | BasePage のイベントを VM の仮想メソッドに委譲 | 中(コードビハインドはあるが集約) | 低(追加ライブラリ不要) | 高い | 外部依存ゼロ、既存プロジェクトへ入れやすい | Page 継承制約、複数継承不可ゆえの設計配慮が必要 |
ゴールの明確化
- 画面が表示された直後に
DisplayAlertを出す。 - Page のコードビハインドにアプリ固有のロジックを書かない(MVVM 準拠)。
- ユニットテスト可能にするため、ViewModel から直接 UI API を叩かず、ダイアログサービス経由で呼ぶ。
準備(共通)
CommunityToolkit.MVVM の利用(推奨)
コマンドやプロパティ変更通知のボイラープレートを削減します。
dotnet add package CommunityToolkit.Mvvm
また、アプローチ A を採用する場合は MAUI Toolkit 自体も追加します。
dotnet add package CommunityToolkit.Maui
// MauiProgram.cs
using CommunityToolkit.Maui;
using Microsoft.Extensions.DependencyInjection;
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp()
.UseMauiCommunityToolkit(); // A案で必要
// DI 登録(共通)
builder.Services.AddSingleton<IDialogService, DialogService>();
builder.Services.AddTransient<SamplePage>();
builder.Services.AddTransient<SampleViewModel>();
return builder.Build();
}
}
ダイアログサービス(UI 呼び出しの抽象化)
ViewModel から DisplayAlert を直接呼ばず、サービス越しに呼出します。UI スレッド切り替えもサービス側で吸収します。
// IDialogService.cs
public interface IDialogService
{
Task AlertAsync(string title, string message, string cancel = "OK");
Task<bool> ConfirmAsync(string title, string message, string accept = "OK", string cancel = "Cancel");
}
// DialogService.cs
public sealed class DialogService : IDialogService
{
public Task AlertAsync(string title, string message, string cancel = "OK")
=> MainThread.InvokeOnMainThreadAsync(() =>
(Application.Current?.MainPage?.DisplayAlert(title, message, cancel)) ?? Task.CompletedTask);
public Task<bool> ConfirmAsync(string title, string message, string accept = "OK", string cancel = "Cancel")
=> MainThread.InvokeOnMainThreadAsync(() =>
Application.Current?.MainPage?.DisplayAlert(title, message, accept, cancel)
?? Task.FromResult(false));
}
アプローチ A:CommunityToolkit の EventToCommandBehavior を使う
最も MVVM らしい手段です。XAML だけでイベントを ViewModel コマンドへ橋渡しできます。
XAML(ページ)
<ContentPage
xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:mct="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:vm="clr-namespace:YourApp.ViewModels"
x:Class="YourApp.Pages.SamplePage"
x:DataType="vm:SampleViewModel">
<ContentPage.Behaviors>
<mct:EventToCommandBehavior EventName="Appearing"
Command="{Binding AppearingCommand}" />
<mct:EventToCommandBehavior EventName="Disappearing"
Command="{Binding DisappearingCommand}" />
</ContentPage.Behaviors>
<Grid RowDefinitions="Auto,*" Padding="16">
<ActivityIndicator IsRunning="{Binding IsBusy}" IsVisible="{Binding IsBusy}" />
<Label Grid.Row="1" Text="{Binding Greeting}" FontSize="24" />
</Grid>
ViewModel(RelayCommand でコマンド生成)
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class SampleViewModel : ObservableObject
{
private readonly IDialogService _dialog;
private readonly Func _firstAppearing; // 初回だけ実行するラッパ
private CancellationTokenSource? _cts;
[ObservableProperty] private string greeting = "ようこそ";
[ObservableProperty] private bool isBusy;
public SampleViewModel(IDialogService dialog)
{
_dialog = dialog;
_firstAppearing = AsyncOnce.RunOnce(InitializeAsync);
}
// 画面表示直後に呼ばれる
[RelayCommand]
private async Task AppearingAsync()
{
// 長い処理の重複起動を防止し、キャンセル制御も行う
_cts?.Cancel();
_cts = new CancellationTokenSource();
try
{
await _firstAppearing(); // 初回のみ
await _dialog.AlertAsync("タイトル", "こんにちは", "OK");
await LoadDataAsync(_cts.Token); // 毎回または必要時
}
catch (OperationCanceledException) { /* NOOP */ }
}
// 画面が閉じる/非表示になる直前
[RelayCommand]
private void Disappearing()
{
_cts?.Cancel(); // 進行中の処理を中断
}
private async Task InitializeAsync()
{
using (BusyScope())
{
await Task.Delay(100); // 初期化など
}
}
private async Task LoadDataAsync(CancellationToken token)
{
using (BusyScope())
{
await Task.Delay(500, token); // データ読込など
Greeting = $"読み込み完了:{DateTime.Now:T}";
}
}
private IDisposable BusyScope()
{
IsBusy = true;
return new ActionOnDispose(() => IsBusy = false);
}
private sealed class ActionOnDispose : IDisposable
{
private readonly Action _dispose;
public ActionOnDispose(Action dispose) => _dispose = dispose;
public void Dispose() => _dispose();
}
}
初回だけ実行したい処理のためのヘルパー
public static class AsyncOnce
{
public static Func<Task> RunOnce(Func<Task> factory)
{
var gate = new SemaphoreSlim(1, 1);
var done = false;
return async () =>
{
if (done) return;
await gate.WaitAsync().ConfigureAwait(false);
try
{
if (!done)
{
await factory().ConfigureAwait(false);
done = true;
}
}
finally
{
gate.Release();
}
};
}
}
ポイント
- 完全 MVVM:Page には XAML のみ。ロジックは ViewModel に集約。
- テスト容易:
IDialogServiceをモックすればアラート表示を検証できる。 - UX 配慮:
CancellationTokenとIsBusyを組合せ、遷移乱打でも安全。
アプローチ B:共通基底クラスで OnAppearing() を委譲
外部依存を増やさずに導入できる実用的なパターンです。コードビハインドは発生しますが、BasePage に集約するため汚れが局所化されます。
基底 ViewModel
public abstract class BasePageViewModel : ObservableObject
{
public virtual Task OnAppearingAsync() => Task.CompletedTask;
public virtual void OnDisappearing() { }
}
基底 Page(Page 側でイベントを捕捉し VM に委譲)
public class BaseContentPage<TViewModel> : ContentPage where TViewModel : BasePageViewModel
{
protected TViewModel VM => (TViewModel)BindingContext;
public BaseContentPage(TViewModel vm)
{
BindingContext = vm;
Appearing += async (_, __) => await VM.OnAppearingAsync();
Disappearing += (_, __) => VM.OnDisappearing();
}
}
派生ページと ViewModel
// AddAppointmentPage.xaml.cs
public partial class AddAppointmentPage : BaseContentPage<AddAppointmentViewModel>
{
public AddAppointmentPage(AddAppointmentViewModel vm) : base(vm) => InitializeComponent();
}
// AddAppointmentViewModel.cs
public sealed class AddAppointmentViewModel : BasePageViewModel
{
private readonly IDialogService _dialog;
public AddAppointmentViewModel(IDialogService dialog) => _dialog = dialog;
public override async Task OnAppearingAsync()
{
await _dialog.AlertAsync("確認", "読み込みが完了しました。", "OK");
}
}
ポイント
- ライブラリ非依存でシンプル。
- Page 継承の制約があるため、独自の BasePage を複数作らない設計に寄せると保守性が高い。
別解:Shell のナビゲーションイベントを橋渡しする
Shell.Navigated は画面遷移完了後に発火します。初回のみ処理したい場合や、パラメータを確実に受け取った後に動かしたい場合に有効です。
// AppShell.xaml.cs
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
Navigated += OnNavigated;
}
private async void OnNavigated(object? sender, ShellNavigatedEventArgs e)
{
if (Current?.CurrentPage?.BindingContext is INavigatedAware vm)
{
await vm.OnNavigatedToAsync(e);
}
}
}
public interface INavigatedAware
{
Task OnNavigatedToAsync(ShellNavigatedEventArgs e);
}
// ViewModel 側で実装
public partial class DetailViewModel : ObservableObject, INavigatedAware
{
private readonly IDialogService _dialog;
private bool _first;
public DetailViewModel(IDialogService dialog) => _dialog = dialog;
public async Task OnNavigatedToAsync(ShellNavigatedEventArgs e)
{
if (_first) return;
_first = true;
await _dialog.AlertAsync("詳細", "ナビゲーション完了後に実行", "OK");
}
}
実装スニペット早見表
| 目的 | 推奨スニペット | 備考 |
|---|---|---|
| 表示直後に一度だけ実行 | AsyncOnce.RunOnce(InitializeAsync) | 多重起動・競合の心配を消す |
| 表示のたびに軽処理 | Appearing/Disappearing コマンドで制御 | キャッシュ済みデータのリフレッシュなど |
| 重い処理のキャンセル | CancellationTokenSource を VM に保持 | Disappearing 時に Cancel() |
| Alert のテスト | IDialogService をモック・フェイク | UI 非依存のユニットテスト可 |
テスト容易性を高めるパターン
フェイク ダイアログサービス
public sealed class FakeDialogService : IDialogService
{
public List<(string title, string message)> Alerts { get; } = new();
public Task AlertAsync(string title, string message, string cancel = "OK")
{
Alerts.Add((title, message));
return Task.CompletedTask;
}
public Task<bool> ConfirmAsync(string title, string message, string accept = "OK", string cancel = "Cancel")
=> Task.FromResult(true);
}
このフェイクを ViewModel に注入して、AppearingAsync 実行後に Alerts が追加されているかを検証できます。
よくある落とし穴と対策
- Appearing は複数回発火:ページに戻ってきたときも呼ばれます。
AsyncOnceで初期化を一度きりにし、残りは軽い更新のみ行う。 - 重い処理で UI が固まる:必ず
await+ 非同期 API を使用。進行中はIsBusyでインジケータを表示。 - 戻る操作で中断される:
CancellationTokenを受け取るメソッド設計にしてDisappearingでキャンセル。 - UI スレッド例外:UI API 呼び出しは
MainThread.InvokeOnMainThreadAsync経由に統一。 - メモリリーク:イベント購読の登録/解除に注意。A 案は Behavior に閉じるため漏れにくい。B 案は BasePage で完結し、匿名ラムダでもページ破棄と共に解消される構成にする。
- パラメータ受け渡し:
IQueryAttributableを併用して遷移時のクエリを受け取り、Appearing側で利用する。
パフォーマンスと UX の実務指針
- 表示タイミングでネットワークを叩くなら、キャッシュ と 差分更新 を基本にする。
- プレースホルダ/スケルトンスクリーン を表示し、体感速度を上げる。
- 初回だけ重い同期(ユーザー初期化)を行い、以降はイベント駆動(プッシュ通知・メッセンジャー)で更新。
.NET 8 時代の記述最適化
[RelayCommand]で非同期コマンドを自動生成(AppearingAsync/Disappearing)。- 表示用の軽いプロパティは
[ObservableProperty]で宣言し、ボイラープレート削減。 - 非同期コマンドの多重実行防止が必要なら
RelayCommand(AllowConcurrentExecutions = false)相当のガードを自前で入れる。
サンプル全体像(A 案の最小構成)
プロジェクトに丸ごと貼れる形で最小構成をまとめます。
// MauiProgram.cs(抜粋)
builder.UseMauiApp<App>()
.UseMauiCommunityToolkit();
builder.Services.AddSingleton<IDialogService, DialogService>();
builder.Services.AddTransient<SamplePage>();
builder.Services.AddTransient<SampleViewModel>();
<!-- SamplePage.xaml -->
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:mct="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:vm="clr-namespace:YourApp.ViewModels"
x:Class="YourApp.Pages.SamplePage"
x:DataType="vm:SampleViewModel">
// SampleViewModel.cs
public partial class SampleViewModel : ObservableObject
{
private readonly IDialogService _dialog;
private readonly Func<Task> _firstAppearing;
private CancellationTokenSource? _cts;
[ObservableProperty] private string greeting = "ようこそ";
[ObservableProperty] private bool isBusy;
public SampleViewModel(IDialogService dialog)
{
_dialog = dialog;
_firstAppearing = AsyncOnce.RunOnce(InitializeAsync);
}
[RelayCommand]
private async Task AppearingAsync()
{
_cts?.Cancel();
_cts = new();
try
{
await _firstAppearing();
await _dialog.AlertAsync("タイトル", "こんにちは", "OK");
await LoadDataAsync(_cts.Token);
}
catch (OperationCanceledException) { }
}
[RelayCommand]
private void Disappearing() => _cts?.Cancel();
private async Task InitializeAsync()
{
using (BusyScope())
{
await Task.Delay(100);
}
}
private async Task LoadDataAsync(CancellationToken token)
{
using (BusyScope())
{
await Task.Delay(500, token);
Greeting = "表示直後の更新が完了しました。";
}
}
private IDisposable BusyScope()
{
IsBusy = true;
return new ActionOnDispose(() => IsBusy = false);
}
private sealed class ActionOnDispose : IDisposable
{
private readonly Action _dispose;
public ActionOnDispose(Action dispose) => _dispose = dispose;
public void Dispose() => _dispose();
}
}
// IDialogService / DialogService / AsyncOnce は前述の通り
「イベント × コマンド」設計の評価観点
| 観点 | A. EventToCommand | B. BasePage 委譲 | コメント |
|---|---|---|---|
| MVVM 準拠 | ◎(コードビハインド 0) | ◯(集約された最小のコードビハインド) | チームのコーディング規約に合わせて選ぶ |
| 外部依存 | CommunityToolkit に依存 | 依存なし | 既存ポリシーや監査方針に影響 |
| 保守・拡張 | Behavior で横断的に適用しやすい | BasePage に集約し拡張容易 | どちらも横展開は容易 |
| テスト | ◎(VM 単体で完結) | ◎(VM 単体で完結) | どちらもサービス注入で UI 非依存 |
Q&A(トラブルシューティング)
Q:DisplayAlert がときどき出ない/例外が出る
A:UI スレッド呼出を MainThread.InvokeOnMainThreadAsync に統一してください。ページ破棄直前はアラート表示がキャンセルされることがあるため、Disappearing 直後の表示開始は避けると安定します。
Q:戻る操作で非同期処理が走り続ける
A:CancellationTokenSource を VM に保持し、Disappearing で Cancel()。非同期メソッド側は必ずトークン対応に。
Q:Appearing で API を叩くと遷移が重い
A:初回だけ InitializeAsync、以降はキャッシュ更新に留める。重い処理は Task.Run ではなく await 対応 API に置換を。
まとめ
- 完全にコードビハインドを無くしたい場合は、A 案の
EventToCommandBehaviorが最も MVVM らしく安全。 - 外部依存を避けたい/既存プロジェクトへ最短で入れたいなら、B 案の
BasePage委譲パターンが現実解。 - どちらのパターンでも、ViewModel から直接 UI API を呼ばずサービス経由にすることで、テスト容易性・保守性・スレッド安全性が大きく向上します。
- 初回実行の一回性は
AsyncOnce、キャンセルはCancellationToken、体感性能はIsBusyとインジケータで底上げ。
付録:最小 DI 構成の全体像(一覧)
// MauiProgram.cs(要点)
builder.UseMauiCommunityToolkit(); // A 案のみ
builder.Services.AddSingleton<IDialogService, DialogService>();
builder.Services.AddTransient<SampleViewModel>();
builder.Services.AddTransient<SamplePage>();
// SamplePage.xaml(要点)
// ViewModel(要点)
[RelayCommand] Task AppearingAsync() => ...;
[RelayCommand] void Disappearing() => ...;

コメント