.NET MAUI Flyoutナビゲーション徹底解説:メモリリークを防ぐページ遷移パターン

.NET MAUI で Flyout ナビゲーションを組むと、ページ遷移やメモリリーク、ホバー表示など細かな挙動で悩みがちです。本記事では FlyoutPage をベースに、「メニュー連打でページインスタンスが増え続ける」「ボタン遷移とメニュー遷移の実装がバラバラ」「パラメータをどう渡すか」「メニューのホバーや選択状態をどう同期するか」といった課題を、実装例付きで整理します。

目次

.NET MAUI の Flyout ナビゲーションで起こりがちな問題

まずは、典型的な「悪いパターン」と、その裏側で起きていることを整理します。Page1 → Page2 → Page3 と遷移するだけの一見シンプルな構成でも、実装次第ではメモリがじわじわ増えていきます。

課題よくある実装問題点
ページ遷移(ページ1→2→3)ボタンやメニューで new XxxPage() を都度呼び出し画面を開くたびにインスタンスが増え続け、戻っても解放されにくい
メニュー連打クリックごとに新しい Page / NavigationPage を生成短時間で大量のインスタンスが作られ、メモリが右肩上がりになる
「ページ2へ移動」ボタンボタン側で Navigation.PushAsync(new Page2())Flyout メニュー経由の遷移とコードが二重管理になり、状態同期も難しくなる
パラメータ受け渡しコンストラクタに直接パラメータを渡してそのまま newページをキャッシュしにくくなり、使い捨て前提の設計になりやすい
メニューのホバー・選択状態デザインだけで Hover を表現、コードからの制御なしマウス操作やページ遷移と UI の見た目が噛み合わない

これらの多くは、「ページをどこで生成し、どこで再利用するか」がバラバラなことが原因です。本記事では以下の方針に沿って改善していきます。

  • メニュー(CollectionView の SelectedItem)を遷移の唯一の入口にする
  • Page / NavigationPage をキャッシュし、基本的に再生成しない
  • 同一ページへの二重遷移を抑止する
  • 画面の更新=ViewModel のコマンドでデータ更新(ページ再生成に頼らない)

なお、.NET MAUI では Flyout 構成として Shell の利用が推奨です。本記事では FlyoutPage ベースの既存アプリを前提にしつつ、要所で Shell 版の書き方にも触れます。

メモリ増加の主因:毎回 new XxxPage() していないか?

メニューのクリックやボタン操作のたびに new XxxPage() していると、以下のような状態に陥ります。

  • ナビゲーションスタック上に同じページ型のインスタンスが大量にぶら下がる
  • イベントやタイマーを解除していないと、ページを閉じても GC に回収されない
  • 最初は問題なく見えるが、長時間利用するとメモリ使用量がじわじわ増える

特に FlyoutPage の Detail に新しい NavigationPage を毎回設定している場合、メニュー連打だけで多数の NavigationPage インスタンスが作られてしまいます。

実装パターン特徴メモリへの影響
使い捨て生成(都度 new)実装は簡単だが、ナビゲーション全体のライフサイクル管理が複雑になる長時間利用でメモリ増加しやすい。イベント解除漏れがあるとリークに見える
キャッシュして使い回し初回だけ生成し、以降はキャッシュから取得ピークメモリを抑えやすい。ライフサイクルを把握しやすく、テストもしやすい

結論として、Flyout メニューと組み合わせる場合は「メニューの数だけ NavigationPage をキャッシュする」設計がもっともシンプルで安定します。

CollectionView の SelectedItem 駆動でナビゲーションを一元化する

まず、Flyout メニューの実装を「SelectedItem が変わったら遷移する」という形に整理します。

メニュー XAML の例

FlyoutPage 側の Flyout に CollectionView を配置し、メニュー項目をバインドします。

<CollectionView x:Name="MenuView"
                SelectionMode="Single">
  <CollectionView.ItemsSource>
    <x:Array Type="{x:Type vm:FlyoutPageItem}">
      <vm:FlyoutPageItem Title="Page1"
                         TargetType="{x:Type views:RoutesPage}" />
      <vm:FlyoutPageItem Title="Page2"
                         TargetType="{x:Type views:SettingsPage}" />
      <vm:FlyoutPageItem Title="Page3"
                         TargetType="{x:Type views:DashboardPage}" />
    </x:Array>
  </CollectionView.ItemsSource>
</CollectionView>

FlyoutPageItem は単純な ViewModel クラスです。必要に応じてアイコンやパラメータ情報を追加できます。

