.NET 9(.NET MAUI 9)で MainPage が非推奨に?CreateWindow でメイン画面を作る理由と移行・複数ウィンドウ完全ガイド

.NET 9(.NET MAUI 9)のプロジェクトを作ると、App.xaml.cs に CreateWindow の override が現れて「MainPage はもう使わないの?」と混乱しがちです。この記事では、仕様変更の理由、Mac/Windows で共通に考えるポイント、複数ウィンドウの作り方、そして window.Stopped に処理を付ける際の落とし穴まで、実務目線で整理します。

目次

MainPage から CreateWindow へ:まず押さえる結論

.NET MAUI 9(.NET 9 世代)では、アプリの最初の画面を Application.MainPage で決める従来の書き方が「非推奨(Obsolete)」になり、代わりに App クラスで CreateWindow を override して Window を作り、その Window に最初の Page(多くは AppShell)を設定するのが基本動線になりました。

ポイントを先にまとめると、次のとおりです。

  • 今後の標準は CreateWindow で new Window(new AppShell()):テンプレートの形は「推奨というより標準ルート」です。
  • MainPage はまだ動くが将来削除予定:挙動は変わらないが、将来の破壊的変更を避けるなら移行しておくのが安全です。
  • Mac / Windows でも同じ考え方:Window 中心の設計はプラットフォーム共通の流れです。
  • 複数ウィンドウは OpenWindow で追加:Application.Current.Windows で管理し、必要に応じて CloseWindow や ActivateWindow を使います。
  • Stopped は「終了」ではない:ウィンドウが見えなくなったタイミングで呼ばれ、復帰が保証されません。ログアウトのような重要処理を置くなら設計の再確認が必要です。

なぜ仕様が変わったのか:Window を中心にした設計へ

この変更は「MainPage が嫌いになったから」ではなく、MAUI の内部構造に合わせて、開発者が正しい抽象(Window)を意識しやすくするためです。

Microsoft Learn の「.NET 9 の .NET MAUI の新機能」では、MainPage に Page を入れると内部的には Window.Page を設定しているだけで、MainPage が Obsolete になっても挙動は変わらないと説明されています。つまり「内部でやっていたことを、開発者が明示する形にした」変更です。

そして、Window を前面に出すことには、実務上メリットが2つあります。

狙い開発者にとってのメリット具体的に何が楽になるか
ライフサイクルを Window 中心に整理「アプリ全体」ではなく「ウィンドウ単位」でイベントを扱える停止・復帰・破棄などの扱いを Window イベント(Created/Stopped/Destroying など)で統一しやすい
複数ウィンドウを前提化MainPage という「単一前提の入口」から脱却できるApplication.Current.Windows でウィンドウ群を管理し、追加・アクティブ化・クローズの API が使える

デスクトップ(Windows / Mac Catalyst)では「ウィンドウ」が当たり前の概念ですし、iPadOS などでも複数ウィンドウ(複数シーン)が実用的です。MAUI が最初からマルチウィンドウを想定している以上、入口を Window に寄せるのは自然な流れだと考えると腑に落ちます。

テンプレートの CreateWindow を正しく読む

.NET 9 の MAUI テンプレートでは、App.xaml.cs がだいたい次のようになります(Shell テンプレートの場合)。

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

    protected override Window CreateWindow(IActivationState? activationState)
    {
        return new Window(new AppShell());
    }
}

ここで重要なのは、単に「MainPage の代わりに書く場所が変わった」だけではなく、CreateWindow が “ウィンドウを作るためのフック” になったことです。シグネチャにも IActivationState? が登場し、復帰・再生成などの文脈を受け取れる形になっています。

また、公式の Window ドキュメントでも「既定では CreateWindow を override して Window を作る」例が示されており、テンプレートの形がドキュメントと整合しています。

「CreateWindow を必ず書く必要がある?」への実務的な回答

結論から言うと、アプリとして成立させるには、最終的に “どこかで Window に Page を持たせる” 必要があります。

ただし、今この瞬間の .NET MAUI 9 では MainPage 自体は残っていて、MainPage を設定して起動する従来コードも動きます(ただし Obsolete 警告が出て、将来は削除予定)。そのため「コンパイルが通るようにするだけ」なら MainPage で凌げるケースもあります。

