.NET 9 MAUIの画面遷移GoToAsync/PushAsync互換性とメモリリーク対策まとめ

.NET 8 から .NET 9 へ MAUI アプリを移行するとき、「Shell.Current.GoToAsync や Navigation.PushAsync はそのまま使えるのか?」「画面遷移を繰り返すとメモリが増え続ける問題は解消されるのか?」という不安を持つ方は少なくありません。本記事では、.NET 9 における MAUI の画面遷移 API の互換性と、メモリリークにつながりやすい実装パターン・対策を、左メニュー+右コンテンツ構成の実例を交えながら整理します。

目次

.NET 9 と MAUI ナビゲーション API の互換性

まず結論から言うと、.NET 9 の .NET MAUI でも Shell.Current.GoToAsync と Navigation.PushAsync 自体は継続して利用できます。ナビゲーションの基本設計は .NET 8 と変わっておらず、Shell アプリでは URI ベースのルーティングと GoToAsync を使うのが推奨です。

.NET 9 の MAUI は、主に品質向上とバグ修正にフォーカスしており、ナビゲーション API そのものに大きな破壊的変更は入っていません。

.NET 8 / .NET 9 で変わった点・変わらない点の整理

項目.NET 8 MAUI.NET 9 MAUI
Shell ナビゲーションShell.Current.GoToAsync() が基本同じ。API 仕様は継続。ルーティングとクエリパラメータも同様
NavigationPage ナビゲーションNavigation.PushAsync() / PopAsync()同じく利用可能(非 Shell 構成向け)
ルート登録Routing.RegisterRoute による登録同じ。Shell のルート構造の考え方も変わらず
Application.MainPage通常のルート切り替えに多用MainPage は obsolete 指定。将来削除予定。
代わりに CreateWindow で Window.Page を設定することが推奨
ハンドラの切断独自 Handler の DisconnectHandler() が自動では呼ばれないケースあり自動で Handler を切り離す仕組みと HandlerProperties.DisconnectPolicy が追加され、特定のリークが減少

つまり、画面遷移のメソッド名や基本的な使い方は .NET 8 と同じなので、「.NET 9 にしたら GoToAsync や PushAsync が使えなくなる」という心配は不要です。一方で、MainPage の扱いなど周辺 API に将来の削除を見据えた変更が入っているため、そこだけ設計をアップデートしておくと長期的に安心です。

GoToAsync と PushAsync の違いと使い分け

Shell アプリでよく混同されるのが Shell.Current.GoToAsync() と Navigation.PushAsync() の違いです。両者の特徴を比較しておきましょう。

項目Shell.Current.GoToAsync()Navigation.PushAsync()
前提構成Shell アプリ専用(AppShell を使う前提)NavigationPage または Page.Navigation が有効な構成
識別方法ルート名/URI でページを指定new Page() など インスタンス を直接 Push
インスタンス生成基本的にはルートごとに 1 インスタンス。ルーティング・DI 設定で挙動を制御呼ぶたびに新しいページインスタンスがスタックに積まれる
戻る動作GoToAsync("..") などルートを URI 的に指定PopAsync() で 1 つ前のページに戻る
Shell との相性Shell を前提に設計されているため、Flyout やタブとの連携が自然Shell 内でも使えるが、混在するとスタックが複雑になりがち
メモリ観点ルート単位で再利用されるケースが多く、無制限にインスタンスが増えにくい呼び方によっては同じページを何十個も積んでしまい、メモリ肥大の原因になりやすい

Shell を採用しているなら、基本は Shell.Current.GoToAsync に統一し、Push 系 API は「どうしても Shell では表現しにくい一部の遷移」に限定するとナビゲーションスタックが整理しやすくなります。

.NET 9 でもメモリリークは起こり得るのか

「ログイン → サブページ → 戻る → 再度サブページ…」を繰り返すと使用メモリが右肩上がりになり、最終的に OS によって落とされる、という報告は .NET 8 / 9 どちらでも複数挙がっています。

