.NET MAUI FlyoutPageでメモリが増え続ける原因と対策(Windows/.NET 8・.NET 9)Shell混在・GC.Collect・DIの落とし穴

.NET 8 の .NET MAUI アプリで FlyoutPage を使ってページを切り替えると、Windows では遷移のたびにメモリが増え続けるように見えることがあります。この記事では、Shell と FlyoutPage の混在が招く落とし穴、GC.Collect を入れるべきかの判断、DI 登録の誤解、そして本当にリークかを見極める診断手順まで、実例ベースで整理します。

目次

症状:FlyoutPage の Detail 差し替えでメモリが右肩上がりになる

今回のケースは、.NET 8 の .NET MAUI アプリ(Windows)で FlyoutPage をルートにしており、メニュー選択のたびに新しいページを生成して Detail に差し替える、という構成です。Microsoft Q&A の質問文では「flayout」と表記されていますが、意図しているのは FlyoutPage です。

典型的には、次のようなナビゲーション処理になっています。

public void Navigate(string navigateTo)
{
    var page = (Page)Activator.CreateInstance(typeof(InProcessPage));
    this.Detail = new NavigationPage(page);


// ここで GC.Collect() を呼びたくなる…
GC.Collect();


}

ページを切り替えるたびにメモリ使用量が増え、Visual Studio の「Managed Memory」ビュー(マネージドメモリのスナップショット)でも、遷移前のページが残っているように見えるため「前のページが解放されていない=メモリリークでは?」という不安につながります。加えて、次のような対策案が検討されがちです。

検討されがちな対策狙いまず結論
GC.Collect() を毎回呼ぶ遷移直後にメモリを減らす本番常用は避け、診断用途に限定
WeakReference でページを持つページを GC で回収しやすくする根本解決になりにくい(参照元が別に残る)
DI を AddSingleton にしてページをキャッシュ再生成を減らして安定させる「メモリを減らす」目的とは逆(常駐が仕様になる)
Shell.Current.NavigationStack を消すナビゲーションスタックをクリアするFlyoutPage と Shell 混在の設計がNG
GitHub の既知 Issue を疑うフレームワーク側の不具合確認状況整理に有効(ただし自分の再現確認が必須)

最優先の設計ポイント:Shell と FlyoutPage を混在させない

このテーマで最も重要なのは、「ナビゲーションの設計を統一する」ことです。FlyoutPage を使っているのに、Shell のナビゲーション API(Shell.Current.NavigationNavigationStack)を前提にした対策を入れようとすると、問題の切り分けが難しくなります。

Microsoft Q&A の受理回答でも、まさにここが指摘されています。FlyoutPage を使っているのに Shell のナビゲーションを触るのは筋が悪い、という話です。

さらに公式ドキュメントでも、FlyoutPage は .NET MAUI Shell アプリと互換性がなく、Shell アプリ内で FlyoutPage を使うと例外が発生すると明記されています。

項目FlyoutPage ベースShell ベース
前提FlyoutPage がアプリのルート(メニュー+詳細)Shell がアプリのルート(ルーティング+Flyout/Tab)
ページ切替Detail = new NavigationPage(new XxxPage())Shell.Current.GoToAsync(...)
スタック管理基本は Detail 側の NavigationPage が管理Shell のルーティング/ナビゲーションが管理
混在Shell の API を触るのは避けるFlyoutPage を組み込むのは避ける

つまり、今回のケースで「Shell.Current.Navigation.NavigationStack を消す」方向に進むのは、設計として誤りになりやすいです。Shell で作り直すなら Shell の流儀で、FlyoutPage で行くなら FlyoutPage の流儀で、に揃えるのが最短ルートです。

「ページが残って見える」=即メモリリークではない

メモリ問題の相談で非常に多いのが、「Visual Studio の Managed Memory にオブジェクトが残っている=リーク」と短絡的に判断してしまうケースです。ですが、実際は次の要因で “残って見える” ことがあります。

  • GC は「必要になったら」走る(遷移直後にすぐ回収されるとは限らない)
  • デバッグ中は、デバッガやホットリロードが参照を保持しやすい
  • OS のメモリ管理(Working Set/Private Bytes)は、GC の回収と一致しないことがある
  • ページが参照され続けているのではなく、内部キャッシュ・ハンドラ・イベントがぶら下がっている場合がある