とはいえ、チーム開発や長期運用を前提にするなら、判断基準はシンプルです。

選択肢短期中長期おすすめ度
CreateWindow で new Window(new AppShell())テンプレート通りで迷いが少ないMainPage 削除の影響を受けにくい高
従来通り MainPage = ...移植コストは低いObsolete → 将来削除で詰む可能性低
base.CreateWindow を使って Window を加工既定 Window の設定を活かせるイベント購読やサイズ調整などを集約できる中

「テンプレートがそうなっているから合わせる」だけでなく、Window 中心でライフサイクルやマルチウィンドウを扱える設計に寄せるのが、結局いちばんトラブルが少ないです。

MainPage を触っていたコードの移行ポイント

一番困るのは、既存コードに「Application.Current.MainPage を参照・差し替えする」箇所が点在している場合です。公式ドキュメントでは、単一ウィンドウのアプリなら Application.Current.Windows[0].Page を使うように案内されています。

置き換え早見表

やりたいこと旧(MainPage)新(Window.Page)補足
現在のルートページを取得Application.Current.MainPageApplication.Current.Windows[0].Page単一ウィンドウ前提なら “0番” で割り切れる
ルートページを差し替えるApplication.Current.MainPage = new ...Application.Current.Windows[0].Page = new ...ログイン後に Shell を差し替える等で利用
画面(要素)から所属ウィンドウへ辿る(工夫が必要)this.Window → this.Window.PageVisualElement に Window プロパティがある
複数ウィンドウから対象を探す想定が薄いApplication.Current.Windows を列挙ウィンドウ識別子やタイトルで絞る戦略が必要

単一ウィンドウでの差し替え例(ログイン後にルートを変える)

「ログインが完了したらアプリのトップ(Shell)に切り替える」「サインアウトしたらログイン画面に戻す」など、以前は MainPage を差し替えていた典型例は、次のように置き換えられます。

// 例: ログイン完了後にルートを差し替える
var window = Application.Current!.Windows[0];
window.Page = new AppShell();

// 例: サインアウトでログインページへ戻す(NavigationPage を使う例)
window.Page = new NavigationPage(new LoginPage());

ここでの注意点は、すでに複数ウィンドウを開くアプリに育つ可能性があるなら “0番決め打ち” を放置しないことです。最初は単一ウィンドウでも、将来ウィンドウ追加をすると途端に「どのウィンドウのページを差し替えるのか?」が問題になります。

複数ウィンドウの作り方:公式に沿った最短ルート

.NET MAUI の複数ウィンドウは、「Window を生成して OpenWindow する」が基本です。公式の Window ドキュメントにも、そのまま使えるコードが載っています。

新しいウィンドウを開く

// 例: 詳細画面を別ウィンドウで開く
var detailWindow = new Window(new DetailPage(itemId))
{
    Title = "詳細"
};

Application.Current?.OpenWindow(detailWindow);

OpenWindow を呼ぶと、Application.Current.Windows(読み取り専用リスト)にウィンドウ参照が保持されます。ここが「複数ウィンドウ前提の世界観」です。

ウィンドウを閉じる

// 特定のウィンドウを閉じる
Application.Current?.CloseWindow(detailWindow);

CloseWindow は API として提供されています。閉じる挙動をコードからコントロールしたい(たとえば“作業中なら確認ダイアログを出す”)場合も、Window イベントやアプリ側の設計で対応しやすくなります。

Mac / Windows で「前面に出す」

Mac Catalyst と Windows では、特定ウィンドウを前面に出す ActivateWindow も案内されています。複数ウィンドウのユースケース(ツールウィンドウ、プレビューウィンドウなど)では地味に効いてきます。

プラットフォーム別:複数ウィンドウ対応状況と追加設定

「Mac / Windows でも同じ?」に対する答えは「基本は同じ」ですが、複数ウィンドウの追加設定はプラットフォームで差があります。公式ドキュメントに沿って整理すると次のとおりです。

プラットフォーム複数ウィンドウ追加設定メモ
Windows対応基本不要デスクトップらしい複数ウィンドウが扱える
Mac Catalyst対応必要(SceneDelegate / Info.plist)iPadOS と同様に複数シーン設定が関わる
Android対応必要(LaunchMode を Multiple)既定の SingleTop のままだと意図通り動かないことがある
iPadOS対応必要(SceneDelegate / Info.plist)iPad のマルチタスク/複数シーンに紐づく
iPhone(iOS)非対応—公式に「iPhone では動かない」と明記