.NET 9 では Handler 周りのクリーンアップ改善などにより、特定のケースのリークが解消・緩和されている一方、以下のような アプリ側の実装起因 のリークはそのまま再現し得ます。

  • ページや ViewModel のイベント購読を解除していない
  • static フィールドやシングルトンサービスがページへの参照を保持し続けている
  • Application.Current.MainPage = new AppShell() を何度も呼んでルートを切り替えている
  • Navigation.PushAsync(new MainPage()) を繰り返し呼び、スタックが無限に増え続ける
  • マップや動画など重いネイティブコントロールを載せたページを頻繁に行き来しているが、ネイティブ側のリソース解放が不十分

特に、質問にあるような Application.Current.MainPage.Navigation.PushAsync(new MainPage()) だけで画面遷移を組んだアプリで「メモリがじわじわと増え続ける」という報告が Microsoft Q&A にも上がっており、実装を見直すことで改善した例が存在します。

典型的な症状と原因の対応表

症状ありがちな原因チェックポイント
同じ画面を行き来するたびにメモリが増加し続けるPush や MainPage 差し替えでページを使い捨てしているナビゲーションスタックの深さ、GC.Collect() 後のインスタンス数を確認
ページを閉じても ViewModel が GC されないイベント購読・メッセンジャーが ViewModel を保持しているイベント解除の有無、メッセンジャーが弱参照かどうか
長時間稼働すると操作が重くなるページやハンドラが徐々に溜まり、GC 対象になっていないメモリスナップショットで同じ型のインスタンスが増え続けていないか
マップ・グラフ画面の出し入れで急激にメモリが跳ね上がるネイティブリソースの解放漏れ、サードパーティコンポーネントのリークコンポーネントのバージョンと既知の不具合、Dispose の有無を確認

なお、.NET MAUI のメモリリークを検出するためのツールや手順は各社ブログでも詳しく解説されています。例えば DevExpress の記事では .NET Meteor や Heapview を用いたリーク調査手順が紹介されているので、1 度目を通してみると良いでしょう。

MainPage を何度も Push しない・差し替えない設計にする

メモリリークの温床になりやすいのが「MainPage を何度も作り直す」パターンです。

  • ログイン画面 → PushAsync(new MainPage())
  • ログインに戻る処理でもう一度 PushAsync(new LoginPage())
  • あるいは Application.Current.MainPage = new AppShell() を何度も呼び出す

これらはすべて「新しいページツリーを次々と作り続ける」動きになり、どこかで参照が残っていると古いツリーが GC されずにメモリを食い続けます。

.NET 9 で推奨されるルートの置き方

.NET MAUI 9 では Application.MainPage プロパティが obsolete になり、代わりに App クラスの CreateWindow をオーバーライドして Window.Page にルートページを設定する方式が推奨されています。

public partial class App : Application
{
    public App()
    {
        InitializeComponent();
    }

    // アプリ起動時のルートページを決める
    protected override Window CreateWindow(IActivationState? activationState)
    {
        // 最初はログインページを表示する例
        return new Window(new LoginPage());
    }
}

ログイン成功後にメインの AppShell に切り替えたい場合は、MainPage の代わりに 既存ウィンドウの Page を差し替えるようにします。

// ログイン成功時
var window = Application.Current.Windows[0];
window.Page = new AppShell();

この書き方であれば「1 つの Window の中でルートページを差し替える」だけなので、ウィンドウが増殖することはありません。また現在の .NET 9 では Application.Current.MainPage = new AppShell() もまだ動作しますが、将来的な削除がアナウンスされているため、新規開発や大規模リファクタでは上記のパターンに寄せておくことをおすすめします。

Shell を使っているなら Shell に統一する

Shell を採用しているアプリでは、できる限り Shell.Current.GoToAsync() に統一するのが安全です。App.Current.MainPage.Navigation.PushAsync() を混在させると、

  • Shell 内部のルートスタック
  • NavigationPage のページスタック

という 2 種類のスタックが入り乱れ、メモリ的にも挙動的にも追いかけづらくなります。

ルート登録と遷移の基本形

// AppShell.cs
public partial class AppShell : Shell
{
    public AppShell()
    {
        InitializeComponent();

        // ルート登録
        Routing.RegisterRoute(nameof(DashboardPage), typeof(DashboardPage));
        Routing.RegisterRoute(nameof(SettingsPage), typeof(SettingsPage));
    }
}
// 遷移側
await Shell.Current.GoToAsync(nameof(DashboardPage));

