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 のまま GoToAsync | LoginPage 解決(DI)直後、VM コンストラクター内 |
| たまに起動できるが遷移が不定 | Shell 初期化と VM 処理の競合(タイミング依存) | 低スペック端末/デバッグビルドで顕著 |
| ポップアップ表示時にクラッシュ | 未割り当てデリゲートの Invoke() | バインド完了前にコマンド実行 |
結論:安全な“遷移トリガー”は Page ライフサイクルに寄せる
MVVM ではUI 操作(ページ遷移・ポップアップ表示)はできる限り View(Page)側に寄せるのが安全です。初期遷移は OnAppearing() などの表示が保証されたタイミングで判定・実行し、ViewModel は「状態(認証済みか)とコマンド(ログイン処理)」に専念させましょう。
最小の修正方針(3 ステップ)
- コンストラクターの
GoToAsyncを全面撤去(遷移は Page 側で)。 - Page と ViewModel のライフタイムを一旦そろえる(開発初期は両方
AddTransientが安定)。 - ポップアップは「状態でバインド」する設計へ(デリゲートはオプショナル+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 | 遷移の可否 |
|---|---|---|---|
| 1 | DI が Page / VM を解決 | null の可能性 | 不可(コンストラクター禁止) |
| 2 | Page が表示準備(OnAppearing) | 有効(通常) | 可(推奨) |
| 3 | UI 完全表示後(レイアウト安定) | 有効 | 可(より安全) |
どうしても 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)を優先しスタックを整理。
トラブルシューティングの実際:切り分け手順
- クラッシュログの採取:
try-catchで例外を捕捉し、Debug.WriteLine(ex)などで吐く。アプリが即死する場合は、VM コンストラクターの中に一時的にログを置き、コンストラクターが動く段階で UI に触っていないかを確認。 - Shell の状態確認:
OnAppearing()でShell.Current != nullを検証。無効なら起動順序の問題。 - DI ライフタイム変更:Page/VM を一旦 Transient に寄せ、エラー再現性が下がるかを確認。
- ポップアップ呼び出し元を一時無効化:デリゲート経由の呼び出しをコメントアウトし、
IsPopUpVisible切り替えのみに統一。再現が消えるかを観察。 - ログイン成功時の遷移を Page 側に移動:VM は
IsAuthenticatedの更新のみ。Page でPropertyChangedを拾って遷移しても良い。
完成形のサンプル:認証フロー全貌
AppShell.xaml(ルート名は MainPage と一致させる)
<Shell ...>
<TabBar>
<ShellContent
Title="Home"
Route="MainPage"
ContentTemplate="{DataTemplate local:MainPage}" />
</TabBar>
</Shell>
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<bool> 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 を触らない”をチームのルールにしておきましょう。

コメント