CreateWindow は「起動時の一回」ではない:ActivationState と再生成の現実

ここが落とし穴になりやすいのですが、CreateWindow は「アプリ起動時に一回呼ばれる場所」と決めつけないほうが安全です。

Microsoft Q&A では、CreateWindow(IActivationState) がウィンドウ生成が必要になったタイミングで呼ばれること、Android では Activity の再作成などで再度呼ばれる可能性があることが説明されています。つまり、CreateWindow に「一回しかやらないはずの初期化」を詰め込みすぎると、再生成時に状態が飛んで「勝手にログイン画面に戻った」などの不具合に見えることがあります。

そこで意識したいのが IActivationState の存在です。公式のアプリライフサイクルドキュメントでは、iOS / Mac Catalyst では Backgrounding イベントで状態を保存し、復帰時にその状態が CreateWindow の IActivationState 引数として渡される、といった流れが説明されています。

実務で効く考え方:CreateWindow は “WindowFactory” として設計する

  • 作るべきページ(Shell / Login / DeepLink 先など)を「状態から決める」
  • 状態の永続化(Preferences / SecureStorage 等)は “別サービス” に逃がす
  • CreateWindow 自体は軽く・副作用少なく(ネットワーク呼び出しを直で置かない)

たとえば「ログイン済みなら AppShell、未ログインなら LoginPage」を切り替えるだけでも、CreateWindow に “状態判定” を入れておくと、再生成に強くなります。

protected override Window CreateWindow(IActivationState? activationState)
{
    // 例: 認証状態をどこかのサービスで判定(擬似コード)
    Page root = _sessionService.IsSignedIn
        ? new AppShell()
        : new NavigationPage(new LoginPage());

    return new Window(root);
}

この方向性に寄せておくと、今後 MainPage が本当に削除されたときも移行コストが最小になります。

Mac の CreateWindow で window.Stopped に処理を付けるのは正しい?

「CreateWindow 内で Window を作ってイベント購読する」こと自体は、公式ドキュメントでも案内されている自然なやり方です。アプリライフサイクルのページでは、CreateWindow を override して Window インスタンスを作り、そこにライフサイクルイベント(Created など)を購読する例が示されています。

ただし、Stopped に「ログアウト」などの重要処理を置くのは注意が必要です。理由は大きく3つあります。

Stopped は「終了」ではなく「見えなくなった」

公式ドキュメントでは Stopped は「ウィンドウが見えなくなったとき」に発生し、そこから復帰する保証はない、とされています。推奨されるのは「長時間処理からの切断」「リソース消費の大きい処理のキャンセル」といった“後始末寄り”の内容です。

つまり、Stopped を “アプリ終了フック” と見なしてログアウト API を叩く設計にしてしまうと、たとえば「一時的にバックグラウンドに回っただけでログアウトする」といった UX 崩壊につながる可能性があります。

イベントハンドラ + async は失敗時に気づきにくい

window.Stopped += async (s, e) => { ... } のような形は、見た目は async Task っぽく見えても、イベントハンドラの都合で 実態は async void 相当になりやすく、例外が観測しづらいです。さらに catch (Exception) { } で握りつぶすと、障害解析がほぼ不可能になります。

どうしても非同期処理をぶら下げるなら、最低限「例外を記録する」「タイムアウトを設ける」「UI に触るなら Dispatcher 経由」くらいは入れておきたいところです。

protected override Window CreateWindow(IActivationState? activationState)
{
    var window = new Window(new AppShell());

    // fire-and-forget するなら、例外を必ず握ってログへ
    window.Stopped += (_, _) => _ = HandleWindowStoppedAsync();

    return window;
}

private async Task HandleWindowStoppedAsync()
{
    try
    {
        // 例: 長い処理は避け、短時間で終わる後始末に寄せる
        await _sessionService.PersistStateAsync();
        _networkService.CancelPendingRequests();
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Stopped handling failed.");
    }
}

CreateWindow 自体が複数回呼ばれる可能性