public class FlyoutPageItem
{
    public string Title { get; set; } = string.Empty;
    public Type TargetType { get; set; } = default!;
}

ページキャッシュ+二重遷移ガードのコードビハインド

次に、FlyoutPage 側で「メニュー → 画面遷移」のロジックを一元管理します。

public partial class MyRootPage : FlyoutPage
{
    // ページごとに NavigationPage をキャッシュ
    private readonly Dictionary<Type, NavigationPage> _navCache = new();
    private bool _navigating;

    // 他ページからも参照できるように公開
    public static CollectionView FlyoutMenuInstance { get; private set; }

    public MyRootPage()
    {
        InitializeComponent();

        FlyoutMenuInstance = MenuView;
        MenuView.SelectionChanged += OnSelectionChanged;
    }

    private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
    {
        if (_navigating) return; // 多重実行ガード
        _navigating = true;

        try
        {
            var item = e.CurrentSelection.FirstOrDefault() as FlyoutPageItem;
            if (item is null) return;

            // 既に同じ種類のページなら何もしない(多重遷移防止)
            if (Detail is NavigationPage currentNav &&
                currentNav.CurrentPage?.GetType() == item.TargetType)
            {
                CloseFlyoutIfNeeded();
                return;
            }

            // NavigationPage をキャッシュして使い回し
            if (!_navCache.TryGetValue(item.TargetType, out var nav))
            {
                var page = (Page)Activator.CreateInstance(item.TargetType)!;
                nav = new NavigationPage(page);
                _navCache[item.TargetType] = nav;
            }

            Detail = nav;
            CloseFlyoutIfNeeded();
        }
        finally
        {
            _navigating = false;
        }
    }

    private void CloseFlyoutIfNeeded()
    {
        if (!((IFlyoutPageController)this).ShouldShowSplitMode)
            IsPresented = false;
    }
}

この実装により、以下のポイントが押さえられます。

  • 各ページ型について 最大 1 インスタンスの NavigationPage しか作られない
  • 同じメニューを連打しても Detail に同じナビゲーションを設定するだけで、新しい Page は生成されない
  • 「どのページに遷移するか」のロジックが FlyoutPage に集約されるため、メンテナンス性が高い

「ページ2へ移動」ボタンはメニューの SelectedItem を操作する

次に、「Page1 内のボタンから Page2 に移動したい」ケースを考えます。ありがちな実装は以下のようなものです。

// アンチパターン
await Navigation.PushAsync(new Page2());

これは簡単ですが、Flyout メニューを完全に無視しているため、

  • Page2 に移動してもメニューの選択状態が Page1 のまま
  • キャッシュしている Page2 インスタンスを使い回せない
  • 「ボタン遷移」と「メニュー遷移」の実装が二重管理になる

そこで、ボタンからも「メニューの SelectedItem を変更するだけ」という形に揃えます。

private void GoToPage2_Clicked(object sender, EventArgs e)
{
    var items = MyRootPage.FlyoutMenuInstance.ItemsSource
        as IList<FlyoutPageItem>;
    if (items is null) return;

    // 例:2番目の要素を Page2 とする
    var target = items[1];

    if (MyRootPage.FlyoutMenuInstance.SelectedItem != target)
    {
        // まだ選択されていなければ通常の遷移
        MyRootPage.FlyoutMenuInstance.SelectedItem = target;
    }
    else
    {
        // 同じページに“再遷移”したいケース
        MyRootPage.FlyoutMenuInstance.SelectedItem = null;
        MyRootPage.FlyoutMenuInstance.SelectedItem = target;
    }
}

ボタンからもメニューを経由することで、次のようなメリットがあります。

  • 遷移ロジックが FlyoutPage の OnSelectionChanged に一本化され、バグの温床が減る
  • メニューの選択状態と表示中のページが常に一致する
  • ページのライフサイクル(生成・破棄)をメニュー側でコントロールできる

ページの「再読み込み」は MVVM で行う

「ページを最新状態に更新したい」という要求はよくありますが、これを毎回 new XxxPage() で作り直すと、すぐにメモリ増加の原因になります。

正しいアプローチは、ViewModel のコマンドを呼び出してデータだけを更新することです。

public class RoutesPageViewModel : INotifyPropertyChanged
{
    public ObservableCollection<string> Routes { get; } = new();

    public ICommand RefreshCommand { get; }

    public RoutesPageViewModel()
    {
        RefreshCommand = new Command(Refresh);
        // 初期読み込みもここで
        Refresh();
    }

    private void Refresh()
    {
        // 例:サービスから最新データを取得して反映
        Routes.Clear();
        // 実際は API などから取得
        Routes.Add($"Updated at {DateTime.Now:T}");
    }

