.NET MAUI ログインでクラッシュ?LoginViewModel追加時のShell.Current null・DIライフタイム・ポップアップ設計まで完全解説

Firebase 認証を使った .NET MAUI のログイン画面で「ViewModel を追加した途端にクラッシュ」——この症状は Shell 初期化や DI のライフタイム不一致が絡む“序盤の落とし穴”が原因で起きがちです。本記事では、原因を具体的に切り分け、再現→修正→安定運用までをコードと設計の両面から分かりやすく解説します。

目次

LoginViewModel を追加するとアプリが落ちる原因と全体像

現象の多くは以下の 3 点が複合して発生します。

  • Shell がまだ有効になっていない段階で、ViewModel のコンストラクターから Shell.Current.GoToAsync() を呼ぶ。
  • DI のライフタイム不一致(Singleton な Page が Transient な ViewModel を早期に解決)により、例外が Page の生成失敗へ波及。
  • ポップアップ制御をデリゲートで強制(required)し、未割り当ての瞬間に Invoke() が null 参照で例外。

よくある症状 ⇔ 直接原因 ⇔ 再現タイミング

症状直接原因再現タイミング
アプリ起動直後に落ちる/真っ白のまま終了Shell.Current が null のまま GoToAsyncLoginPage 解決(DI)直後、VM コンストラクター内
たまに起動できるが遷移が不定Shell 初期化と VM 処理の競合(タイミング依存)低スペック端末/デバッグビルドで顕著
ポップアップ表示時にクラッシュ未割り当てデリゲートの Invoke()バインド完了前にコマンド実行

結論:安全な“遷移トリガー”は Page ライフサイクルに寄せる

MVVM ではUI 操作(ページ遷移・ポップアップ表示)はできる限り View(Page)側に寄せるのが安全です。初期遷移は OnAppearing() などの表示が保証されたタイミングで判定・実行し、ViewModel は「状態(認証済みか)とコマンド(ログイン処理)」に専念させましょう。

最小の修正方針(3 ステップ)

  1. コンストラクターの GoToAsync を全面撤去(遷移は Page 側で)。
  2. Page と ViewModel のライフタイムを一旦そろえる(開発初期は両方 AddTransient が安定)。
  3. ポップアップは「状態でバインド」する設計へ(デリゲートはオプショナル+null セーフ)。

実装例:安定稼働のためのコードに差し替え

DI 登録(MauiProgram.cs)

// DI 登録(開発初期は統一して Transient が無難)
builder.Services.AddTransient<LoginPage>();
builder.Services.AddTransient<LoginPageViewModel>();

builder.Services.AddTransient();
builder.Services.AddTransient();

// ルート(Shell)構成を使うなら AppShell を Singleton で保持
builder.Services.AddSingleton(); 

LoginPage.xaml.cs(OnAppearing で初期遷移)

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


protected override async void OnAppearing()
{
    base.OnAppearing();

    // Shell と UI が揃った(表示直前〜直後)で判定・遷移
    if (BindingContext is LoginPageViewModel vm)
    {
        // 認証済みならメインへ
        if (vm.IsAuthenticated)
        {
            // null セーフに Current を参照
            var shell = Shell.Current;
            if (shell != null)
            {
                await shell.GoToAsync($"//{nameof(MainPage)}");
            }
        }
    }
}


} 

LoginPageViewModel.cs(状態と処理に専念)

public partial class LoginPageViewModel : ObservableObject
{
    private readonly FirebaseAuthClient _auth;


public LoginPageViewModel(FirebaseAuthClient auth /* 他の依存もここに */)
{
    _auth = auth;
}

// 認証状態を Page 側に知らせるフラグ
public bool IsAuthenticated => _auth.User != null;

// UI 直接操作は避け、プロパティに寄せる
[ObservableProperty]
private bool isPopUpVisible;

[ObservableProperty]
private string email = string.Empty;

[ObservableProperty]
private string password = string.Empty;

[RelayCommand]
private async Task LoginAsync()
{
    // ガード節:未入力なら即リターン
    if (string.IsNullOrWhiteSpace(Email) ||
        string.IsNullOrWhiteSpace(Password))
    {
        // 必要ならトースト/エラー表示用の状態をセット
        return;
    }

    try
    {
        await _auth.SignInWithEmailAndPasswordAsync(Email, Password);

        // ここで Shell 操作をしない(View に任せる)
        // 成功したことだけを状態で伝える
        OnPropertyChanged(nameof(IsAuthenticated));
    }
    catch (Exception ex)
    {
        // ログに出す・UI 用エラー状態をプロパティで保持
        // 例:ErrorMessage = ex.Message; IsError = true;
    }
}

// デリゲートは「必須」にしない。未設定でも安全に。
public Action OpenPopUp { get; set; } = static () => { };
public Action ClosePopUp { get; set; } = static () => { };

[RelayCommand]
private void ShowRegisterPopUp()
{
    // 可能なら状態で開閉、必要時のみデリゲートを使う
    IsPopUpVisible = true;
    OpenPopUp?.Invoke();
}

[RelayCommand]
private void HideRegisterPopUp()
{
    IsPopUpVisible = false;
    ClosePopUp?.Invoke();
}


} 