Microsoft Q&A のスレッドでも、検証者側は「最初は増えるが、数秒待つと回収されて一定値に落ち着く」という挙動を報告しています(例として約 6500KB 程度で維持される、というコメント)。

この話が示しているのは、「増えたこと」よりも「増え続けること」が重要だという点です。時間経過や GC 後に戻るなら、単なる一時的増加(または OS 側の保持)であり、必ずしもリークではありません。

GC.Collect を本番フローに入れるのは避け、診断に使う

質問者が気にしている GC.Collect() は、結論から言うと “常用” はおすすめできません。強制 GC は UI スレッドを止めたり、GC の最適化を妨げたり、逆に断片化やスループット低下を招くことがあります。

ただし、スレッド内でも言及されているように「本当にリークかどうかの切り分け」には使えます。具体的には、遷移直後に 1 回だけ強制 GC をかけて、それでもメモリ増加が止まらないならリーク疑いが濃い、という考え方です。

診断用に試すなら、次のように “最大世代まで・ブロッキング・コンパクションあり” を明示して、さらにファイナライザ待ちも入れると観察しやすくなります(ただし、実行は開発時の一時テストに限定してください)。

// 診断用(本番常用は推奨しない)
System.GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true);
System.GC.WaitForPendingFinalizers();
System.GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: true);
観察ポイント見え方解釈
強制 GC 後に増え続ける遷移回数に比例して右肩上がり参照が残っている可能性が高い(リーク疑い)
強制 GC 後に落ちる/横ばい一時増加後に一定値へGC タイミング・キャッシュ・OS 側保持の可能性
Managed Memory には残るが、保持元が弱い数秒〜しばらく後に消える「まだ回収されていないだけ」のケースが多い

DI の AddSingleton / AddTransient だけで根本解決はしない

「ページや ViewModel を DI コンテナに登録して、Singleton にすればキャッシュされて解決するのでは?」という発想は、メモリの観点では逆効果になりやすいです。Singleton は “アプリが生きている限り生きる” ため、ページが残るのは仕様になります。

また、Transient にしたからといって「遷移した瞬間に前ページが必ず回収される」わけでもありません。Q&A のコメントでも、AddTransient で試しても Managed Memory 上では前ページが見える、といった趣旨の話が出ています。

ここで押さえておきたいのは、DI のライフタイムは “参照関係” を変えるわけではないことです。ページが回収されない原因が、イベント購読や静的参照、ハンドラ(ネイティブ側)にあるなら、DI の設定を変えても状況は変わりません。

登録意味メモリ観点の注意
AddSingleton同一インスタンスをずっと使うページ常駐が仕様になる(軽くしないと重い)
AddTransient要求ごとに新規生成回収は GC 次第。リークがあると増え続ける
AddScopedスコープ内で同一MAUI ではスコープ設計を明確にしないと逆に複雑化

おすすめは「ページは基本 Transient(必要に応じて ViewModel も Transient)」「ただし重いリソース(カメラ・マップ・ストリーム・タイマー)を持つ場合は明示的に破棄/解除する設計」をセットで考えることです。

NavigationStack を消すより、FlyoutPage らしい遷移に整える

FlyoutPage でよくある誤解が、「遷移のたびにナビゲーションスタックが溜まっているのでは?」という疑いです。ですが、今回のコードは Detail を “新しい NavigationPage に置き換えている” ため、一般的にはスタックが積み上がり続ける構造ではありません(古い NavigationPage が参照されなければ GC 対象になります)。

むしろ問題になりやすいのは次の2つです。

  • FlyoutPage と Shell を混在させて、想定外の参照・ナビゲーション状態が発生している
  • ページ側(または ViewModel 側)が外部リソースやイベント購読を解除していない

FlyoutPage の公式ドキュメントでも、FlyoutPage はアプリのルートとして設計されており、別のページタイプの子として使うと予期しない動作を招く可能性がある、と注意されています。

FlyoutPage ベースで運用するなら、遷移処理は次のような形に寄せると読みやすく、参照関係も追いやすくなります。

// FlyoutPage のコードビハインドやナビゲーションサービスで呼ぶイメージ
public void NavigateToPage(Page page)
{
    Detail = new NavigationPage(page);
    IsPresented = false; // メニューを閉じる
}

DI を使うなら、Activator ではなく DI から生成するほうが、依存関係が明確になります(ただし、これ自体がメモリを解決するわけではありません)。

