日程Fit|「いつ空いてますか?」の往復はもう不要。候補日を選んでURLを送るだけ|登録不要|今すぐ無料で使う →

.NET MAUIのShellナビゲーションで長時間後に「Reinicia la aplicacion」が出る原因と対策大全|ポップアップとメモリリークを徹底解説

業務向け .NET MAUI アプリで、長時間運用後にページ遷移のたび「Reinicia la aplicacion(アプリを再起動してください)」が断続的に表示される――この厄介な不具合は、単発のナビゲーション失敗ではなくスタック肥大化とリソース残留の複合要因で起こるケースが大半です。本記事では Shell+CommunityToolkit(Maui/Mvvm)構成を前提に、原因の見立て、最短で効く処方箋、具体的な実装レシピ、長時間検証の手順までを一挙に整理します。

日程Fit。無料・登録不要。「いつ空いてる?」を、ひとつのリンクで。リンクを送って、○△×でかんたん日程調整。無料で日程を作る。
目次

事象の要約(前提)

  • MAUI(CommunityToolkit.Maui / CommunityToolkit.Mvvm)を使用し、Shell ナビゲーション(Shell.Current.GoToAsync)でページを遷移。
  • ポップアップ(IPopupService.ShowPopupAsync もしくは ShowPopupAsync 拡張)でローディング UI を包み、終了時に閉じる実装が多数。
  • 約 2 時間の連続操作(遷移・ポップアップの頻回表示)後に、遷移時「Reinicia la aplicacion」のエラーダイアログが断続的に表示。ダイアログ後も遷移は成立するが、アプリ再起動までメッセージが消えない。
  • 短時間のデバッグやメモリ監視では再現しづらい。

「Reinicia la aplicacion」が出るメカニズムの考え方

このメッセージ自体は OS / ランタイム / ハンドラ層の汎用エラーダイアログに近く、深部での例外・タイムアウト・リソース枯渇がトリガーになっている場合が多いです。MAUI+Shell 構成で長時間後に顕在化するなら、次の複合要因をまず疑います。

要因群具体例長時間後に効く理由
ナビゲーション スタック肥大相対ルート遷移を積み重ね、戻り履歴が延々と蓄積。
モーダル遷移やタブ入れ替えでさらに積み上がる。
ヒープ圧迫・ビュー階層の保持・古いページのハンドラが残る。
ポップアップ/イベントの残留ポップアップの Opened/Closed/サイズ変更/ディスプレイ回転イベントなどを解除しない。
BindingContext を切らずに View・ViewModel が参照循環。
GC の到達可能グラフが増え続け、回収不能なオブジェクトが溜まる。
多重遷移の競合連打や並列タスクから複数回の GoToAsync 呼び出し。
ポップアップ閉鎖と同時遷移のレースコンディション。
一部の遷移が中断・例外化し、以後プラットフォーム側の状態が不安定に。
重いハンドラのリーク画像ハンドラ/WebView/カスタムレンダラが破棄されず積み上がる。描画・バッファ資源が枯渇し、後段の遷移トリガーで表面化。