    public event PropertyChangedEventHandler? PropertyChanged;
}

ページ側は、単にコマンドをバインドするだけです。

<Button Text="現在のページを再読み込み"
        Command="{Binding RefreshCommand}" />

こうすることで、

  • ページインスタンスはそのまま再利用されるため、メモリが増えにくい
  • ViewModel が「データの単一の出入口」となり、テストもしやすくなる

Flyout メニューを使いながらパラメータを渡す設計

FlyoutPage を使い続ける場合でも、パラメータ付き遷移は十分に実現できます。ここでは代表的な 2 パターンを紹介します。

パターン1:BindingContext 経由でパラメータを注入する

キャッシュしているページに対して、遷移前に ViewModel にパラメータを渡します。

public interface IInitializeWithParameter
{
    void Apply(object? parameter);
}

public record DetailParam(int Id);

ViewModel 側(またはページ側)でインターフェイスを実装します。

public class DetailPageViewModel : IInitializeWithParameter
{
    public int Id { get; private set; }

    public void Apply(object? parameter)
    {
        if (parameter is DetailParam p)
        {
            Id = p.Id;
            // Id を使ってデータ再取得など
        }
    }
}

FlyoutPage の遷移処理で、DetailPage を選択したタイミングでパラメータを注入します。

// Flyout 遷移時の一部
var pageType = item.TargetType;

if (!_navCache.TryGetValue(pageType, out var nav))
{
    var page = (Page)Activator.CreateInstance(pageType)!;
    nav = new NavigationPage(page);
    _navCache[pageType] = nav;
}

// パラメータを ViewModel に注入(必要な場合のみ)
if (nav.CurrentPage.BindingContext is IInitializeWithParameter vm)
{
    vm.Apply(new DetailParam(id: 123));
}

Detail = nav;

この方式のメリットは、

  • ページを再生成する必要がない
  • ViewModel がパラメータ受け取りの責務を持つため、テストしやすい
  • パラメータ型を record などで定義しておけば、型安全に扱える

パターン2:Shell に移行できるならルーティング+クエリパラメータ

新規開発や大規模リファクタリングが可能であれば、Shell へ移行してルーティング+クエリパラメータの組み合わせを強くおすすめします。Flyout メニューも Shell の Flyout として自然に記述できます。

// AppShell.xaml.cs などでルート登録
Routing.RegisterRoute(nameof(DetailPage), typeof(DetailPage));

// 送る側
await Shell.Current.GoToAsync(nameof(DetailPage), new Dictionary<string, object>
{
    ["Id"] = 123
});

受け取る側は [QueryProperty] 属性でシンプルに書けます。

[QueryProperty(nameof(Id), "Id")]
public partial class DetailPage : ContentPage
{
    public int Id { get; set; }

    public DetailPage()
    {
        InitializeComponent();
    }

    protected override void OnAppearing()
    {
        base.OnAppearing();
        // Id を元にデータ取得など
    }
}

Shell を使うと、

  • URL ライクなルーティングで画面遷移を記述できる
  • ディープリンクや状態復元も組み込みでサポートされる
  • Flyout メニューの定義も XAML ベースで整理される

既存の FlyoutPage ベースで当面運用したい場合はパターン1を、長期的に見直す場合は Shell への移行を検討するとよいでしょう。

ページ遷移とメニュー選択状態を同期する

「メニューをクリックしたとき」は SelectedItem が自動で設定されますが、「ボタンからメニューを経由して遷移したとき」や、「アプリ起動直後」にも UI の状態を合わせておくと UX が安定します。

起動時に表示中ページから SelectedItem を合わせる

アプリ起動直後やログイン遷移直後など、Detail にあらかじめ設定したページがある場合、そのページ型から逆引きして SelectedItem を調整します。

private void SyncSelectedItemWithCurrentPage()
{
    if (Detail is not NavigationPage nav ||
        nav.CurrentPage is null ||
        FlyoutMenuInstance.ItemsSource is not IEnumerable<FlyoutPageItem> items)
    {
        return;
    }

    var currentType = nav.CurrentPage.GetType();
    var matched = items.FirstOrDefault(x => x.TargetType == currentType);
    if (matched != null)
    {
        FlyoutMenuInstance.SelectedItem = matched;
    }
}

このメソッドを MyRootPage のコンストラクタ末尾や起動後の適切なタイミングで呼び出すことで、「表示されているページ」と「メニューの選択状態」がずれないようにできます。

ホバー表示(マウスオーバー)の実装例