private readonly IServiceProvider _services;

public void NavigateTo<TPage>() where TPage : Page
{
    var page = _services.GetRequiredService<TPage>(); // Transient 推奨
    Detail = new NavigationPage(page);
    IsPresented = false;
}

Windows で本当にリークか見極める診断手順

「リークなのか、GC タイミングなのか」を判断するには、観察のしかたを揃えるのが大切です。.NET MAUI 公式のメモリリーク診断ガイド(GitHub Wiki)でも、スナップショットの取り方や gcdump の活用、デバッグ時の注意点が整理されています。

手順

  1. 計測条件を揃える
    • 同じ操作(例:A→B→A→B…)を同じ回数繰り返す
    • ページに載せるデータ量(画像、リスト件数)を固定する
    • 可能なら Release 構成でも比較する(挙動が変わることがある)
  2. Managed Memory のスナップショットを 2 回以上取る Visual Studio の Diagnostic Tools(Memory Usage)で遷移前後のスナップショットを取り、増えている型(Page / ViewModel / Handler / Image など)を確認します。Wiki では、デバッグ中のスナップショットは便利だが、正確性のために XAML Hot Reload を無効化する必要がある旨も注意されています。
  3. 強制 GC を「1回だけ」かけて変化を見る 強制 GC 後も右肩上がりなら、どこかが参照を保持している可能性が高いです。逆に、しばらく待つと落ち着くなら “増えて見えるだけ” の可能性があります。
  4. 残っているオブジェクトの「保持元(Root)」を追う Managed Memory のツリーで、誰がその Page を参照しているかを辿ります。典型は「静的イベント」「タイマー」「Messaging」「長寿命サービス」が Root になります。
  5. 必要に応じて gcdump を取る MAUI Wiki では dotnet-gcdump などでスナップショット(gcdump)を採取して解析する方法も案内されています。原因追跡が難しい場合ほど、gcdump のほうが再現性が上がります。

リークを起こしやすい “ありがちパターン” と対処

FlyoutPage 自体のせいに見えて、実は「ページ内のコード」が参照を握り続けているケースは少なくありません。特に MAUI では、ビジュアルツリー(View 階層)とハンドラ(ネイティブ)を通じて参照がつながりやすいので、次の点を重点的に確認すると効率的です。

原因パターンよくある症状対処の方向性
イベント購読の解除漏れ(静的イベント含む)ページを閉じても ViewModel が残るOnDisappearing/Dispose で Unsubscribe、WeakEvent を検討
タイマー/Dispatcher/Task がページをキャプチャ遷移後もバックグラウンド処理が継続CancellationToken で停止、タイマーを Stop
IDisposable リソース(Stream/Socket/Bitmap 等)の破棄漏れメモリやハンドルが増え続けるDispose/DisposeAsync を実装し、遷移で必ず呼ぶ
メッセージング(MessagingCenter 等)に登録したまま画面を閉じても購読が生きる購読解除、または寿命を短くする
画像・大量リストのキャッシュGC 後も使用メモリが戻りにくいページ再利用をやめる/キャッシュ戦略を見直す

「ページが解放される設計」に寄せるなら、ViewModel にクリーンアップ口を用意して、遷移のタイミングで呼ぶのが実務的です。

public interface ICleanup
{
    void Cleanup();
}

public partial class InProcessViewModel : ICleanup
{
    private CancellationTokenSource? _cts;

    public void Start()
    {
        _cts = new CancellationTokenSource();
        // _cts.Token を使った処理を開始
    }

    public void Cleanup()
    {
        _cts?.Cancel();
        _cts?.Dispose();
        _cts = null;
        // イベント解除などもここで
    }
}

関連 Issue と “別件” の切り分け:#23625 と #15257

今回の相談では、GitHub の既知 Issue を参照しながら原因を探っています。ここで重要なのは「自分の症状と一致しているか」を冷静に切り分けることです。

