業務向け .NET MAUI アプリで、長時間運用後にページ遷移のたび「Reinicia la aplicacion(アプリを再起動してください)」が断続的に表示される――この厄介な不具合は、単発のナビゲーション失敗ではなくスタック肥大化とリソース残留の複合要因で起こるケースが大半です。本記事では Shell+CommunityToolkit(Maui/Mvvm)構成を前提に、原因の見立て、最短で効く処方箋、具体的な実装レシピ、長時間検証の手順までを一挙に整理します。
事象の要約(前提)
- 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/カスタムレンダラが破棄されず積み上がる。 | 描画・バッファ資源が枯渇し、後段の遷移トリガーで表面化。 |
最短で効く処方箋(結論)
- 絶対ルート(
//)でのナビゲーションに統一:モジュール切替やリスト→詳細→編集→完了など「区切り」の遷移は都度ルートを再定義して履歴をクリアします。 - ナビゲーション ロック(単一実行):連打や並列からの二重遷移を
SemaphoreSlimやInterlockedで排他。 - ポップアップの確実な破棄:
IDisposable/IAsyncDisposable/Closedハンドラでイベント解除・BindingContext = null・ハンドラDisconnectを徹底。 - スタック・ポップアップ数・メモリの定期計測ログ:5 分間隔で
NavigationStack.Count/ModalStack.Count/使用メモリを出力し、異常成長を検知。 - 既知リークの芽を潰す:特に画像・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) =>
_navGuard.RunAsync(async () =>
{
// ここでロード中ポップアップを開く場合は後述の安全版を呼ぶ
await Shell.Current.GoToAsync($"{nameof(OrderDetailPage)}", new Dictionary<string,object>
{
["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;
}
}
}
ナビゲーションとポップアップの順序を統一(レース回避)
- ナビゲーションガードでロック開始。
- ポップアップ表示。
- データ取得/検証。
- ポップアップを閉じる。
- 絶対ルート(必要に応じて)で遷移。
- ロック解除。
// 一連のフロー(例)
await _navGuard.RunAsync(async () =>
{
var host = Shell.Current.CurrentPage;
using var popup = new LoadingPopup();
await PopupSafe.ShowAsync(host, popup, async () =>
{
// 長時間の 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<string> logger = null!)
{
interval ??= TimeSpan.FromMinutes(5);
_cts?.Cancel();
_cts = new CancellationTokenSource();
_ = Task.Run(async () =>
{
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() => _cts?.Cancel();
}
既知リークの芽を潰すためのパターン
| 対象 | やってはいけない | 安全な代替 |
|---|---|---|
画像(Image / ImageSource) | ストリームを開きっぱなし、再利用時に上書き。 | StreamImageSource を使う場合は作成元の Stream を using で閉じる。画像差し替え時は古い Handler?.DisconnectHandler() を呼んでから Source = null。 |
| WebView | ページ切替時に置き去り。JS タイマーが動き続ける。 | OnDisappearing で WebView.Source = null、Handler?.DisconnectHandler()。必要なら EvaluateJavaScriptAsync("window.stop()")。 |
| タイマー / Rx / イベント | Device.StartTimer の参照を捨てる。 | CancellationTokenSource を保持し、破棄時に Cancel。イベントは WeakEventManager 経由か += と -= を対で配置。 |
| MVVM Command | 連打で IAsyncRelayCommand が多重実行。 | AllowConcurrentExecutions = false 相当をガードで実現(上記 NavigationGuard)。 |
Android / iOS のメモリ圧に備える(ライフサイクル連携)
// MauiProgram.cs
builder.ConfigureLifecycleEvents(events =>
{
#if ANDROID
events.AddAndroid(android =>
{
android.OnTrimMemory((_, level) =>
{
// 画像キャッシュや不要なビューのハンドラを解放
Microsoft.Maui.ApplicationModel.MainThread.BeginInvokeOnMainThread(() =>
{
TryReleaseCaches();
});
});
});
#endif
});
メモリ圧で OS からシグナルが来たら、キャッシュ・サムネイル・使っていないページのハンドラを優先的に解放します。
診断のフレームワーク(原因の当たりを付ける)
| 観測ポイント | 増加しているなら | 疑うべき箇所 |
|---|---|---|
NavigationStack.Count が右肩上がり | 戻り履歴が積み上がり続けている | 相対遷移の乱用、完了後のルート遷移抜け |
| ポップアップ同時表示数が 2 以上に増える | 開閉のレース/二重表示 | ガード未実装、Close 完了待ちの欠落 |
| ヒープ増加だがオブジェクト型が特定の UI に偏る | UI/ハンドラのリーク | イベント解除漏れ、BindingContext 残留、Handler 切断忘れ |
長時間検証のすすめ方(実機・2 時間シナリオ)
- 最低限の対策(絶対ルート・ガード・ポップアップ破棄)を実装したビルドを作成。
- エミュレーターではなく実機を使用(スリープ無効・画面常時オン、充電接続)。
- 5 分おきに
RuntimeProbeのログを収集。 - テスト内容:ページ遷移/検索/編集/保存/ポップアップ表示を繰り返し、実運用に近いシナリオを 2 時間続ける。
- 再現すればログから「増えているもの」を特定し、テーブルの対応付けに従って潰す。
- 再現しなければ、対策が効いた可能性が高い。念のため 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<string, object?>? p = null) =>
_guard.RunAsync(() => Shell.Current.GoToAsync($"//{route}", p));
public Task ToAsync(string route, IDictionary<string, object?>? p = null) =>
_guard.RunAsync(() => Shell.Current.GoToAsync(route, p));
}
以後、ViewModel からは ISafeNavigator だけを呼ぶようにし、チーム全体で「絶対ルートの場面」「相対遷移の場面」「戻り履歴の削減ポリシー」を明文化します。
よくある落とし穴と回避策
- ポップアップ閉鎖と同時の遷移:閉鎖イベント内で遷移を始めるとレースになりがち。閉じる → 完全に閉じ終わるのを待つ → 遷移の順に統一。
- 例外でポップアップが閉じない:
try/finallyでClose()を必ず呼ぶ。Closed完了の待機も忘れない。 - 「画面を跨ぐ長時間タスク」:
CancellationTokenSourceを渡して、離脱時にキャンセルできるようにする。キャンセル不能だとリソースが長時間残る。 - ViewModel → View の強参照:メッセンジャやコールバックで View を閉じ込めるとリークに直結。イベントは WeakEventManager 経由にする。
- デバッグ専用フラグの置き忘れ:意図せず詳細ログやトレースを常時有効にし、I/O 負荷・メモリ負荷が積み上がる。
チェックリスト(リリース前に全て〇)
| 項目 | チェック |
|---|---|
機能の区切り遷移はすべて // ルートで実装されている | □ |
二重遷移を防ぐガード(SemaphoreSlim など)が全画面に適用済み | □ |
ポップアップは IDisposable 実装・イベント解除・BindingContext=null の 3 点が完了 | □ |
RuntimeProbe 等で 2 時間のメトリクスが横ばい | □ |
| 画像/WebView/タイマーの破棄コードが OnDisappearing/Dispose に存在 | □ |
| 例外時でもポップアップが確実に閉じるユニットテストがある | □ |
トラブルが続く場合の深掘り(メモリ・ヒープの差分解析)
なお、上記の対策後も発生する場合は、メモリスナップショットの差分で「どの型が増えているか」を確認します。特に View/ViewModel/Handler/Bitmap/WebViewRenderer 系が増え続けるなら、破棄忘れ(イベント解除/ハンドラ切断/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 () =>
{
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 |
| 連打すると即時発症 | 多重遷移の競合 | SemaphoreSlim/Interlocked によるロック |
| 画像/WebView の多い画面で悪化 | ハンドラ/バッファのリーク | ハンドラ切断、キャッシュ解放、表示切替時の Source=null |
まとめ
長時間運用後に突如あらわれる「Reinicia la aplicacion」は、単一のバグではなくスタック肥大 × リソース残留 × 多重遷移が絡み合って発火する“状態異常”です。まずは 絶対ルート遷移・ナビゲーションガード・ポップアップの確実な破棄の 3 点を最速で導入し、メトリクスを取りながら効果を確認してください。これで沈静化するなら、原因はほぼリソースの残留とスタック肥大に収束します。もし残るなら、ログの傾向から増えている型を特定し、画像/WebView/イベントの破棄を一点ずつ堅実に直していきましょう。最終的に「遷移の安全 API」を共通化すれば、チーム全体で同種の再発を防止できます。

コメント