WPFで未処理の例外を一括で処理する

WPFで例外が処理されずに最上位のベントハンドラから例外がthrowされた場合、アプリがクラッシュします。

アプリがクラッシュすると、以下のような、OSのアプリのクラッシュレポートダイアログが表示され、以上が発生したことがユーザーに通知されます。

f:id:Takachan:20180508225412p:plain

この動き、なんの前触れもなくアプリが消えるよりは幾分ですが、ユーザーフレンドリーではありませんし、開発者もこの時、ほぼ何の情報も取れません。従って実質的に無意味だったりします。なので、未処理の例外を処理する必要が出てくるのため、今回はその対処法を紹介したいと思います。

まず、WPFにMainメソッドが無い問題に対応する

前提として、WPFで未処理の例外を処理する場合、じゃあその処理をどこに書けばいいのかという問題があります。

見えるところでやるなら、WPFのメインウインドウクラスのコンストラクタに処理書くというのが真っ先に頭に浮かびますが、これではタイミングが遅すぎる場合があります。従って、メインウインドウのインスタンスを生成する前に実行したいです。が、WPFだとMain関数が見当たらないのです。これは、WPFではMain関数はコンパイル時に自動生成されためで、プログラマーは直接触れないことになっているからです。

なので、ほぼ同じ意味を持つStartupイベントハンドラに処理を記述することになります。まずは、Starupイベントの作り方です。

最初にApp.xamlに以下記述を追記します。

// App.xaml

<Application x:Class="WpfApp12.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:WpfApp12"
             StartupUri="MainWindow.xaml" Startup="Application_Startup"> <!-- ← Startupイベントを追加 -->
    <Application.Resources/>
</Application>

で、App.xamlの実装のApp.xaml.csにイベントハンドラの中身を記述します。

// App.xaml.cs

public partial class App : Application
{
    // Startupで呼ばれるイベントハンドラ
    private void Application_Startup(object sender, StartupEventArgs e)
    {
        // アプリ開始時の処理
    }
}

2種類の例外に対応する

WPFはGUIのため、考慮しないといけない例外は基本的に2種類、WindowsFormsHostをWPF上で使用している場合3種類です。

それぞれ以下の通りです。

  • WPFのUIスレッド(≒メインスレッド)上で発生した例外
  • WPFのUIスレッド以外で発生した例外
  • WinFormHost上のWinFormコントーロールで発生した例外

先ずは、「WPFのUIスレッド上で発生した例外」の処理ですが、これはApplicationを継承したAppクラスに専用のイベントハンドラ「DispatcherUnhandledException」が用意されているので、これを使用します。

先ほどのApp.xamlに以下の通り処理を追記します。

// App.xaml

<Application x:Class="WpfApp12.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:WpfApp12"
             StartupUri="MainWindow.xaml"
             Startup="Application_Startup"
             DispatcherUnhandledException="Application_DispatcherUnhandledException"> <!-- ← 追加 -->
    <Application.Resources/>
</Application>

VisualStudioのGUIで操作するとイベントハンドラが勝手にApp.xaml.csに追加されるので例外発生時の処理を追記します。

e.Handled = true; とすると例外が起きた後も画面が終了しません。

// App.xaml.cs

public partial class App : Application
{
    // DispatcherUnhandledExceptionのイベントハンドラ
    private void Application_DispatcherUnhandledException(object sender, 
        DispatcherUnhandledExceptionEventArgs e)
    {
        // 未処理の例外の処理
        Exception ex = e.Exception;
        MessageBox.Show(ex.ToString());
        
        // ハンドルされない例外を処理済みにするためにtrueを指定
        e.Handled = true;
    }
}

次に、「WPFのウインドウスレッド以外で発生した例外」と「WinFormHost上のWinFormコントーロールで発生した例外」ですがこれは冒頭の、Starupメソッド内に記述します。

WinFormHostは滅多に使わないと思いますが、使用方法はWinFrom時代の未処理の例外の設定方法と同じです。WPFではStartupに書くということくらいしか違いはありません。

// App.xaml.cs

