.NET MAUIで未処理例外をグローバルに捕捉する方法|WPF DispatcherUnhandledException相当と実装例

.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)プロセス全体/TaskUI限定ではなく、全体の最後の通知として使う
クラッシュ回避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.BeginInvokeOnMainThreadUIスレッドへ確実に投げる“とりあえずUIへ投げたい”ときクラッシュ直前は実行されない可能性もある
Application.Current.DispatcherDispatcher.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で安定運用する最短ルートです。

この記事を書いた人

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

コメント

コメントする

目次