.NET MAUIでも、WPFのApplication.DispatcherUnhandledExceptionのように「未処理例外を一括で拾ってログや後始末をしたい」と感じる場面があります。本記事は.NET 7/Visual Studio 2022のMAUIを前提に、使える例外フック、実装例、注意点、そしてクラッシュを減らす実務対策まで整理します。
.NET MAUIに「WPFのDispatcherUnhandledException相当」が見当たらない理由
WPFはWindows専用のUIフレームワークで、UIスレッド(Dispatcher)上で未処理になった例外をアプリ側に通知する仕組みとして、Application.DispatcherUnhandledExceptionが用意されています。最大の特徴は、ハンドラ内でe.Handled = trueのように「その例外を処理済みにして、アプリのクラッシュを回避する」余地がある点です。
一方、.NET MAUIはWindowsだけでなくAndroid/iOS/macOSなど複数プラットフォームの上に成り立ちます。UI実装も、WindowsならWinUI、AndroidならAndroidランタイム、iOSならUIKit…というように土台が異なります。そのため、WPFのDispatcherUnhandledExceptionと完全に同じ思想・挙動の「単一API」をMAUIが標準で提供しづらい、という事情があります。
しかし「アプリが落ちる直前に、ログを残したい」「セッション/トークンを閉じたい」「最低限の後始末をしたい」というニーズ自体はMAUIでも同じです。そこで使うのが、WPF専用ではなく.NET共通の“最終手段の例外フック”です。
結論:MAUIでも.NET共通の“最後のフック”は使える
MAUIでまず押さえるべき“グローバル例外フック”は次の2つです。
- AppDomain.CurrentDomain.UnhandledException:未処理例外がプロセスを落とす直前に通知されうる(ただし「落ちるのを止める」用途ではない)
- TaskScheduler.UnobservedTaskException:観測されなかったTask例外を通知(ただしリアルタイムではない/保険的)
| 観点 | WPF(DispatcherUnhandledException) | MAUI(.NET共通フック) | 実務上のポイント |
|---|---|---|---|
| 主戦場 | UIスレッド(Dispatcher) | プロセス全体/Task | UI限定ではなく、全体の最後の通知として使う |
| クラッシュ回避 | e.Handled = true で回避できるケースあり | 基本的に回避目的ではない(ログ・後始末向け) | “落ちない設計”は発生源でのtry/catchと例外観測が本命 |
| 後始末(HTTP等) | 比較的余裕があることも | 終了が迫るため完了保証しにくい | 短時間・ベストエフォート+次回起動で回収する設計が堅い |
| クロスプラットフォーム | Windows限定 | .NETとして共通 | まず共通フック、その後に必要ならプラットフォーム別フック |
AppDomain.CurrentDomain.UnhandledException:できること/できないこと
AppDomain.CurrentDomain.UnhandledExceptionは、未処理例外が上位に伝播し、最終的にアプリ(プロセス)が終了しうるタイミングで呼ばれるイベントです。MAUIでも利用できます。
ただし重要な注意点があります。
- “クラッシュを防ぐ仕組み”ではない:イベントを購読しても、プロセス終了は基本的に止められません。
- ハンドラ内の処理は最小限に:この段階ではアプリの状態が不安定だったり、OSが強制終了に向かっていたりします。
- 同期イベントである:awaitで長々と処理する前提に向きません(後述の「次回起動で回収」が効きます)。
つまり、このイベントは「最後にログを残す」「最低限のフラグを書き出す」「可能なら短時間のクリーンアップを試す」といった“保険”として扱うのが現実的です。
TaskScheduler.UnobservedTaskException:観測漏れの保険(リアルタイムではない)
TaskScheduler.UnobservedTaskExceptionは、Taskが例外で失敗したのに、誰にもawaitされず観測されなかったときに通知される仕組みです。特に「fire-and-forget(投げっぱなし)」にしてしまったTaskの例外を拾う保険になります。
ただし、これも万能ではありません。
- 発火タイミングはGC次第:例外が起きた“その瞬間”ではなく、未観測のTaskが回収される頃に呼ばれます。
- 根本解決にならない:本来はawaitして例外を観測すべきものが漏れているサインです。
そのため、UnobservedTaskExceptionは「漏れの検知」「ログ収集」には有効ですが、トークン無効化などの“時間が重要な処理”の起点としては期待しすぎないほうが安全です。
実装場所が重要:例外フックは“できるだけ早く”登録する
.NET MAUIのテンプレート構成(.NET 7/Visual Studio 2022)であれば、共通フックの登録場所として扱いやすいのはMauiProgram.csです。アプリ起動のかなり早い段階で動くため、「できるだけ早くグローバルハンドラを付けたい」という目的に合います。
以下は、ログ+最低限の後始末(ベストエフォート)までを行うイメージです。WordPressに貼り付けても崩れにくいよう、コード中のジェネリクス記号はHTMLエスケープしています。
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Maui.ApplicationModel;
public static class GlobalExceptionHook
{
private static IServiceProvider? _services;
private static int _handling;
public static void Register(IServiceProvider services)
{
_services = services;
AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandledException;
TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
}
private static void OnAppDomainUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
var ex = e.ExceptionObject as Exception ?? new Exception("UnhandledException: unknown ExceptionObject");
Handle(ex, isTerminating: e.IsTerminating, source: "AppDomain.CurrentDomain.UnhandledException");
}
private static void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
// AggregateExceptionで来ることが多い
Handle(e.Exception, isTerminating: false, source: "TaskScheduler.UnobservedTaskException");
// 既定ではクラッシュしない環境でも、ログ目的で「観測した」扱いにしておく
e.SetObserved();
}
private static void Handle(Exception ex, bool isTerminating, string source)
{
// クラッシュ直前は例外が連鎖しやすいので、最低限の二重実行防止
if (Interlocked.Exchange(ref _handling, 1) == 1) return;
try
{
var logger = _services?.GetRequiredService<ILoggerFactory>().CreateLogger("GlobalException");
logger.LogError(ex, "Unhandled exception caught. Source={Source} Terminating={Terminating}", source, isTerminating);
// UI通知は「できたらやる」。ここで確実性を求めない
MainThread.BeginInvokeOnMainThread(async () =>
{
try
{
var app = Microsoft.Maui.Controls.Application.Current;
var page = app?.MainPage;
if (page != null)
{
await page.DisplayAlert("エラー", "予期しないエラーが発生しました。アプリを再起動してください。", "OK");
}
}
catch
{
// UI表示に失敗しても二次障害にしない
}
});
// 後始末は短時間のベストエフォートで。完了保証はできない
_ = BestEffortCleanupAsync(logger, ex);
}
catch
{
// グローバルハンドラ内で例外を投げると最悪なので握りつぶす
}
}
private static async Task BestEffortCleanupAsync(ILogger logger, Exception ex)
{
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(1.0));
// 例:トークン無効化を試す(実装はアプリに合わせる)
await TokenRevoker.RevokeAsync(cts.Token);
}
catch (Exception cleanupEx)
{
logger.LogWarning(cleanupEx, "Cleanup failed (best effort).");
}
}
}
MauiProgram.cs側は次のように呼び出します。
public static class MauiProgram
{
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
// いつものDI登録やフォント設定など…
// builder.Services.AddSingleton<...>();
var app = builder.Build();
// Build後にServicesが確定するので、ここで登録するのが分かりやすい
GlobalExceptionHook.Register(app.Services);
return app;
}
}
UIスレッドで処理したいとき:DispatcherとMainThreadの使い分け
未処理例外を拾った後、「エラーダイアログを表示したい」「特定のUIを閉じたい」といったUI操作をしたくなることがあります。ただし、例外フックはUIスレッドで呼ばれるとは限りません。そこで、UIスレッドへ投げ直す手段を用意します。
| 手段 | 例 | 向いている場面 | 注意点 |
|---|---|---|---|
| MainThread.BeginInvokeOnMainThread | UIスレッドへ確実に投げる | “とりあえずUIへ投げたい”とき | クラッシュ直前は実行されない可能性もある |
| Application.Current.Dispatcher | Dispatcher.Dispatch(() => { … }) | 既にアプリが立ち上がっておりDispatcherが取れるとき | Application.Currentがnullのタイミングに注意 |
Dispatcherを使う例は次の通りです。
var dispatcher = Microsoft.Maui.Controls.Application.Current?.Dispatcher;
dispatcher?.Dispatch(() =>
{
// ここはUIスレッド
// 例:画面遷移や表示更新など
});
ただし、「未処理例外発生=アプリ終了が迫っている」状態でUIを操作するのは成功率が高くありません。ユーザーに確実に通知したいなら、クラッシュ後の再起動時に「前回異常終了した」ことを検知して案内する設計のほうが安定します(後述)。
未処理例外時に「トークンを閉じる」「HTTPで後始末」する際の落とし穴
ご質問の核心はここで、たとえば以下のような後始末を想定しているケースが多いはずです。
- OAuth/OIDCのリフレッシュトークンを無効化したい
- サーバー側に「強制ログアウト」を通知したい
- 中途半端なセッションを閉じたい(サーバーのロック解放など)
結論として、未処理例外フック内でHTTP呼び出しを行うこと自体は可能です。ただし、次の制約を受けます。
- 通信完了を保証できない:プロセスがすぐ落ちる、OSがアプリを停止する、ネットワークが切れている等。
- ハンドラが同期である:await前提のきれいな制御フローになりにくい。
- 二次障害の危険:ハンドラ内の例外で、ログすら残らない最悪パターンになりやすい。
そこで実務では「未処理例外時の後始末」は次の発想で設計すると堅くなります。
| やりたいこと | 未処理例外フック内 | 次回起動時 | おすすめ |
|---|---|---|---|
| ログ保存 | ◎(最優先) | ○(前回分の送信) | フック内はローカル保存、送信は次回/バックグラウンド |
| トークン無効化(HTTP) | △(短時間のベストエフォート) | ◎(確実性が上がる) | フック内は「フラグ保存+短タイムアウトで試す」、本命は次回回収 |
| UIダイアログ表示 | △ | ◎(起動時に案内) | クラッシュ直前より起動時の方が成功率が高い |
| 状態復旧(複雑な処理) | × | ○ | 未処理例外フックで複雑化させない |
実務で一番効く設計:「次回起動で回収する」
未処理例外が起きた瞬間にHTTPで何かを完了させようとすると不確実です。そこで、次の二段構えが実務で強いです。
- 未処理例外フック内:「後でやるべき後始末がある」ことをローカルに記録する(フラグやキュー)+可能なら短いタイムアウトでベストエフォート実行
- 次回起動時:記録を読み取り、落ち着いた環境で確実に後始末(トークン無効化、ログ送信など)を実行する
たとえば「トークン無効化が必要」をフラグ化し、次回起動時に回収する例です。
using Microsoft.Maui.Storage;
public static class CrashRecovery
{
private const string NeedRevokeKey = "need_revoke_token";
public static void MarkNeedRevoke()
{
// 例:フラグだけ保存(機密情報はSecureStorageへ)
Preferences.Set(NeedRevokeKey, true);
}
public static bool ConsumeNeedRevoke()
{
var need = Preferences.Get(NeedRevokeKey, false);
if (need)
{
Preferences.Set(NeedRevokeKey, false);
}
return need;
}
}
未処理例外フック内では、HTTPを無理に完遂しようとするより、まずフラグを書きます。
// グローバル例外ハンドラ内(例)
CrashRecovery.MarkNeedRevoke();
そして起動時(Appのコンストラクタや最初のページ表示直後など)に回収します。
public partial class App : Microsoft.Maui.Controls.Application
{
private readonly ITokenRevoker _revoker;
public App(ITokenRevoker revoker)
{
InitializeComponent();
MainPage = new AppShell();
_revoker = revoker;
// 起動後に回収(UI起動を妨げない)
_ = TryRecoverAsync();
}
private async Task TryRecoverAsync()
{
if (!CrashRecovery.ConsumeNeedRevoke())
return;
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await _revoker.RevokeAsync(cts.Token);
}
catch
{
// 失敗しても、次回以降に再試行する設計もあり
CrashRecovery.MarkNeedRevoke();
}
}
}
この方式だと、クラッシュ直前の不安定な状態に賭けず、“確実に後始末が完了する可能性”が大きく上がります。セキュリティ要件が厳しい場合でも「フック内は記録だけ」「次回起動で速やかに無効化」という設計は現実解になりやすいです。
クラッシュ回避を狙うなら:グローバルで握りつぶすより“発生源で潰す”
WPFの感覚だと「最後に全部拾ってe.Handledで握りつぶせばいい」と考えがちですが、MAUIではそのやり方は噛み合いません。クラッシュ回避を本気で狙うなら、次の原則が効きます。
| やること | 狙い | 具体例 |
|---|---|---|
| イベントハンドラは例外を外に出さない | UIスレッドを落とさない | ボタン押下、タイマー、メッセージ購読などでtry/catch |
| async voidを避ける | 例外が観測不能になりやすい | 可能ならasync Taskへ分離し、呼び出し側で例外処理 |
| Taskは必ずawaitして例外を観測 | UnobservedTaskException頼みをやめる | 並列実行はTask.WhenAll、個別にtry/catch |
| 投げっぱなしTaskには“安全な投げっぱなし”を用意 | 漏れを構造的に減らす | FireAndForgetSafeAsync拡張メソッドを用意 |
投げっぱなしTaskの事故を減らすための、シンプルな拡張メソッド例です。
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
public static class TaskExtensions
{
public static void FireAndForgetSafe(this Task task, ILogger logger, string context)
{
_ = task.ContinueWith(t =>
{
try
{
if (t.Exception != null)
{
logger.LogError(t.Exception, "Fire-and-forget task failed. Context={Context}", context);
}
}
catch
{
// ログすら落ちないように
}
}, TaskContinuationOptions.OnlyOnFaulted);
}
}
「awaitしない」ことが必要な場面はゼロにはできません。だからこそ、投げっぱなしを“標準化して安全にする”のが、実務では効きます。
プラットフォーム別の例外フックも必要になるケース
基本は.NET共通フックで十分なことが多いですが、次のような要件がある場合は、プラットフォーム別のフックも検討対象になります。
- 特定プラットフォーム(例:Windowsだけ)でUI例外をより詳細に取りたい
- OS/ランタイム側のクラッシュレポートと紐付けたい
- プラットフォーム固有のイベント(WinUIのUnhandledExceptionなど)で追加情報を得たい
Windows(MAUI + WinUI)での補強例
MAUIのWindowsプロジェクトには Platforms/Windows/App.xaml.cs があり、ここはWinUI側のアプリクラス(MauiWinUIApplication)です。WinUIのUnhandledExceptionを購読できます。
using Microsoft.UI.Xaml;
namespace YourAppNamespace;
public partial class App : MauiWinUIApplication
{
public App()
{
this.UnhandledException += (s, e) =>
{
// ここでログを取る、必要なら e.Handled を検討(状態破壊リスクあり)
// e.Exception で例外が取れる
};
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
ただし、例外を握りつぶして継続できたとしても、アプリ状態が壊れている可能性があります。継続運用するより、ログ採取→安全に終了→再起動の方がユーザー体験としてマシなことも多いです。
Androidでの補強例
Androidではランタイム側のフック(例:UnhandledExceptionRaiser)を併用する設計もあります。Platforms/Android/MainApplication.cs等の起動ポイントで登録します。
using Android.App;
using Android.Runtime;
namespace YourAppNamespace;
[Application]
public class MainApplication : MauiApplication
{
public MainApplication(nint handle, JniHandleOwnership ownership) : base(handle, ownership) { }
public override void OnCreate()
{
base.OnCreate();
AndroidEnvironment.UnhandledExceptionRaiser += (s, e) =>
{
// e.Exception で例外が取れる
// ログ採取・フラグ保存など(完了保証しない)
// e.Handled をtrueにして継続する設計は慎重に(状態破壊リスク)
};
}
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
}
iOSでの補強例
iOSはOS側の制約が強く、クラッシュ直前に何でもできるわけではありません。現実的には、.NET共通フックでのログ+フラグ保存、そして次回起動時の回収が特に効きやすい領域です。iOS側の起動ポイント(Platforms/iOS/AppDelegate.cs)で共通フックの登録状況を確認し、必要なら追加のログ経路を用意する、という方針が安全です。
ログ設計のコツ:未処理例外で“最低限これだけは”残す
未処理例外は再現が難しいことが多く、ログの質が調査コストを大きく左右します。グローバルフックで残すべき情報を、最初から決めておくと強いです。
| 項目 | 例 | 理由 |
|---|---|---|
| 例外の型・メッセージ | NullReferenceException / メッセージ | 一次切り分けが速い |
| スタックトレース | ex.ToString() | 原因箇所の特定に必須 |
| 発火元(source) | AppDomain / UnobservedTask | どの経路の例外か分かる |
| アプリ情報 | バージョン、ビルド番号 | 特定バージョンだけの不具合を追える |
| 端末・OS情報 | OSバージョン、機種 | プラットフォーム依存の再現性に効く |
| 直前の操作(任意) | 画面名、コマンド名 | 再現手順推定の助け |
「ログ送信」まで未処理例外フックでやりたくなることもありますが、通信は不確実になりがちです。まずはローカル保存(ファイル、Preferences、SecureStorage等)を優先し、送信は次回起動時や、アプリが落ち着いたタイミングで行う設計が安定します。
まとめ:MAUIのグローバル例外フックは“保険”、本命は例外を観測する設計
.NET MAUIで「WPFみたいに未処理例外をグローバルに捕まえたい」とき、WPF専用のDispatcherUnhandledExceptionに完全一致する仕組みは基本的にありません。その代わり、.NET共通のAppDomain.CurrentDomain.UnhandledExceptionとTaskScheduler.UnobservedTaskExceptionが“最後のフック”として使えます。
ただし、これらは「落ちないようにする」ための魔法のスイッチではなく、主目的はログ採取・終了処理・最低限のクリーンアップです。HTTPでトークン無効化などを行う場合も、完了保証が難しいため、短時間のベストエフォート+次回起動で回収という二段構えが実務的に強い選択肢になります。
最終的には、グローバルフックを“保険”として整備しつつ、イベントハンドラや非同期処理で例外を観測し、クラッシュを減らす設計(async void回避、await徹底、投げっぱなしTaskの標準化)が、.NET MAUIで安定運用する最短ルートです。

コメント