.NET MAUI Windowsで「PlatformView cannot be null here」を解消する方法|ButtonHandler.Mapperで全ボタンのカーソルをHandに

.NET MAUI(Windows)でログイン後に画面遷移すると、動作は問題ないのにログへ「PlatformView cannot be null here」の例外が毎回出る――そんな現象は、HandlerChanged と PlatformView の扱いが原因です。本記事では原因の仕組みと、全ボタンのカーソルをHandにしつつ例外を出さない実装に整理します。

目次

現象:ログインは成功するのに、例外スタックトレースが毎回残る

対象は .NET MAUI の Windows 向けデスクトップアプリで、log4net などでアプリログを集約しているケースです。ログインボタンを押すと認証は正常に通り、App.Current.MainPage = new MyRootPage()(または別ページ)へ差し替えて画面遷移も完了します。ところが、その直後に次のような例外ログがほぼ毎回出力され、運用ログが「ノイズ」で埋まってしまいます。

System.InvalidOperationException: PlatformView cannot be null here
    at Microsoft.Maui.Handlers.ViewHandler`2.get_PlatformView()
    at Microsoft.Maui.Handlers.ButtonHandler.Microsoft.Maui.Handlers.IButtonHandler.get_PlatformView()
    at ...MauiProgram...CreateMauiApp... (省略)

重要なのは、ユーザー体験としてはログインも画面遷移も成功しており、画面がクラッシュするわけではない点です。例外が「握りつぶされている(または内部で処理されている)」だけに見えるため、現場では次のような困りごとが発生しがちです。

  • 監視基盤が例外ログをエラーとして検知し、誤検知のアラートが増える
  • 本当に追うべき障害ログが埋もれて調査効率が落ちる
  • FirstChanceException(初回例外)を拾う設定だと、内部で処理される例外もすべて出てしまう
項目内容現場での見え方
発生タイミングログイン成功後、MainPage差し替え直後「遷移は成功しているのに例外が出る」
影響範囲Windows(WinUI)で発生しやすいAndroid/iOS では再現しないこともある
直接原因HandlerChanged 内で handler.PlatformView を参照原因箇所が「起動時の共通設定」に見える

前提知識:MAUI の VirtualView・Handler・PlatformView の関係

今回の「PlatformView cannot be null here」を腹落ちさせるために、MAUI の UI を支える3要素を整理します。

  • VirtualView:MAUI 側の抽象ビュー(例:Microsoft.Maui.Controls.Button)
  • Handler:VirtualView とネイティブ実装をつなぐブリッジ(例:ButtonHandler)
  • PlatformView:プラットフォーム(Windows/WinUI)側のネイティブコントロール(例:Microsoft.UI.Xaml.Controls.Button)

MAUI は画面生成・破棄のタイミングで、Handler を「接続(Connect)」したり「切断(Disconnect)」したりします。ここで勘違いしやすいのが、Handler は一度付いたら不変ではなく、画面遷移や破棄の過程で付け替え・解放が起きるという点です。特に App.Current.MainPage を差し替える方式の遷移は、旧ページをまとめて破棄するため、旧ページ配下のボタン群も一気に切断・解放されます。

原因:HandlerChanged は「付いた時」だけでなく「外れる時」にも呼ばれる

問題になりやすいのは、アプリ全体のボタンのカーソルを Hand(指)にしたくて、ButtonHandler.Mapper.AppendToMapping(または ImageButtonHandler.Mapper.AppendToMapping)でグローバル設定を入れているケースです。たとえば次のように HandlerChanged をフックし、そこで handler.PlatformView を使って WinUI のボタンに対してカーソル変更を入れているとします。

Microsoft.Maui.Handlers.ButtonHandler.Mapper.AppendToMapping("Custom", (handler, view) =>
{
#if WINDOWS
    Button button = handler.VirtualView as Button;
    button.HandlerChanged += (sender, args) =>
    {
        Microsoft.UI.Xaml.Controls.Button btn = (Microsoft.UI.Xaml.Controls.Button)handler.PlatformView;
        btn.Loaded += (s2, e2) =>
        {
            MainThread.BeginInvokeOnMainThread(() =>
            {
                btn.ChangeCursor(
                    Microsoft.UI.Input.InputSystemCursor.Create(
                        Microsoft.UI.Input.InputSystemCursorShape.Hand));
            });
        };
    };
#endif
});

HandlerChanged は「ハンドラーが付いた/外れた」というライフサイクルイベントです。つまり、ログインページからルートページへ差し替えるとき、旧ページ配下のボタンは破棄される過程で Handler が切断され、HandlerChanged がもう一度呼ばれます。

このときの流れを、ログイン成功後のページ差し替えに沿って追うと次の通りです。

順番起きていること内部の状態
Aログインボタン押下で認証処理が走るLoginPage の Button は表示中、PlatformView も有効
Bログイン成功、App.Current.MainPage をルートへ差し替え旧ページが破棄フェーズに入り、Handler が切断される
C破棄の過程で HandlerChanged が再度発火すでに PlatformView が解放され、内部的に null 相当
Dhandler.PlatformView を参照getter が null を許容せず InvalidOperationException を投げる

ここで落とし穴になるのが、「じゃあ null チェックすれば良いのでは?」という発想です。MAUI の handler.PlatformView は単純なフィールドではなく、内部状態を検証する getter を持ちます。したがって、次のようなチェックも結局は getter 呼び出しになり、PlatformView が無いタイミングだと同じ例外が投げられます。

// これ自体が PlatformView の getter を呼ぶため、状態によっては例外が出る
if (handler.PlatformView != null)
{
    // ...
}

「ログイン後にだけ出る」「ページ差し替えの直後に出る」「UI の破棄タイミングに絡んでいる」――この3点が揃っている場合、原因はほぼ HandlerChanged と PlatformView 参照の組み合わせです。

解決策:HandlerChanged をやめ、マッピング内で PlatformView に直接設定する

根本方針はシンプルです。

  • HandlerChanged を使って PlatformView を後追いしない
  • AppendToMapping が呼ばれた時点(=PlatformView が生成されているタイミング)で、PlatformView に直接設定する

実務では次の修正が最も効きます。ポイントは、HandlerChanged の登録そのものを削除し、AppendToMapping の中で WinUI のボタン(PlatformView)に対し Loaded をフックしてカーソルを変えることです。

Microsoft.Maui.Handlers.ButtonHandler.Mapper.AppendToMapping("HandCursor", (handler, view) =>
{
#if WINDOWS
    if (handler.PlatformView != null)
    {
        var btn = (Microsoft.UI.Xaml.Controls.Button)handler.PlatformView;
        btn.Loaded += (s, e) =>
        {
            MainThread.BeginInvokeOnMainThread(() =>
            {
                btn.ChangeCursor(
                    Microsoft.UI.Input.InputSystemCursor.Create(
                        Microsoft.UI.Input.InputSystemCursorShape.Hand));
            });
        };
    }
#endif
});

この形にすると、旧ページ破棄時に HandlerChanged が再発火して PlatformView を触る、という経路が消えます。その結果、ログイン成功時に出ていた「PlatformView cannot be null here」例外が発生しなくなり、ログのノイズを除去できます。

より堅牢にするための実装パターン

上記の修正版でも十分ですが、運用で「地味に効く」改善ポイントがあります。特にアプリ規模が大きいほど、細かな積み重ねが保守性に効きます。

Loaded の多重登録を避けて一回だけ適用する

MAUI のマッピングは、状況によって同一コントロールに複数回呼ばれることがあります(再レイアウト、テンプレートの更新、Hot Reload など)。Loaded にラムダをそのまま追加していくと、理屈上は多重に登録される可能性が残ります。そこで、Loaded イベントで一度適用したら、自分自身を解除しておくと安心です。

Microsoft.Maui.Handlers.ButtonHandler.Mapper.AppendToMapping("HandCursor", (handler, view) =>
{
#if WINDOWS
    if (handler.PlatformView is Microsoft.UI.Xaml.Controls.Button btn)
    {
        Microsoft.UI.Xaml.RoutedEventHandler loaded = null;
        loaded = (s, e) =>
        {
            btn.Loaded -= loaded;

            // Loaded は UI スレッドで呼ばれるので、通常は BeginInvoke は必須ではありません。
            // ただしアプリの方針で統一したい場合は残してもOKです。
            btn.ChangeCursor(
                Microsoft.UI.Input.InputSystemCursor.Create(
                    Microsoft.UI.Input.InputSystemCursorShape.Hand));
        };

        btn.Loaded += loaded;
    }
#endif
});

この書き方のメリットは「イベントが積み上がらない」ことです。ログ上のノイズだけでなく、将来的なパフォーマンス劣化や思わぬ二重処理も避けられます。

ImageButton にも同じ方針で適用する

アプリ全体で「押せるものは Hand」を徹底したい場合、Button だけではなく ImageButton も対象になります。Windows の PlatformView はバージョンや内部実装で差が出ることがあるため、型を決め打ちしすぎず、まずは FrameworkElement として扱えるかを確認するのが安全です(ChangeCursor の実装が UIElement で使える前提)。

Microsoft.Maui.Handlers.ImageButtonHandler.Mapper.AppendToMapping("HandCursor", (handler, view) =>
{
#if WINDOWS
    if (handler.PlatformView is Microsoft.UI.Xaml.FrameworkElement element)
    {
        Microsoft.UI.Xaml.RoutedEventHandler loaded = null;
        loaded = (s, e) =>
        {
            element.Loaded -= loaded;
            element.ChangeCursor(
                Microsoft.UI.Input.InputSystemCursor.Create(
                    Microsoft.UI.Input.InputSystemCursorShape.Hand));
        };
        element.Loaded += loaded;
    }
#endif
});

この形なら、将来 MAUI の内部で ImageButton の PlatformView 型が変わっても、FrameworkElement の範囲で吸収できる可能性が上がります。

「全ボタン Hand」を必要な画面だけに限定したい場合

全体に適用するのが要件として過剰な場合は、グローバルな Mapper ではなく、対象を限定する設計も検討できます。

  • 特定のボタンだけに適用:カスタムコントロール(例:HandButton)を用意して、その型だけにマッピングを当てる
  • 特定の画面だけに適用:画面生成時にスタイルや Behavior を付与する(Windows のみ)
  • アクセシビリティ要件がある:カーソルだけでなく、フォーカス可視化・キーボード操作・タッチ領域も含めて統一設計する
方式向いている要件メリット注意点
Handler.Mapper でグローバル適用アプリ内の全ボタンを Hand にしたい設定箇所が1か所で済み、漏れが出ないアプリ全体に影響するため、キー名の衝突・二重登録に注意
画面/ボタン単位で個別適用一部のボタンだけ Hand にしたい影響範囲が明確で、例外的デザインに対応しやすい適用漏れが起きやすく、画面数が多いと保守が重くなる
カスタムコントロールで限定Hand にするボタンを明示したい意図がコード/ XAML 上で読みやすい移行コストがあり、既存画面が多いと置換が大変

設計の結論:全ボタンを Hand にしたいならグローバル実装で問題ない

「アプリ内のすべてのボタンのカーソルを Hand にしたい」という要件であれば、ButtonHandler.Mapper.AppendToMapping を使ったグローバル設定は妥当です。むしろ、ボタン単位で都度設定するほうが漏れ・揺れが出やすく、UI の一貫性を損ねる原因になります。

ただし、グローバル適用には次の実務ポイントがあります。

  • キー名を固定する:"HandCursor" のように意味が分かるキーを使い、他のカスタマイズと衝突しないようにする
  • 起動時に一度だけ登録する:MauiProgram.CreateMauiApp() の中で一度だけ実行される構成にする
  • Windows 専用に閉じる:#if WINDOWS を必ず付け、他プラットフォームに影響を出さない

メモリリークとパフォーマンスへの影響

「イベントをフックするとリークが心配」という声は自然ですが、今回の修正は次の点で比較的安全です。

  • 登録対象は PlatformView(ネイティブボタン)で、画面破棄と一緒に解放される
  • Loaded 内でイベント解除(Loaded -= loaded)しておけば、長期的にイベントが残り続ける可能性が下がる
  • 処理内容は「カーソルを一度設定する」だけで軽い

逆に、問題になりやすいのは次のパターンです。

  • HandlerChanged を使って、破棄フェーズでも handler.PlatformView を触ってしまう
  • イベント解除をせず、複数回 Loaded が積み上がる(画面の作りによっては起こり得る)
  • カーソル変更以外にも重い処理(画像生成や IO)を Loaded で毎回走らせる

「全ボタンに対して一回だけ軽い設定を入れる」範囲であれば、実務上は問題になりにくい設計です。気になる場合は、開発時に次の観点で確認すると安心です。

観点確認方法期待する状態
イベントの積み上がりLoaded で自分自身を解除しているか1ボタンにつき1回だけ適用される
ページ破棄後の参照HandlerChanged の利用有無を検索破棄フェーズで PlatformView を触らない
ログの静粛性ログイン→遷移を複数回繰り返す例外スタックトレースが出ない

実務チェックリスト:ログイン直後の例外ノイズを消すために見る場所

「PlatformView cannot be null here」が出たら、まずは次を順にチェックすると最短で潰せます。

  • MauiProgram.cs の Mapper 追加で HandlerChanged を使っていないか
  • イベント内で handler.PlatformView を参照していないか(特にページ破棄時に呼ばれ得るイベント)
  • 画面遷移が App.Current.MainPage = ... の差し替え方式か(旧ページの一括破棄が起こる)
  • 例外を FirstChanceException として拾っていないか(内部で処理される例外までログ化される)

さらに、同じ構造の不具合は Button 以外でも起きます。例えば EditorHandler や EntryHandler などで、PlatformView の破棄タイミングに絡むイベントから PlatformView を触ってしまうと同様のログ汚れにつながります。共通ルールとしては、次の2つがシンプルで強いです。

  • PlatformView を触るなら「生成直後(Mapper の中)」か「PlatformView 自身のイベント(Loaded など)」に閉じる
  • 破棄に近いライフサイクルイベント(HandlerChanged、Unloaded 相当など)では PlatformView を前提にしない

よくある質問

BeginInvokeOnMainThread は必要?

WinUI の Loaded は基本的に UI スレッドで発火するため、カーソル変更だけなら必須ではないことが多いです。ただし、アプリ内で「UI 更新はすべて MainThread 経由に統一する」方針がある場合は、残しても問題ありません。重要なのは、HandlerChanged から PlatformView を触らないことです。

ログには出るがアプリは落ちない。無視してよい?

放置しても致命傷にならないケースはありますが、運用ログの品質が下がると「本当に重要な例外」を見逃します。また、将来 MAUI の実装が変わったときに挙動が悪化する可能性もあります。UI のライフサイクルに沿った実装へ直しておくほうが長期的に安全です。

全ボタンを Hand にするのは UX 的に正しい?

Windows デスクトップでは、クリック可能な要素に Hand を出すのは一般的です。ただし、アプリがキーボード主体/タッチ主体の場合や、リンクとボタンの意味を分けたい場合は、Hand の適用範囲を見直す価値があります。設計の観点では「一貫性」と「誤認防止」のバランスを取り、必要なら限定適用へ切り替えられる構造にしておくと安心です。

まとめ:例外の原因は PlatformView そのものではなく、参照タイミング

「PlatformView cannot be null here」は、MAUI が想定するタイミング以外で PlatformView に触れたときに出やすい例外です。ログイン成功後に MainPage を差し替えるような遷移では、旧ページ配下のコントロールが破棄され、Handler が切断されます。その過程で HandlerChanged が再発火し、PlatformView が無い状態で getter を呼ぶと例外が出ます。

対処は、HandlerChanged をやめて、Mapper の中で PlatformView に直接カーソル設定を入れることです。これにより、アプリ全体のボタンを Hand に統一しつつ、ログのノイズも消せます。運用まで見据えるなら、Loaded の解除・キー名の整理・Windows 条件コンパイルの徹底までセットで入れておくと、将来の保守が楽になります。

この記事を書いた人

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

コメント

コメントする

目次