最短で効く処方箋(結論)

  1. 絶対ルート(//)でのナビゲーションに統一:モジュール切替やリスト→詳細→編集→完了など「区切り」の遷移は都度ルートを再定義して履歴をクリアします。
  2. ナビゲーション ロック(単一実行):連打や並列からの二重遷移を SemaphoreSlimInterlocked で排他。
  3. ポップアップの確実な破棄IDisposableIAsyncDisposableClosed ハンドラでイベント解除・BindingContext = null・ハンドラ Disconnect を徹底。
  4. スタック・ポップアップ数・メモリの定期計測ログ:5 分間隔で NavigationStack.CountModalStack.Count/使用メモリを出力し、異常成長を検知。
  5. 既知リークの芽を潰す:特に画像・WebView・タイマー/イベントの取り扱いを安全化。

実装レシピ:すぐ差し替えられるコード集

絶対ルートでの遷移に統一する(スタックを増やさない)

「次の画面から戻らない設計」や「機能の区切り」では、必ず絶対ルートを使います。


// ルート登録(AppShell 等で起動時に一度)
Routing.RegisterRoute(nameof(DashboardPage), typeof(DashboardPage));
Routing.RegisterRoute(nameof(OrdersPage), typeof(OrdersPage));
Routing.RegisterRoute(nameof(OrderDetailPage), typeof(OrderDetailPage));

// "機能の区切り" で必ずルート遷移
await Shell.Current.GoToAsync($"//{nameof(DashboardPage)}");

// 「一覧→詳細→完了」後は戻り履歴を残さない
await Shell.Current.GoToAsync($"//{nameof(OrdersPage)}?refresh=true");

// 相対遷移しか使えない箇所は、遷移後に余分なページを削る
static void TrimBackStack(INavigation nav, int keepLast = 1)
{
while (nav.NavigationStack.Count > keepLast)
nav.RemovePage(nav.NavigationStack[0]); // 古いページから削除
}
// 例:遷移直後に実行
TrimBackStack(Shell.Current.Navigation, keepLast: 1); 

多重遷移を完全に遮断する「ナビゲーションガード」

SemaphoreSlim で排他し、例外時でも解除されるよう try/finally を徹底します。


public interface INavigationGuard
{
    Task RunAsync(Func navigationAction);
}

public sealed class NavigationGuard : INavigationGuard, IDisposable
{
private readonly SemaphoreSlim _gate = new(1, 1);
private bool _disposed;
public async Task RunAsync(Func<Task> navigationAction)
{
    if (_disposed) return;
    await _gate.WaitAsync().ConfigureAwait(false);
    try
    {
        await navigationAction().ConfigureAwait(false);
    }
    finally
    {
        if (!_disposed) _gate.Release();
    }
}

public void Dispose()
{
    _disposed = true;
    _gate.Dispose();
}

}

// ViewModel 側(CommunityToolkit.Mvvm)
public partial class OrdersViewModel : ObservableObject
{
private readonly INavigationGuard _navGuard;
public OrdersViewModel(INavigationGuard navGuard) => _navGuard = navGuard;
[RelayCommand]
private Task OpenDetailAsync(Order o) =&gt; 
    _navGuard.RunAsync(async () =&gt;
    {
        // ここでロード中ポップアップを開く場合は後述の安全版を呼ぶ
        await Shell.Current.GoToAsync($"{nameof(OrderDetailPage)}", new Dictionary&lt;string,object&gt;
        {
            ["Order"] = o
        })
        .ConfigureAwait(false);
    });

} 

ローディング ポップアップの「安全な開閉」と確実な破棄

ポップアップはイベントの登録解除・ハンドラの切断・BindingContext の解放までを一連の責務にします。


// 共通ローディングポップアップ
public sealed class LoadingPopup : Popup, IDisposable
{
    private bool _disposed;
public LoadingPopup()
{
    // サイズや回転イベントを購読するなら必ず解除が必要
    DeviceDisplay.MainDisplayInfoChanged += OnMainDisplayInfoChanged;
    this.Opened += OnOpened;
    this.Closed += OnClosed;
}

private void OnOpened(object? s, PopupOpenedEventArgs e) { /* アニメ開始など */ }
private void OnClosed(object? s, PopupClosedEventArgs e) { /* ロガー出力など */ }

private void OnMainDisplayInfoChanged(object? s, DisplayInfoChangedEventArgs e)
{
    // レイアウト調整など
}

protected override void OnHandlerChanged()
{
    base.OnHandlerChanged();
    // Handler が切り替わるケースもあるため都度確認
}

public void Dispose()
{
    if (_disposed) return;
    _disposed = true;

    DeviceDisplay.MainDisplayInfoChanged -= OnMainDisplayInfoChanged;
    this.Opened -= OnOpened;
    this.Closed -= OnClosed;

    // 子要素とハンドラの切断
    if (Content is View v)
    {
        v.Handler?.DisconnectHandler();
    }
    Content = null;

    // BindingContext の解放(参照循環を断つ)
    BindingContext = null;
    Handler?.DisconnectHandler();
}

}

// 安全な表示ヘルパ
public static class PopupSafe
{
public static async Task ShowAsync(Page host, Popup popup, Func work, CancellationToken ct = default)
{
try
{
using (popup as IDisposable)
{
var tcs = new TaskCompletionSource();
popup.Closed += (_, __) => tcs.TrySetResult(null);
            // 表示(戻り値はポップアップ固有)
            _ = host.ShowPopupAsync(popup);

            // 実行
            await work().ConfigureAwait(false);

            // 正常終了で閉じる
            popup.Close();
            await tcs.Task.ConfigureAwait(false); // Close 完了待ち
            return default;
        }
    }
    catch
    {
        // 例外でも確実に閉じる
        try { popup.Close(); } catch { /* no-op */ }
        throw;
    }
}

} 

ナビゲーションとポップアップの順序を統一(レース回避)

  1. ナビゲーションガードでロック開始。
  2. ポップアップ表示。
  3. データ取得/検証。
  4. ポップアップを閉じる。
  5. 絶対ルート(必要に応じて)で遷移。
  6. ロック解除。

// 一連のフロー(例)
await _navGuard.RunAsync(async () =>
{
    var host = Shell.Current.CurrentPage;
    using var popup = new LoadingPopup();
await PopupSafe.ShowAsync(host, popup, async () =&gt;
{
    // 長時間の I/O
    await _service.LoadAsync().ConfigureAwait(false);
})
.ConfigureAwait(false);

await Shell.Current.GoToAsync($"//{nameof(OrdersPage)}").ConfigureAwait(false);

}); 

スタック/ポップアップ数/メモリ使用量の定期監視

5 分間隔のメトリクス送出で、発症直前の傾向を把握します。AppCenter などのテレメトリがあればそこへ送信、なければファイルログでも十分です。


public static class RuntimeProbe
{
    private static CancellationTokenSource? _cts;
public static void Start(TimeSpan? interval = null, Action&lt;string&gt; logger = null!)
{
    interval ??= TimeSpan.FromMinutes(5);
    _cts?.Cancel();
    _cts = new CancellationTokenSource();

    _ = Task.Run(async () =&gt;
    {
        var timer = new PeriodicTimer(interval.Value);
        while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
        {
            var nav = Shell.Current?.Navigation;
            var navCount = nav?.NavigationStack.Count ?? -1;
            var modalCount = nav?.ModalStack.Count ?? -1;

            long bytes = GC.GetTotalMemory(forceFullCollection: false);
            var msg = $"NAV={navCount}, MODAL={modalCount}, MEM={bytes / (1024 * 1024)}MB";
            (logger ?? Console.WriteLine).Invoke(msg);
        }
    }, _cts.Token);
}

public static void Stop() =&gt; _cts?.Cancel();

} 

既知リークの芽を潰すためのパターン

対象やってはいけない安全な代替
画像(Image / ImageSourceストリームを開きっぱなし、再利用時に上書き。StreamImageSource を使う場合は作成元の Streamusing で閉じる。画像差し替え時は古い Handler?.DisconnectHandler() を呼んでから Source = null
WebViewページ切替時に置き去り。JS タイマーが動き続ける。OnDisappearingWebView.Source = nullHandler?.DisconnectHandler()。必要なら EvaluateJavaScriptAsync("window.stop()")
タイマー / Rx / イベントDevice.StartTimer の参照を捨てる。CancellationTokenSource を保持し、破棄時に Cancel。イベントは WeakEventManager 経由か +=-= を対で配置。
MVVM Command連打で IAsyncRelayCommand が多重実行。AllowConcurrentExecutions = false 相当をガードで実現(上記 NavigationGuard)。

Android / iOS のメモリ圧に備える(ライフサイクル連携)


// MauiProgram.cs
builder.ConfigureLifecycleEvents(events =&gt;
{
#if ANDROID
    events.AddAndroid(android =&gt;
    {
        android.OnTrimMemory((_, level) =&gt;
        {
            // 画像キャッシュや不要なビューのハンドラを解放
            Microsoft.Maui.ApplicationModel.MainThread.BeginInvokeOnMainThread(() =&gt;
            {
                TryReleaseCaches();
            });
        });
    });
#endif
});
  

メモリ圧で OS からシグナルが来たら、キャッシュ・サムネイル・使っていないページのハンドラを優先的に解放します。

診断のフレームワーク(原因の当たりを付ける)

観測ポイント増加しているなら疑うべき箇所
NavigationStack.Count が右肩上がり戻り履歴が積み上がり続けている相対遷移の乱用、完了後のルート遷移抜け
ポップアップ同時表示数が 2 以上に増える開閉のレース/二重表示ガード未実装、Close 完了待ちの欠落
ヒープ増加だがオブジェクト型が特定の UI に偏るUI/ハンドラのリークイベント解除漏れ、BindingContext 残留、Handler 切断忘れ

長時間検証のすすめ方(実機・2 時間シナリオ)

  1. 最低限の対策(絶対ルート・ガード・ポップアップ破棄)を実装したビルドを作成。
  2. エミュレーターではなく実機を使用(スリープ無効・画面常時オン、充電接続)。
  3. 5 分おきに RuntimeProbe のログを収集。
  4. テスト内容:ページ遷移/検索/編集/保存/ポップアップ表示を繰り返し、実運用に近いシナリオを 2 時間続ける。
  5. 再現すればログから「増えているもの」を特定し、テーブルの対応付けに従って潰す。
  6. 再現しなければ、対策が効いた可能性が高い。念のため 4 時間の延長シナリオも 1 回実施。

さらに踏み込む:共通基盤としての「安全ナビゲーション API」

プロジェクト横断で安全性を担保するため、Shell 直叩きをやめて「安全なラッパー」のみ使用する規約化が有効です。


public interface ISafeNavigator
{
    Task ToRootAsync(string route, IDictionary<string, object?>? parameters = null);
    Task ToAsync(string route, IDictionary<string, object?>? parameters = null);
}

public sealed class SafeNavigator : ISafeNavigator
{
private readonly INavigationGuard _guard;
public SafeNavigator(INavigationGuard guard) => _guard = guard;
public Task ToRootAsync(string route, IDictionary&lt;string, object?&gt;? p = null) =&gt;
    _guard.RunAsync(() =&gt; Shell.Current.GoToAsync($"//{route}", p));

public Task ToAsync(string route, IDictionary&lt;string, object?&gt;? p = null) =&gt;
    _guard.RunAsync(() =&gt; Shell.Current.GoToAsync(route, p));

} 

以後、ViewModel からは ISafeNavigator だけを呼ぶようにし、チーム全体で「絶対ルートの場面」「相対遷移の場面」「戻り履歴の削減ポリシー」を明文化します。

よくある落とし穴と回避策

  • ポップアップ閉鎖と同時の遷移:閉鎖イベント内で遷移を始めるとレースになりがち。閉じる → 完全に閉じ終わるのを待つ → 遷移の順に統一。
  • 例外でポップアップが閉じないtry/finallyClose() を必ず呼ぶ。Closed 完了の待機も忘れない。
  • 「画面を跨ぐ長時間タスク」CancellationTokenSource を渡して、離脱時にキャンセルできるようにする。キャンセル不能だとリソースが長時間残る。
  • ViewModel → View の強参照:メッセンジャやコールバックで View を閉じ込めるとリークに直結。イベントは WeakEventManager 経由にする。
  • デバッグ専用フラグの置き忘れ:意図せず詳細ログやトレースを常時有効にし、I/O 負荷・メモリ負荷が積み上がる。

チェックリスト(リリース前に全て〇)

項目チェック
機能の区切り遷移はすべて // ルートで実装されている
二重遷移を防ぐガード(SemaphoreSlim など)が全画面に適用済み
ポップアップは IDisposable 実装・イベント解除・BindingContext=null の 3 点が完了
RuntimeProbe 等で 2 時間のメトリクスが横ばい
画像/WebView/タイマーの破棄コードが OnDisappearing/Dispose に存在
例外時でもポップアップが確実に閉じるユニットテストがある

トラブルが続く場合の深掘り(メモリ・ヒープの差分解析)

なお、上記の対策後も発生する場合は、メモリスナップショットの差分で「どの型が増えているか」を確認します。特に ViewViewModelHandlerBitmapWebViewRenderer 系が増え続けるなら、破棄忘れ(イベント解除/ハンドラ切断/BindingContext 解放)を再点検してください。

実装テンプレ(まとめて貼って使える最低限セット)


// 1) DI 登録(MauiProgram.cs)
builder.Services.AddSingleton<INavigationGuard, NavigationGuard>();
builder.Services.AddSingleton<ISafeNavigator, SafeNavigator>();

// 2) ViewModel 例
public partial class ExampleViewModel : ObservableObject
{
private readonly ISafeNavigator _nav;
public ExampleViewModel(ISafeNavigator nav) => _nav = nav;
[RelayCommand]
private async Task ExecuteAsync()
{
    using var popup = new LoadingPopup();
    await PopupSafe.ShowAsync(Shell.Current.CurrentPage, popup, async () =&gt;
    {
        await Task.Delay(1200); // ダミー I/O
    });

    await _nav.ToRootAsync(nameof(DashboardPage));
}

}

// 3) Page の破棄フック
public partial class ExamplePage : ContentPage, IDisposable
{
private bool _disposed;
protected override void OnDisappearing()
{
    base.OnDisappearing();
    // 画面固有のイベントを解除
    SizeChanged -= OnSizeChanged;
}

public void Dispose()
{
    if (_disposed) return;
    _disposed = true;

    SizeChanged -= OnSizeChanged;

    if (Content is View v)
        v.Handler?.DisconnectHandler();

    BindingContext = null;
    Handler?.DisconnectHandler();
}

private void OnSizeChanged(object? sender, EventArgs e) { /* ... */ }

} 

原因と対策の対応表(保存版)

症状の兆候考えられる原因対策
遷移を重ねるほど発症が早まる履歴の過多、相対遷移の乱用絶対ルート遷移に統一、不要なページの RemovePage
ポップアップを多用する機能でのみ発症ポップアップのイベント解除漏れIDisposable 実装、Closed 待機、BindingContext=null
連打すると即時発症多重遷移の競合SemaphoreSlimInterlocked によるロック
画像/WebView の多い画面で悪化ハンドラ/バッファのリークハンドラ切断、キャッシュ解放、表示切替時の Source=null

まとめ

長時間運用後に突如あらわれる「Reinicia la aplicacion」は、単一のバグではなくスタック肥大 × リソース残留 × 多重遷移が絡み合って発火する“状態異常”です。まずは 絶対ルート遷移・ナビゲーションガード・ポップアップの確実な破棄の 3 点を最速で導入し、メトリクスを取りながら効果を確認してください。これで沈静化するなら、原因はほぼリソースの残留とスタック肥大に収束します。もし残るなら、ログの傾向から増えている型を特定し、画像/WebView/イベントの破棄を一点ずつ堅実に直していきましょう。最終的に「遷移の安全 API」を共通化すれば、チーム全体で同種の再発を防止できます。

この記事を書いた人

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

コメント

コメントする

目次