【WPF】未処理の例外処理を捕捉する方法

【WPF】未処理の例外処理を捕捉する方法

はじめに

WPF アプリケーション開発において、未処理の例外はアプリケーションのクラッシュや不安定な動作を引き起こす可能性があります。

今回の記事では、WPF アプリケーションで発生する未処理例外を効果的にハンドリングする方法について解説します。

具体的には、DispatcherUnhandledExceptionAppDomain.CurrentDomain.UnhandledExceptionTaskScheduler.UnobservedTaskExceptionの 3 つのイベントハンドラに焦点を当てます。

実行環境

  • OS: Windows11 22H2(22621.1555)
  • エディタ: Visual Studio 2022(17.3.6)
  • フレームワーク: .NET6
  • ライブラリ: NLog

サンプルアプリケーション

sample-application

コードビハインド

例外処理を呼び出すための処理は以下のようになっています。今回は 0 の除算のエラーを意図的に発生させるようにしています。

"MainWindow.xaml.cs
namespace Catching_Unhandled_Exceptions
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        /// <summary>
        /// UIスレッド
        /// </summary>
        /// <param name="sender">コントロールオブジェクト</param>
        /// <param name="e">イベント</param>
        private void DispatcherException_Click(object sender, RoutedEventArgs e)
        {

            try
            {
                // 意図した例外だった場合
                var zero1 = 0;
                var cal1 = 10 / zero1;
            }
            catch (Exception ex)
            {
                throw new ToDivisionByZeroException("DispatcherExceptionが発生しました。", ex);
            }
        }

        /// <summary>
        /// 致命的なエラー
        /// </summary>
        /// <param name="sender">コントロールオブジェクト</param>
        /// <param name="e">イベント</param>
        private void AppDomainException_Click(object sender, RoutedEventArgs e)
        {
            var zero = 0;
            var cal = 10 / zero;

            try
            {
                // 正常な処理
            }
            catch (Exception ex)
            {
                throw new Exception($"致命的なエラーが発生しました。", ex);
            }
        }

        /// <summary>
        /// 非UIスレッド1
        /// </summary>
        /// <param name="sender">コントロールオブジェクト</param>
        /// <param name="e">イベント</param>
        private void TaskSchedulerException1_Click(object sender, RoutedEventArgs e)
        {
            // タイマーを開始
            BackgroundWorker.Start();

            try
            {
                // awaitしていない処理はtry catch で例外を補足できない
                // TaskScheduler.UnobservedTaskException で補足される
                Task.Run(() =>
                {
                    var zero = 0;
                    var cal = 10 / zero;
                });

            }
            catch (Exception ex)
            {
                throw new Exception($"TaskSchedulerException1が発生しました。", ex);
            }
        }

        /// <summary>
        /// 非UIスレッド2
        /// </summary>
        /// <param name="sender">コントロールオブジェクト</param>
        /// <param name="e">イベント</param>
        private async void TaskSchedulerException2_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                // await で非同期処理を待機すると try catch で例外を補足できる
                await ToDivisionByZero();
            }
            catch (Exception ex)
            {
                throw new ToDivisionByZeroException($"TaskSchedulerException2が発生しました。", ex);
            }
        }

        /// <summary>
        /// 通常の例外処理
        /// </summary>
        /// <param name="sender">コントロールオブジェクト</param>
        /// <param name="e">イベント</param>
        private void NomalException_Click(object sender, RoutedEventArgs e)
        {
            try
            {
                var zero = 0;
                var cal = 10 / zero;
            }
            catch (Exception ex)
            {
                throw new Exception($"NomalExceptionが発生しました。", ex);
            }
        }

        /// <summary>
        /// ガベージコレクタを実行
        /// </summary>
        /// <param name="sender">コントロールオブジェクト</param>
        /// <param name="e">イベント</param>
        private void GarbageCollection_Click(object sender, RoutedEventArgs e)
        {
            // 明示的にガベージコレクションを実行
            GC.Collect();

            // 現在のスレッドを一時停止し、すべてのファイナライザが実行されるのを待機
            GC.WaitForPendingFinalizers();
        }


        /// <summary>
        /// 非同期処理(Zeroの除算)
        /// </summary>
        /// <returns></returns>
        private async Task<int> ToDivisionByZero()
        {
            return await Task.Run(() =>
                    {
                        var zero = 0;
                        return 10 / zero;
                    });
        }

    }
}