LoginPage.xaml(状態バインディングでの表示制御サンプル)

<ContentPage ...>
  <Grid RowDefinitions="Auto,*,Auto">


<VerticalStackLayout Margin="24" Spacing="12">
  <Entry Placeholder="Email"
         Text="{Binding Email}" Keyboard="Email" />
  <Entry Placeholder="Password"
         Text="{Binding Password}" IsPassword="True" />
  <Button Text="Login"
          Command="{Binding LoginCommand}" />
  <Button Text="Register"
          Command="{Binding ShowRegisterPopUpCommand}" />
</VerticalStackLayout>


<Grid IsVisible="{Binding IsPopUpVisible}"
      BackgroundColor="#80000000"
      Grid.RowSpan="3" InputTransparent="False">
  <Frame Padding="24" CornerRadius="12"
         VerticalOptions="Center" HorizontalOptions="Center">
    <Label Text="Registerフォーム(例)" />
    <Button Text="閉じる"
            Command="{Binding HideRegisterPopUpCommand}" />
  </Frame>
</Grid>



 

アンチパターンを潰す:安全な代替手段と判断基準

アンチパターンなぜ危険か推奨アプローチ
VM コンストラクターで Shell.Current.GoToAsync()Shell 未初期化/UI スレッド外の可能性Page.OnAppearing() で判定・遷移
Singleton Page + Transient VM の組み合わせ乱用早期解決した VM の例外が Page 生成失敗に直結初期は両方 Transient。必要性が明確な箇所のみ Singleton
ポップアップ制御のデリゲートを required未割り当て瞬間の Invoke() で null 参照状態(プロパティ)に寄せる。デリゲートはオプショナル+?.Invoke()
入力検証を UI だけに依存プログラム経由の呼出で不正値が素通りコマンド側にガード節を必ず配置

Shell とライフサイクル:どの時点なら遷移して良い?

.NET MAUI で Shell を採用している場合、App.Current.MainPage に AppShell(もしくは Shell を継承したページ)が割り当てられて初めて Shell.Current が有効になります。VM のコンストラクターは DI 解決の瞬間に実行されるため、Shell がまだ無いタイミングも普通にあり得ます。

時系列の目安

順序イベント/動作Shell.Current遷移の可否
1DI が Page / VM を解決null の可能性不可(コンストラクター禁止)
2Page が表示準備(OnAppearing)有効(通常)可(推奨)
3UI 完全表示後(レイアウト安定)有効可(より安全)

どうしても ViewModel 側から遷移したい場合は、遅延実行(MainThread.BeginInvokeOnMainThread、Dispatcher、await Task.Yield() など)で表示完了後にスケジュールする方法もありますが、見通しとテスト容易性の観点で Page 側へ寄せるのが定石です。

ポップアップの“MVVM 化”:デリゲートから状態駆動へ

ポップアップ(例:登録用 RegisterPopUp)を ViewModel から制御したい場合、デリゲート注入ではなく「状態プロパティ」へ寄せるのが堅牢です。UI は IsPopUpVisible にバインドし、ViewModel からは true/false を切り替えるだけにします。

設計の比較

方式長所短所向く場面
デリゲート注入直感的、UI 実装の差し替えが容易null 安全性・バインド順依存、テスト困難暫定対応、PoC
状態バインディング宣言的でテスト容易、タイミングに強いUI 実装に合わせた XAML が必要本番運用、長期保守

