.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 相当 |
| D | handler.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 条件コンパイルの徹底までセットで入れておくと、将来の保守が楽になります。

コメント