DispatcherUnhandledException

DispatcherUnhandledExceptionイベントは、WPF アプリケーションの UI スレッドで発生した未処理の例外をキャッチするためのイベントです。

このイベントハンドラは、App.xaml.csファイルに設定することができます。

App.xaml.cs

"App.xaml.cs"
using Catching_Unhandled_Exceptions.Exceptions;
using NLog;
using System.Threading.Tasks;
using System;
using System.Windows;
using System.Windows.Threading;

namespace Catching_Unhandled_Exceptions
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// </summary>
    public partial class App : Application
    {
        /// <summary>
        /// ロガー
        /// </summary>
        static Logger _logger = LogManager.GetCurrentClassLogger();

        /// <summary>
        /// コンストラクタ
        /// </summary>
        public App()
        {
            // UIスレッドで処理されていない例外をここでまとめてキャッチする
            DispatcherUnhandledException += App_DispatcherUnhandledException;

            // 非UIスレッドで処理されていない例外を処理する
            TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;

            // 例外が処理されずにアプリケーションがクラッシュする直前に呼び出される
            AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
        }

        // ~ 中略 ~

NLog の使用方法に関しては以下の記事で解説しています。

例外をキャッチしたら、DispatcherUnhandledExceptionEventArgsHandledプロパティをtrueに設定することで、アプリケーションがクラッシュするのを防ぐことができます。

ここでは前提条件として、意図した例外(BaseExceptionを継承した独自の Exception クラス)であれば、Info, Warn, Errorでもアプリケーションを終了しない実装にしています。

無闇にHandledプロパティをtrueに設定すると、アプリケーションを終了させなければならない例外を握りつぶしてしまい、動作が不安定になる可能性があるので注意が必要です。

また、これらの例外処理内で NLog などのライブラリを使用してロギングしておくことで、意図しない例外の原因を突き止めやすくなります。

// 型が変換できなかったらnullが返却される(意図していない例外であればnullになる)
var exceptionBase = e.Exception as BaseException;

例外処理を共通化する方法は下記の記事で解説しています。

"App.xaml.cs"
using Catching_Unhandled_Exceptions.Exceptions;
using NLog;
using System.Threading.Tasks;
using System;
using System.Windows;
using System.Windows.Threading;

namespace Catching_Unhandled_Exceptions
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// </summary>
    public partial class App : Application
    {
        /// <summary>
        /// ロガー
        /// </summary>
        static Logger _logger = LogManager.GetCurrentClassLogger();

        /// <summary>
        /// コンストラクタ
        /// </summary>
        public App()
        {
            // UIスレッドで処理されていない例外をここでまとめてキャッチする
            DispatcherUnhandledException += App_DispatcherUnhandledException;

            // 非UIスレッドで処理されていない例外を処理する
            TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;

            // 例外が処理されずにアプリケーションがクラッシュする直前に呼び出される
            AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
        }

        /// <summary>
        /// UIスレッド未処理
        /// </summary>
        /// <param name="sender">コントロールオブジェクト</param>
        /// <param name="e">イベント</param>
        private void App_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
        {
            // デフォルトのアイコンはエラーに設定
            MessageBoxImage iconImage = MessageBoxImage.Error;
            // キャプション
            string caption = "エラー";

            // 型が変換できなかったらnullが返却される(意図していない例外であればnullになる)
            var exceptionBase = e.Exception as BaseException;

            if (exceptionBase != null)
            {
                if (exceptionBase.Kind == BaseException.ExceptonKind.Info)
                {
                    iconImage = MessageBoxImage.Information;
                    caption = "意図した情報";

                    _logger.Info(e.Exception.Message, e.Exception);

                }

                if (exceptionBase.Kind == BaseException.ExceptonKind.Warning)
                {
                    iconImage = MessageBoxImage.Warning;
                    caption = "意図した警告";

                    _logger.Warn(e.Exception.Message, e.Exception);

                }

                if (exceptionBase.Kind == BaseException.ExceptonKind.Error)
                {
                    iconImage = MessageBoxImage.Error;
                    caption = "意図したエラー";

                    _logger.Error(e.Exception.Message, e.Exception);
                }

                // ExceptionBaseに変換できる例外は意図したエラーとしてアプリケーションを実行するものとする
                e.Handled = true;

                MessageBox.Show(e.Exception.Message, caption, MessageBoxButton.OK, iconImage);

            }
        }

        // ~ 中略 ~

ブレークポイントを置いて例外を発生させてみます。

DispatcherException_Clickで発生させた例外は、try catch構文内で処理されていますが、DispatcherUnhandledExceptionで補足されているのがわかります。

後述しますが、注意点として非同期処理の例外(別スレッド)はこのイベントでは検知できません。しかし、非同期処理でもasync/awaittry catch構文内に記述されていれば、このDispatcherUnhandledExceptionで補足することが出来ます。

TaskSchedulerException2_Clickという処理を実行すると、DispatcherUnhandledExceptionに処理が集まってくることが分かります。

"TaskSchedulerException2_Click抜粋"
/// <summary>
/// 非UIスレッド2
/// </summary>
/// <param name="sender">コントロールオブジェクト</param>
/// <param name="e">イベント</param>
private async void TaskSchedulerException2_Click(object sender, RoutedEventArgs e)
{
    try
    {
        // await で非同期処理を待機すると try catch で例外を補足できる
        await ToDivisionByZero();
    }
    catch (Exception ex)
    {
        throw new ToDivisionByZeroException($"TaskSchedulerException2が発生しました。", ex);
    }
}

// ~ 中略 ~

/// <summary>
/// 非同期処理(Zeroの除算)
/// </summary>
/// <returns></returns>
private async Task<int> ToDivisionByZero()
{
    return await Task.Run(() =>
            {
                var zero = 0;
                return 10 / zero;
            });
}

TaskSchedulerException2

TaskScheduler.UnobservedTaskException

TaskScheduler.UnobservedTaskExceptionイベントは、Task Parallel Library(TPL)で実行される非同期タスクで発生した未処理の例外をキャッチするためのイベントです。

このイベントは、タスクが完了してもその例外が観察されず、ガーベジコレクション(GC)の対象となるタスクがあるときに発生します。

このイベントハンドラでは、UnobservedTaskExceptionEventArgsSetObserved()メソッドを呼び出すことで、例外が観察されたことにしてアプリケーションのクラッシュを防ぐことができます。

"App.xaml.cs"
// ~ 抜粋 ~

/// <summary>
/// 非UIスレッド未処理
/// </summary>
/// <param name="sender">コントロールオブジェクト</param>
/// <param name="e">イベント</param>
private void TaskScheduler_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
    // デフォルトのアイコンはエラーに設定
    MessageBoxImage iconImage = MessageBoxImage.Error;
    // キャプション
    string caption = "バックグラウンドタスクでエラー";

    // タイマーを停止
    BackgroundWorker.Stop();
    TimeSpan elapsedTime = BackgroundWorker._stopwatch.Elapsed;

    _logger.Error(e.Exception, $"{e.Exception.Message} - [実行から例外補足まで掛かった時間: {elapsedTime.TotalSeconds}]");

    var result = MessageBox.Show("バックグラウンドタスクで\nアプリケーションを終了しますか?",
                                caption,
                                MessageBoxButton.YesNo,
                                iconImage);

    if(result == MessageBoxResult.Yes)
    {
        Environment.Exit(1);
    }

    // 例外を処理済みとしてアプリケーションを継続する
    e.SetObserved();
}

例外が発生してから、ガベージコレクションによって破棄されるまで時間が掛るのでタイマー処理で時間を計測しています。

"MainWindow.xaml.cs"
// ~ 抜粋 ~

/// <summary>
/// 非UIスレッド1
/// </summary>
/// <param name="sender">コントロールオブジェクト</param>
/// <param name="e">イベント</param>
private void TaskSchedulerException1_Click(object sender, RoutedEventArgs e)
{
    // タイマーを開始
    BackgroundWorker.Start();

    try
    {
        // awaitしていない処理はtry catch で例外を補足できない
        // TaskScheduler.UnobservedTaskException で補足される
        Task.Run(() =>
        {
            var zero = 0;
            var cal = 10 / zero;
        });

    }
    catch (Exception ex)
    {
        throw new Exception($"TaskSchedulerException1が発生しました。", ex);
    }
}

ボタンを押してから約 50 秒後にイベントが発火していました。

TaskSchedulerException1

任意でガーベジコレクションを実行してこのタスクを破棄すると即座にイベントは発火します。

TaskSchedulerException1-02

"ガベージコレクションを実行するコード"

// ~ 抜粋 ~

/// <summary>
/// ガベージコレクタを実行
/// </summary>
/// <param name="sender">コントロールオブジェクト</param>
/// <param name="e">イベント</param>
private void GarbageCollection_Click(object sender, RoutedEventArgs e)
{
    // 明示的にガベージコレクションを実行
    GC.Collect();

    // 現在のスレッドを一時停止し、すべてのファイナライザが実行されるのを待機
    GC.WaitForPendingFinalizers();
}

AppDomain.CurrentDomain.UnhandledException

AppDomain.CurrentDomain.UnhandledExceptionイベントは、アプリケーションドメイン全体で発生した未処理の例外をキャッチするためのイベントです。

アプリケーションドメインは、アプリケーションの実行環境を表し、同じアプリケーション内の複数のスレッドや非同期タスク間でリソースやコードを共有するために使用されます。

注意点として、このイベント内で例外はe.Handled = true;e.SetObserved();のように処理済みとすることが出来ません。必ずアプリケーションは終了します。

そのためこのイベントは、UI スレッド以外で発生した例外をキャッチする場合や、アプリケーション全体で共通のエラーハンドリングやログ記録を行いたい場合に役立ちます。

"App.xaml.cs"
// ~ 抜粋 ~

/// <summary>
/// クラッシュする直前に呼び出される
/// </summary>
/// <param name="sender">コントロールオブジェクト</param>
/// <param name="e">イベント</param>
private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
{
    // 例外オブジェクトを取得
    Exception exception = e.ExceptionObject as Exception;

    // デフォルトのアイコンはエラーに設定
    MessageBoxImage iconImage = MessageBoxImage.Error;
    // キャプション
    string caption = "致命的なエラー";

    _logger.Error(exception, exception.Message);

    MessageBox.Show("エラーが発生しました\nアプリケーションを終了します。", caption, MessageBoxButton.OK, iconImage);
}

AppDomain.CurrentDomain.UnhandledException

イベントハンドラ使用時の注意点

これらのイベントハンドラを効果的に活用するために、以下の点に注意する必要があります。

  1. コード内で、具体的な例外タイプをキャッチし適切に処理する。
  2. 非同期操作で例外が発生する可能性がある場合は、async/awaitパターンを使用して、適切に例外をキャッチし処理する。
  3. 適切なリソース管理を行い、アプリケーションのクリーンアップ処理を適切に実行する。
  4. 例外が発生した場合に、ユーザーに適切なフィードバックを表示する。
  5. エラー情報をログに記録し、開発者が問題を追跡し解決するのに役立てるようにする。

これらの項目を適切に行うことでアプリケーションの安定性やデバッグの容易性を向上させ、DispatcherUnhandledExceptionAppDomain.CurrentDomain.UnhandledExceptionTaskScheduler.UnobservedTaskExceptionイベントハンドラを最終的なセーフティネットとして活用することができます。

未処理の例外によるアプリケーションのクラッシュや不安定な動作を防ぐために、適切に組み合わせて使用してください。

最後に

WPF アプリケーションで未処理の例外を効果的にハンドリングすることは、アプリケーションの安定性やユーザーエクスペリエンスを向上させる上で重要です。

DispatcherUnhandledExceptionAppDomain.CurrentDomain.UnhandledExceptionTaskScheduler.UnobservedTaskExceptionの 3 つのイベントハンドラは、それぞれ異なるシナリオで未処理の例外をキャッチし、適切な処理を行うための仕組みを提供しています。

アプリケーション開発時にこれらのイベントハンドラを適切に設定し例外処理を行うことで、アプリケーションの信頼性やデバッグの容易性を向上させることができます。

未処理の例外が発生した場合のユーザーへのフィードバックやログ記録も、問題の追跡や解決に役立ちます。

最後に、DispatcherUnhandledExceptionAppDomain.CurrentDomain.UnhandledExceptionTaskScheduler.UnobservedTaskExceptionは最終的なセーフティネットとして機能することを意識し、アプリケーションの各部分で適切な例外処理を行うことが重要です。