前述のとおり、プラットフォーム(特に Android)では Activity 再生成などで CreateWindow が再度呼ばれ得ます。Stopped を含むイベント購読を CreateWindow で行うなら、同じサービスに対して重複購読が起きないような設計(Window インスタンス単位で管理する、購読解除を Destroying に寄せる等)も考慮しておくと安全です。

Window ライフサイクルを “目的別” に使い分ける

「Stopped に置くべき処理/置くべきでない処理」を判断するために、Window のライフサイクルを整理しておきましょう。公式のアプリライフサイクルドキュメントでは、各イベントの意味が明確に書かれています。

イベントざっくり意味向いている処理避けたい処理
Createdウィンドウ作成時Window 固有の初期設定、軽い初期化重い IO、ネットワークでの必須処理
Activated / Deactivatedフォーカスを得た/失ったショートカット更新、フォーカス依存の UI 制御認証状態の破壊的変更
Stoppedウィンドウが見えなくなった長時間処理停止、リクエストキャンセル、状態保存(軽量)「必ず成功してほしい」ログアウト/課金確定など
Destroyingネイティブウィンドウ破棄購読解除、リソース解放、ネイティブ側フックのクリーンアップUI 操作、長い処理
Backgrounding(iOS/Mac Catalyst)バックグラウンド遷移/クローズOS が保持する State への保存、復帰に備えた最小データの退避重い処理、完了必須の通信

もし「セキュリティ上、ウィンドウが見えなくなったら即ログアウトしたい」という要件がある場合でも、Stopped で “API ログアウト完了を待ってから Quit する” のような強制終了設計は、プラットフォーム都合で完遂できないことがあります。設計としては次のような方向が堅いです。

  • サーバー通知(ログアウト API)は “ベストエフォート”:失敗しても次回起動で再試行できる設計にする
  • クライアント側のセッション無効化を優先:トークンを消す・鍵を破棄するなど、端末側だけで完結する処理を最優先にする
  • 重要アクションはユーザー操作に寄せる:明示的なログアウトボタン、画面ロック、一定時間無操作で再認証など

「どのウィンドウの Page を触るのか」問題を避けるコツ

Window 中心に移行すると、必ず出てくるのが「今操作しているのはどの Window?」という問いです。公式ドキュメントでは、アプリ側で Application.Current.Windows を使うだけでなく、各要素(ページ/ビュー)から Window にアクセスできることも案内されています。

たとえば、ボタンを押した画面の “所属ウィンドウ” に対してだけタイトルを変えたいなら次のように書けます。

// Page / View 側(VisualElement)で、所属 Window に触る例
this.Window.Title = "編集中";

こうしておくと、「Windows[0] 決め打ち」で別ウィンドウまで巻き込む事故を減らせます。複数ウィンドウを視野に入れるなら、特におすすめの書き方です。

よくあるハマりどころチェックリスト

  • CreateWindow に “一回だけのはず” の初期化を詰め込まない(再生成で二重初期化/状態リセットが起きる可能性)。
  • 単一ウィンドウ前提の Windows[0] 依存を、いつか外せる形にする(UI からは element.Window を優先)。
  • Stopped を「終了」と誤解しない(見えなくなっただけで発火し、復帰は保証されない)。
  • 複数ウィンドウはプラットフォームごとに追加設定が必要(Android の LaunchMode、iPadOS/Mac Catalyst の Scene 設定など)。
  • イベント購読は解除まで設計する(Destroying でのクリーンアップを意識)。

まとめ:.NET 9 の MAUI は「Window を起点に考える」と迷わない

.NET MAUI 9 で MainPage が Obsolete になり、CreateWindow でメイン画面を作る流れが前面に出たのは、MAUI が本来持っている “Window 中心・マルチウィンドウ前提” の設計を、開発者が素直に使える形に寄せたためです。

単一ウィンドウでも、移行の要点はシンプルです。

  • 最初の画面は CreateWindow で new Window(new AppShell())
  • MainPage を触っていた箇所は Windows[0].Page または element.Window.Page へ
  • Stopped は終了ではないので、重要処理は設計から見直す

これを押さえておけば、今後 MainPage が本当に削除されても慌てずに済み、複数ウィンドウやライフサイクルイベントを活かした “デスクトップらしい MAUI アプリ” に自然に進化させられます。

この記事を書いた人

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

コメント

コメントする

目次