.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/CancellationTokenSourceStream/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 で実装を見直した後も、実際に「リークが消えたか」を確認することが重要です。簡易的な検証手順をまとめておきます。
- 問題になりそうな遷移(例: メニュー → 画面 A → 戻る)を 10〜20 回機械的に繰り返す UI を用意する
- Visual Studio の「診断ツール」でメモリ使用量グラフを表示し、ピークと谷が繰り返されているかを見る
- テスト中に数回、手動で
GC.Collect(); GC.WaitForPendingFinalizers();を呼び、スナップショットを取得する - スナップショットを比較し、「Page / ViewModel / Handler など特定の型のインスタンス数」が右肩上がりになっていないか確認する
- 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 アプリへの近道です。

コメント