.NET MAUIでViewModelからOnAppearing相当を実行する完全ガイド|EventToCommandBehaviorと基底クラスの比較と実装

「ページが表示されたタイミングで DisplayAlert を出したい。でも MVVM を崩したくない」。.NET MAUI で最初に必ずぶつかるこの課題に対して、ViewModel 側から OnAppearing 相当の処理を安全・テスト容易・再利用性高く実行するための実装パターンを、実運用で使えるコードと設計判断の観点込みで徹底解説します。

目次

.NET MAUI で ViewModel から「画面表示直後の処理」を走らせるには

.NET MAUI では ContentPage に Appearing/Disappearing イベントがありますが、ViewModel には OnAppearing が存在しません。MVVM を守るなら、Page のコードビハインドにロジックを直接書かず、ViewModel のコマンドに委譲するのが基本方針です。本記事では次の2案を中心に解説します。

アプローチ概要MVVM 純度導入コストテスト容易性主なメリット注意点
A. CommunityToolkit の EventToCommandBehaviorXAML で 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() =&gt; 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) =&gt; _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&lt;App&gt;()
       .UseMauiCommunityToolkit();
builder.Services.AddSingleton&lt;IDialogService, DialogService&gt;();
builder.Services.AddTransient&lt;SamplePage&gt;();
builder.Services.AddTransient&lt;SampleViewModel&gt;();
<!-- 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&lt;Task&gt; _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() =&gt; _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(() =&gt; IsBusy = false);
    }

    private sealed class ActionOnDispose : IDisposable
    {
        private readonly Action _dispose;
        public ActionOnDispose(Action dispose) =&gt; _dispose = dispose;
        public void Dispose() =&gt; _dispose();
    }
}
// IDialogService / DialogService / AsyncOnce は前述の通り

「イベント × コマンド」設計の評価観点

観点A. EventToCommandB. 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() => ...; 

この記事を書いた人

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

コメント

コメントする

目次