FlyoutPage で “消えたページが Managed Memory に残って見える” 問題(#23625)

dotnet/maui の Issue #23625 は、FlyoutPage の画面遷移後に “消えたはずのページが Managed Memory ウィンドウに残る” という報告で、Windows / 8.0.3 GA の記載があります。

この Issue は現時点ではオープンで Backlog になっており、アプリ側での再現条件や保持経路の特定が重要になります。

Map を含むページでメモリが戻らない問題(#15257)

#15257 は Map/Maps コントロールを含むページで、Android でナビゲーションを繰り返すと RAM が増え続けるという報告です。プラットフォームや症状が違うため、Windows の FlyoutPage 問題と同一視しないのがポイントです。

.NET 9 では何が変わる?改善の見方と現実的な期待値

.NET MAUI は .NET 9 世代で品質改善・テスト拡充・バグ修正にフォーカスしていることが公式ドキュメントでも述べられています。したがって、.NET 8 で気になった挙動は、まず最新(可能なら .NET 9)で再確認するのが合理的です。

また、#23625 に関連して言及されている PR として、dotnet/maui の Pull Request #23738 があります。これはハンドラの Disconnect を制御するための HandlerProperties.DisconnectPolicyDisconnectHandlers といった API を追加するもので、net9.0 ブランチにマージされています。

PR 本文にも API の概形が示されています(どのタイミングで DisconnectHandler を呼ぶかを制御する意図)。

さらに、その PR の会話中で Issue #23625 が参照されているため、少なくとも “メモリに関する文脈” と無関係ではありません。

ただし、ここでの注意点は「PR がマージ=あなたの症状が必ず解決」ではないことです。実際には、アプリ固有の参照保持(イベント、サービス、リソース)も絡むため、アップグレードはあくまで “再現性を下げる/改善する可能性が高い一手” と捉え、必ず自分のプロジェクトで再テストしましょう。

FlyoutPage を採用し続ける場合の実務的なベストプラクティス

FlyoutPage を使い続ける場合は、「メニューは固定」「Detail は差し替える」「差し替え時にクリーンアップ」を基本線にすると事故が減ります。

おすすめのルール

  • FlyoutPage をルートに固定(子ページとして使わない)
  • Shell の API を触らない(混在しない)
  • ページ生成は Transient を基本(ただし重いページは「残す」ことを仕様にする)
  • ページ/ViewModel の寿命を設計する(イベント解除、タイマー停止、Dispose)
  • “メモリが増える” ではなく “増え続ける” を監視する(強制 GC 後の挙動で判断)

ページキャッシュは「メモリ節約」ではなく「速度優先」

ページキャッシュ(例:Singleton 化や辞書に保持)は、操作感を滑らかにするために使うことはあります。しかしその場合は、メモリ使用量が下がらないのが正しい挙動になります。つまり、キャッシュは「メモリを減らすための解決策」ではなく「再生成コストを払わない代わりに常駐させる」トレードオフです。

もしキャッシュするなら、次のように “キャッシュするページを厳選し、解放できる仕組みも用意する” のがおすすめです。

// 例:ページの簡易キャッシュ(必要なページだけ)
// ※メモリを減らす目的ではなく、再生成コスト削減のために使う
private readonly Dictionary<Type, Page> _cache = new();

public Page GetOrCreate<TPage>(Func<TPage> factory) where TPage : Page
{
    if (_cache.TryGetValue(typeof(TPage), out var p))
        return p;

    var page = factory();
    _cache[typeof(TPage)] = page;
    return page;
}

public void ClearCache()
{
    _cache.Clear(); // キャッシュを捨てれば GC 対象になる
}

まとめ:この問題で迷ったときの判断基準

  • Shell と FlyoutPage は混ぜない(公式にも FlyoutPage は Shell と非互換)
  • GC.Collect は本番常用しない。必要なら “診断の一手” として一時的に使う
  • DI の Singleton はメモリ節約ではない(常駐が仕様になる)
  • “増えた” ではなく “増え続ける” を見る(時間経過・強制 GC 後も右肩上がりか)
  • 最新版(特に .NET 9)で再現確認する。MAUI は .NET 9 で品質改善が進んでいる
  • それでも明確に増え続けるなら、最小再現プロジェクト+スナップショットで保持元を特定し、GitHub Issue 側で追う(Q&A はバグトラッカーではない)

FlyoutPage のナビゲーションで「メモリが増え続ける」と感じたときは、まず設計(Shell 混在の排除)を正し、次に “本当に増え続けるか” を診断手順で確認し、そのうえでページ側の参照保持(イベント・タイマー・リソース)を潰していくのが、遠回りに見えて最短の解決ルートです。

この記事を書いた人

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

コメント

コメントする

目次