.NET MAUI では、モバイル(タッチ)ではホバーの概念がありませんが、Windows などデスクトップ向けにはホバー表現があると使い勝手が向上します。ここでは PointerGestureRecognizer と VisualStateManager を組み合わせて実装します。

XAML 側:VisualState と PointerGestureRecognizer

<DataTemplate x:DataType="vm:FlyoutPageItem">
  <Grid x:Name="Root" Padding="8">
    <Grid.GestureRecognizers>
      <PointerGestureRecognizer
          PointerEntered="OnPointerEntered"
          PointerExited="OnPointerExited" />
    </Grid.GestureRecognizers>

    <VisualStateManager.VisualStateGroups>
      <VisualStateGroup x:Name="HoverStates">
        <VisualState x:Name="Normal" />
        <VisualState x:Name="Hover">
          <VisualState.Setters>
            <Setter Property="BackgroundColor" Value="#22000000" />
          </VisualState.Setters>
        </VisualState>
      </VisualStateGroup>
    </VisualStateManager.VisualStateGroups>

    <HorizontalStackLayout Spacing="8">
      <Label Text="{Binding Title}" VerticalOptions="Center" />
    </HorizontalStackLayout>
  </Grid>
</DataTemplate>

コードビハインド側:VisualState 切り替え

void OnPointerEntered(object sender, EventArgs e)
    => VisualStateManager.GoToState((VisualElement)sender, "Hover");

void OnPointerExited(object sender, EventArgs e)
    => VisualStateManager.GoToState((VisualElement)sender, "Normal");

このようにしておくと、マウス操作時には Hover 状態が効き、タッチデバイスでは単に無視されます。PC 向けの UI として違和感のない挙動になります。

メモリリークを防ぐためのチェックリスト

最後に、Flyout ナビゲーション周りで特に気を付けたいメモリ関連のポイントをチェックリスト形式でまとめます。

項目推奨パターン避けたいパターン
ページ生成メニュー種類ごとに 1 インスタンスをキャッシュメニュークリックごとに new XxxPage()
ナビゲーションCollectionView.SelectedItem からのみ遷移を開始ボタンごとに個別に PushAsync / Detail = new NavigationPage()
同一ページへの連打現在表示中のページ型と比較して遷移をスキップ連打するたびに新しいページを積み上げる
画面更新ViewModel のコマンド(例:RefreshCommand)でデータだけ更新ページを再生成して NavigationStack に積み直す
タイマー・イベントOnAppearing で登録、OnDisappearing で解除コンストラクタで登録しっぱなしで解除しない
大きな画像・ストリームDispose や using などで明示的に解放ページが消えるのを期待して暗黙に解放される前提で扱う

実際にメモリの挙動を確認する際は、以下のような順番で確認すると原因を切り分けやすくなります。

  1. メニューを連打しても、ページインスタンス数が増えないか(Visual Studio のメモリプロファイラで確認)
  2. 特定のページを何度も開閉してもメモリが戻るか(タイマー・イベント解除漏れの有無)
  3. 画像一覧やストリームを扱うページで、動作後にメモリの山が残らないか

まとめ:Flyout ナビゲーションを「単一の出入口」で設計する

本記事では、.NET MAUI の Flyout ナビゲーションにおけるメモリ増加(リーク)対策と、ボタン・メニューの遷移ロジックの整理方法を解説しました。

  • メモリ増加の主因は、画面を都度 new して使い捨てていること
  • CollectionView.SelectedItem を遷移の唯一の入口とし、ページ/NavigationPage をキャッシュして使い回すことで、連打してもインスタンスが増えない設計にできる
  • 「ページ2へ移動」ボタンも、Flyout メニューの SelectedItem を書き換えることで 遷移ロジックを一元化できる
  • 画面の再読み込みは ViewModel のコマンドでデータを更新し、ページ再生成に頼らない
  • パラメータ受け渡しは、FlyoutPage 継続なら BindingContext 注入、大きな改修が可能なら Shell のルーティングを検討する
  • デスクトップ向けには、PointerGestureRecognizer と VisualStateManager でホバー表現を加えると UX が向上する

Flyout ナビゲーションを「どこから遷移しても必ずこの処理を通る」という単一の出入口として設計できると、メモリリーク対策だけでなく、デザイン変更や機能追加のたびに修正箇所が増える、といった悩みも大きく減ります。既存コードで new XxxPage() が散在している場合は、まず「メニュー遷移の一元化」と「ページキャッシュ」から着手してみてください。

この記事を書いた人

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

コメント

コメントする

目次