ログイン後に「ログイン画面に戻れては困る」場合は、絶対ルート(先頭に //)を使ってスタックをリセットします。

// ログイン成功後
await Shell.Current.GoToAsync("//Main"); // Main は AppShell 側で定義したルート名

質問文にある App.Current.GoToAsyn は誤記で、正しくは Shell.Current.GoToAsync(...) です。

「左メニュー+右コンテンツ」は Page ではなく ContentView で切り替える

デスクトップ用途の MAUI アプリでは、次のような「左にメニュー(ボタン群)、右にコンテンツ」というレイアウトがよく使われます。

  • 左カラム: メニュー(ダッシュボード、レポート、設定などのボタン)
  • 右カラム: 選択された機能の UI(コンテンツ領域)

この構成を ページ遷移 で実現しようとすると、メニューを含んだ MainPage 全体を何度も Push したり、MainPage 自体を作り直したりしがちで、メモリリークの温床になります。

そこでおすすめなのが、右コンテンツを Page ではなく ContentView として差し替える方式です。

レイアウト例(XAML)

<Grid ColumnDefinitions="200,*">
  <StackLayout Grid.Column="0" Padding="8">
    <Button Text="ダッシュボード" Clicked="OnDashboardClicked" />
    <Button Text="レポート" Clicked="OnReportsClicked" />
    <Button Text="設定" Clicked="OnSettingsClicked" />
  </StackLayout>

  <Grid x:Name="currentContentViewHolder"
        Grid.Column="1"
        Padding="12">
    <!-- 初期表示コンテンツ -->
    <views:DashboardView />
  </Grid>
</Grid>

ContentView 差し替えコード例

void OnDashboardClicked(object sender, EventArgs e)
{
    currentContentViewHolder.Children.Clear();
    currentContentViewHolder.Children.Add(new DashboardView());
}

void OnReportsClicked(object sender, EventArgs e)
{
    currentContentViewHolder.Children.Clear();
    currentContentViewHolder.Children.Add(new ReportsView());
}

void OnSettingsClicked(object sender, EventArgs e)
{
    currentContentViewHolder.Children.Clear();
    currentContentViewHolder.Children.Add(new SettingsView());
}

この方式のメリットは次の通りです。

  • ナビゲーションスタックを増やさない(常に 1 ページ内で完結)
  • 各コンテンツを ContentView として分割でき、テスト・再利用がしやすい
  • 大画面デバイス(デスクトップ/タブレット)に適した情報密度を維持しやすい

スマートフォン向けには、「左メニュー+右コンテンツ」ではなく通常のページ遷移(メニュー → コンテンツ)に切り替え、同じ ViewModel を共有する構成にすることでコードの再利用も可能です。

イベント購読解除・Dispose を徹底してリークを防ぐ

.NET 9 になって Handler のクリーンアップは改善されていますが、アプリコード側のイベントやリソース管理を間違えると、やはりページや ViewModel が延命されてしまいます。

イベント購読の基本パターン

  • Appearing / OnNavigatedTo などで += したイベントは、Disappearing / OnNavigatedFrom で -= する
  • 長寿命オブジェクト(シングルトンサービス、メッセンジャーなど)への購読は特に注意
protected override void OnAppearing()
{
    base.OnAppearing();
    _someService.SomethingHappened += OnSomethingHappened;
}

protected override void OnDisappearing()
{
    _someService.SomethingHappened -= OnSomethingHappened;
    base.OnDisappearing();
}

IDisposable / using の徹底

次のような型は Dispose を忘れがちです。

  • Timer / CancellationTokenSource
  • Stream / HttpClient(長期利用しないもの)
  • SignalR などの接続オブジェクト
public sealed class SampleViewModel : IDisposable
{
    private readonly CancellationTokenSource _cts = new();

    public void Dispose()
    {
        _cts.Cancel();
        _cts.Dispose();
    }
}

ページが破棄されるタイミングで ViewModel の Dispose を呼ぶようにしておくと、リソースを確実に解放できます。

メッセンジャー / イベント集約の注意点

MVVM のメッセンジャーやイベントバスを静的に保持していると、「送信元は消えても購読者が残り続ける」問題が起きがちです。CommunityToolkit.Mvvm の WeakReferenceMessenger のように、弱参照ベースの仕組みを使うと、GC の邪魔をしにくくなります。

ナビゲーションスタックを意識した整理テクニック

どうしても PushAsync 系を使いたい場合でも、不要になったページをスタックから外すことでメモリを抑えられます。

ログインページをスタックから除外する例

// LoginPage 上のコード
async Task NavigateToMainAsync()
{
    // MainPage を現在のページの「前」に挿入
    Navigation.InsertPageBefore(new MainPage(), this);

    // 自分自身(LoginPage)を Pop してスタックから外す
    await Navigation.PopAsync();
}

Shell の場合は、前述の // 付き絶対ルートを使うことで、ナビゲーション履歴ごと切り替えることができます。

メモリリークの検証手順

.NET 9 で実装を見直した後も、実際に「リークが消えたか」を確認することが重要です。簡易的な検証手順をまとめておきます。

  1. 問題になりそうな遷移(例: メニュー → 画面 A → 戻る)を 10〜20 回機械的に繰り返す UI を用意する
  2. Visual Studio の「診断ツール」でメモリ使用量グラフを表示し、ピークと谷が繰り返されているかを見る
  3. テスト中に数回、手動で GC.Collect(); GC.WaitForPendingFinalizers(); を呼び、スナップショットを取得する
  4. スナップショットを比較し、「Page / ViewModel / Handler など特定の型のインスタンス数」が右肩上がりになっていないか確認する
  5. Android では Android Studio Profiler、iOS では Xcode Instruments (Leaks/Allocations) でも同様の観測を行う

それでも解決しない場合、GitHub の .NET MAUI リポジトリや Microsoft Q&A には .NET 8 / 9 にまたがるメモリリーク議論が多数蓄積されているため、類似の再現プロジェクトがないかを探し、自分のケースとの共通点・差分を洗い出すのが近道です。

具体的な置き換え例

ログイン → メインへの切り替え(スタックを残さないパターン)

Shell を使う前提で、ログイン画面からメイン画面へ切り替える例です。

// App.xaml.cs
public partial class App : Application
{
    public App()
    {
        InitializeComponent();
    }

    protected override Window CreateWindow(IActivationState? activationState)
    {
        // 起動時は LoginPage を表示
        return new Window(new LoginPage());
    }
}
// LoginPage.xaml.cs
private async void OnLoginSucceeded(object sender, EventArgs e)
{
    var window = Application.Current.Windows[0];

    // ルートを AppShell に差し替え
    window.Page = new AppShell();

    // アプリ内のホーム画面へ遷移(絶対ルート)
    await Shell.Current.GoToAsync("//Home");
}

メニュークリックで右コンテンツだけ差し替える例

質問にある「currentContentViewHolder」を使う実装例です。

void OnReportsClicked(object sender, EventArgs e)
{
    currentContentViewHolder.Children.Clear();
    currentContentViewHolder.Children.Add(new ReportsView()); // ContentView
}

ViewModel ベースにしたい場合は、クリックイベント側では SelectedMenu などのプロパティを更新し、XAML 側で DataTemplateSelector などを使って動的に ContentView を切り替えると、テストしやすい構成になります。

まとめ

  • .NET 9 でも MAUI の標準ナビゲーション API(Shell.Current.GoToAsync / Navigation.PushAsync)は基本的にそのまま利用できます。
  • ただし Application.MainPage は .NET MAUI 9 で obsolete になっており、将来の削除に備えて CreateWindow と Window.Page ベースの設計へ移行しておくと安心です。
  • メモリ増加は、MainPage を何度も Push/差し替えする構成や、イベント解除漏れ・静的参照など 実装側の設計 が主因になりやすく、.NET 9 でも同様の実装なら再現し得ます。
  • Shell を使うなら Shell に統一し、左メニュー+右コンテンツ のようなレイアウトは Page ではなく ContentView 差し替え方式に寄せることで、ナビゲーションスタックを増やさずに済みます。
  • プロファイラやメモリスナップショットで実際にリーク有無を確認し、イベント購読解除・IDisposable・スタック整理を徹底することが、長時間安定稼働する MAUI アプリへの近道です。

この記事を書いた人

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

コメント

コメントする

目次