public partial class App : Application
{
    private void Application_Startup(object sender, StartupEventArgs e)
    {
        // アプリケーションで発生した未処理の例外の処理
        AppDomain.CurrentDomain.UnhandledException += (o, eh) =>
        {
            // 例外の処理
            var _ex = eh.ExceptionObject as Exception;
            MessageBox.Show(_ex.ToString());
            
            // このまま終わると動作を停止しましたというウインドウが出てしまうので
            // 挙動を抑えるために自分で先んじで終了する
            Environment.Exit(-1);
        };

        // WinFormHost上のWinFormコントーロールで発生した未処理の例外の処理
        // System.Windows.Forms.dllが必要("Application"という名前がかぶってるので名前空間は完全名で記述
        System.Windows.Forms.Application.ThreadException += (o, eh) =>
        {
            // 例外の処理
            Exception _ex = eh.Exception;
            MessageBox.Show(_ex.ToString());
        };
    }
}

最も重要なのは、DispatcherUnhandledExceptionの最後のe.Handled = true;を記述するとDispatcherUnhandledExceptionで拾われた例外はUnhandledExceptionに拾われなくなる動作です。これが無いとDispatcherUnhandledException → UnhandledExceptionの順にハンドラが2種類とも呼ばれて、終了後にアプリが落ちてしまいます。

UnhandledExceptionが呼ばれると、ハンドラ終了後にアプリがクラッシュ扱いで終了してしまうのでEnvironment.Exit()で自分を安全に終了させます。リソースの解放なども必要であればここで処理を行います。

ThreadExceptionで拾った例外は自動で処理済みとなるようです。UnhandledExceptionは反応しません。ただ、なんか挙動が変な時があるので少し動きを見た方がいいと思います。

どうしてこんなことをするのか?

どうしてこのような機構が存在するのかという理由ですが、直接利益がある状況は、WPFで(というかWinFormも含めてですが)全部のイベントハンドラにtry ~ catch を付けないためです。MVVMでデータバインドしていないとコントロールにイベントを張っていきますが、以下のように定型的なtry ~ catchが続きます。DRYの精神に思いっきり反してますし、何かcatch説に固有の処理が無い場合、先述の例外ハンドラに任せてこのような記述は全て削除することができます。

public partial class MainWindow : Window
{
    public MainWindow()
    {
        InitializeComponent();
    }

    protected virtual void Window_Loaded(object sender, RoutedEventArgs e)
    {
        try
        {
            // なんかの処理
        }
        catch(Exception ex)
        {
            // ログ出力
        }
    }

    protected virtual void Window_PreviewMouseDown(object sender, MouseButtonEventArgs e)
    {
        try
        {
            // なんかの処理
        }
        catch(Exception ex)
        {
            // ログ出力
        }
    } 
    // 以下ずっと同じ
}

【おまけ】新しい例外ハンドルの方法

.NET4.0以降では以下の2つの機能が追加されています。以下の処理はWPFだとかUIスレッドなどは関係なく.NET内共通で使用できる例外処理の仕組みです。

それぞれい以下の通りです。

  • 例外が発生した瞬間、catch節やハンドラに入る前に例外を補足する仕組み
  • バックグラウンドタスクで発生した例外を処理する仕組み

「例外が発生した瞬間、catch節やハンドラに入る前に例外を補足する仕組み」ですが、MVVMでバインドしているプロパティとか内部で発生した例外とかも拾えるのでデバッグの時は結構有効だと思います。

使用方法はAppDomainのFirstChanceExceptionにハンドラを設定します。

// public event EventHandler<FirstChanceExceptionEventArgs> FirstChanceException;

private void Application_Startup(object sender, StartupEventArgs e)
{
    AppDomain.CurrentDomain.FirstChanceException += (o, eh) =>
    {
        Exception _ex = eh.Exception;
        MessageBox.Show(_ex.ToString());
    };
}

第2引数の.Exceptionで発生した例外が取れます。かなりすごい数が取れる場合があるので常に有効だとちょっとキツいかもしれません。

「バックグラウンドタスクで発生した例外を処理する仕組み」ですが、こちらは、TaskScheduler.UnobservedTaskExceptionにハンドラを指定します。どうやらインスタンスがGCで破棄されるときに実行されるみたいで、反応がちょっと「遅い」です。

private void Application_Startup(object sender, StartupEventArgs e)
{
    TaskScheduler.UnobservedTaskException += (o, eh) =>
    {
        Exception _ex = eh.Exception;
        MessageBox.Show(_ex.ToString());

        // 処理済みとしてアプリを終了させないために以下メソッドを呼び出します。
        eh.SetObserved();
    };
}

Taskを使用して終了を待たない場合、上記処理を使用して例外の発生を処理するのが良いと思います。くれぐれもTaskで走らせるステートメントすべてでtry ~ catchで囲む記述をしないことが大切かと思います。(イベントハンドラの時と同じですね。)