堅牢化チェックリスト(配布用テンプレ)

  • VM のコンストラクターで UI 操作(遷移・ダイアログ)をしない。
  • 初期遷移は Page.OnAppearing() または UI スレッドの遅延実行で。
  • Page と VM の DI ライフタイムは原則そろえる(初期は Transient)。
  • ポップアップは状態駆動(IsPopUpVisible)に変更。デリゲートはオプショナル&?.Invoke()。
  • ログインコマンドに必ずガード節(Email/Password の空チェック)。
  • 例外は握りつぶさず、UI 用プロパティ(ErrorMessage など)に写す。
  • Shell ルートは XAML または起動時に確実に登録(名前の揺れに注意)。
  • ナビゲーションは絶対パス(//MainPage)を優先しスタックを整理。

トラブルシューティングの実際:切り分け手順

  1. クラッシュログの採取:try-catch で例外を捕捉し、Debug.WriteLine(ex) などで吐く。アプリが即死する場合は、VM コンストラクターの中に一時的にログを置き、コンストラクターが動く段階で UI に触っていないかを確認。
  2. Shell の状態確認:OnAppearing() で Shell.Current != null を検証。無効なら起動順序の問題。
  3. DI ライフタイム変更:Page/VM を一旦 Transient に寄せ、エラー再現性が下がるかを確認。
  4. ポップアップ呼び出し元を一時無効化:デリゲート経由の呼び出しをコメントアウトし、IsPopUpVisible 切り替えのみに統一。再現が消えるかを観察。
  5. ログイン成功時の遷移を Page 側に移動:VM は IsAuthenticated の更新のみ。Page で PropertyChanged を拾って遷移しても良い。

完成形のサンプル:認証フロー全貌

AppShell.xaml(ルート名は MainPage と一致させる)

&lt;Shell ...&gt;
  &lt;TabBar&gt;
    &lt;ShellContent
        Title="Home"
        Route="MainPage"
        ContentTemplate="{DataTemplate local:MainPage}" /&gt;
  &lt;/TabBar&gt;
&lt;/Shell&gt;

LoginPage.xaml.cs(認証状態の変化を監視して遷移)

protected override async void OnAppearing()
{
    base.OnAppearing();


if (BindingContext is LoginPageViewModel vm)
{
    // 初期状態で認証済みなら即遷移
    if (vm.IsAuthenticated && Shell.Current != null)
    {
        await Shell.Current.GoToAsync("//MainPage");
        return;
    }

    // 認証成功後に遷移するパターン(状態変化監視)
    vm.PropertyChanged += async (_, e) =>
    {
        if (e.PropertyName == nameof(LoginPageViewModel.IsAuthenticated)
            && vm.IsAuthenticated
            && Shell.Current != null)
        {
            await Shell.Current.GoToAsync("//MainPage");
        }
    };
}


} 

入力バリデーションの二重化(ViewModel 側)

[RelayCommand]
private async Task LoginAsync()
{
    if (string.IsNullOrWhiteSpace(Email) ||
        string.IsNullOrWhiteSpace(Password))
    {
        // UI の Validation とは別に、必ずガード節を置く
        return;
    }


try
{
    await _auth.SignInWithEmailAndPasswordAsync(Email, Password);
    OnPropertyChanged(nameof(IsAuthenticated));
}
catch (AuthException ex)
{
    // 例外型は使用ライブラリに合わせて
    ErrorMessage = "ログインに失敗しました。メールとパスワードを確認してください。";
    // ログ:ex.ToString()
}


} 

よくある質問(FAQ)

Q. OnAppearing() が複数回走って二重遷移しませんか?

あり得ます。遷移前にフラグ(例:_navigated)で一度きりに制限するか、遷移先を絶対パス(//MainPage)にしてスタックをクリアしてください。

Q. それでも Shell.Current が null のことがある?

起動直後のデバッグやカスタム起動順序で稀にあります。MainThread.BeginInvokeOnMainThread で 1 ティック遅らせる、または await Task.Yield() を入れて描画完了後に実行してください。

Q. Page/VM を Singleton にしたい理由がある場合は?

明確に「状態をアプリ全体で共有する」「コストの高いオブジェクトを使い回す」などの意図がある場合のみ検討します。副作用としてライフサイクルが長くなり、リークや初期化順問題が増えるため、まずは Transient で安定させ、必要箇所限定で昇格させましょう。

テスト方針:回帰しないための自動化ポイント

  • ユニットテスト:LoginAsync に空文字を渡したとき 早期リターン すること、例外時に UI 用エラー状態を立てること。
  • UI テスト(遷移):認証済みで起動 → LoginPage を経由せず MainPage が表示されること。未認証で起動 → LoginPage が表示され、正しい入力で MainPage に遷移すること。
  • ポップアップ:IsPopUpVisible 切り替えで表示/非表示が反映されること。デリゲート未設定でも例外が出ないこと。

運用ヒント:ログ、計測、クラッシュ回避の小技

  • 集中ログ:すべてのナビゲーション前に Debug.WriteLine($"Navigate: {route}") を一時挿入し、スタックを可視化。
  • ガードヘルパ:ShellGuard.TryGoToAsync(string route) のようなラッパーを用意し、Shell.Current == null を検査してから遷移。
  • 遅延初期化:Firebase クライアントなど外部依存は「使う直前まで作らない」。起動コストを平準化しレースを減らす。

Shell 遷移ガードの例

public static class ShellGuard
{
    public static async Task&lt;bool&gt; TryGoToAsync(string route)
    {
        var shell = Shell.Current;
        if (shell == null) return false;
        try
        {
            await shell.GoToAsync(route);
            return true;
        }
        catch
        {
            return false;
        }
    }
}

まとめ:安全原則さえ守れば “初期遷移クラッシュ” はなくなる

  • 遷移は Page ライフサイクルに乗せる(OnAppearing)。
  • Page/VM は同じライフタイムで開始(まずは Transient)。
  • ポップアップは状態バインディングに寄せ、デリゲートはオプショナル+?.Invoke()。
  • 入力はUI と VM の二重バリデーションでクラッシュ予防。

上記を徹底するだけで、「LoginViewModel を追加した瞬間に落ちる」問題は解消し、認証フローは格段に安定します。設計と実装の責務分離を守り、壊れにくいログイン体験を育てていきましょう。

付録:チェック用スニペット集

① コンストラクターからの遷移を探す正規表現(目視レビュー用)

// "GoToAsync" が VM の ctor 内にあるかをざっくり探す
// - class FooViewModel
// - public FooViewModel(...) { ... GoToAsync(...); ... }
GoToAsync\(.+\)

② ルート名の取り違えを検査するメモ

  • AppShell.xaml の Route="MainPage" と GoToAsync("//MainPage") の一致。
  • nameof(MainPage) を使うとクラス名変更に強い。

③ 例外の握りつぶしを禁止するテンプレ

try
{
    await SomeAsync();
}
catch (Exception ex)
{
    // ログと UI 状態の両方を更新
    Debug.WriteLine(ex);
    ErrorMessage = "処理に失敗しました。時間をおいて再度お試しください。";
    IsError = true;
}

ケーススタディ:なぜ「たまにだけ落ちる」のか

VM のコンストラクターが 時々先に走り、Shell.Current がまだ null。別の時は Shell 構成が先に終わり Shell.Current が有効——という初期化競合が背景にあります。CPU やデバッガのステップ、ログ I/O など微細な要因で順序が前後し、“デバッグでは成功、本番で失敗”というやっかいな症状を生みます。コンストラクターで UI を触らないというルールさえ守れば、競合自体が発生せず、再現性に振り回されなくなります。

設計指針の再掲(配布カード)

指針理由実践ポイント
VM は状態とビジネスロジックに専念テスト容易・タイミング依存の回避ナビゲーションは Page/Shell 層に集中
DI ライフタイムはまず統一依存の初期化順問題を抑制必要箇所のみ Singleton へ昇格
宣言的 UI(状態駆動)を徹底null 安全・再利用性・保守性IsPopUpVisible 等のフラグで制御
ガード節と例外処理を必ず用意クラッシュをユーザー体験に持ち込まないUI 用のエラー状態をプロパティで公開

おわりに

「Shell 初期化」「DI ライフタイム」「ポップアップ制御」を整理すると、ログイン導線は一気に安定します。本記事のスニペットとチェックリストをそのままコードレビューの基準に取り込み、“コンストラクターで UI を触らない”をチームのルールにしておきましょう。

この記事を書いた人

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

コメント

コメントする

目次