4. WPF应用程序中的未捕获异常处理

一. 目标

  1. 理解和使用UI未捕获异常DispatcherUnhandledException的使用方法和触发方式.
  2. 理解和使用程序域未捕获异常AppDomain.CurrentDomain.UnhandledException的使用方法和触发方式.
  3. 理解和使用异步代码中未观察的异常TaskScheduler.UnobservedTaskException的使用方法和触发方式.

二. 技能介绍

① UI未捕获异常的处理方式

DispatcherUnhandledExceptionUI线程未处理异常捕获事件介绍

  • 用途: 专门用于捕获UI线程上抛出的未处理的异常,在WPF应用程序中就是指的主线程.
  • 特点: 允许开发者阻止异常终止应用程序,默认情况下,如果不处理这个事件,则异常会导致应用程序关闭
  • 注册方式: 通常在App()构造哈数中注册其事件

使用案例: 捕获到异常,并且显示弹窗,弹窗完之后,写一个按钮事件,点击会抛出一个异常.

namespace DispatcherExceptionHandlerSimple
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// </summary>
    public partial class App : Application
    {
        public App()
        {
            DispatcherUnhandledException += App_DispatcherUnhandledException;
        }

        private void App_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
        {
            System.Diagnostics.Debug.WriteLine($"UI线程上未处理的异常: {e.Exception.Message}");
            e.Handled = false;  // 这里表示异常已经被处理,不继续往上抛了,程序不会关闭
        }
    }
}
namespace DispatcherExceptionHandlerSimple
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        // 点击了UI模拟UI线程按钮
        private void Btn_ThrowEOnUIClick(object sender, RoutedEventArgs e)
        {
            // 这里在UI线程上抛出一个异常
            throw new Exception("UI线程上发生了异常");
        }
    }
}

注意这里在调试的过程中会发现,点击了按钮之后,程序中断了,还没有到UI线程异常捕获的地方就中断了,然后可能会看到如下的截图:
在这里插入图片描述
此时我们把下面的引发此异常类型时中断关闭之后,然后再点击运行,还是会出现如下的窗口.

我先说结论,第二种情况下,其实已经捕获了这个异常了,控制台可以看到输出,打断点的时候我们也可以验证,确实App_DispatcherUnhandledException执行了,但是为什么最后程序又崩了,又回来了呢,因为这个异常在App_DispatcherUnhandledException这里没有处理,它会继续冒泡,然后就会被系统捕获到,租后就会显示界面上这种情况,怎么样让程序不崩溃的,只要把上面的e.Handle=True即可,表示这个异常异常处理了,程序就不会崩了.

上面的代码我学到了哪些技能:

  1. 调试过程中可以设置异常是否中断来控制调试流程
  2. 如果是在UI线程(主线程)上抛出了异常,又没有被处理,会被DispatcherUnHandledException捕获,并且可以通过设置e.Handle=true防止应用程序崩溃,如果没有捕获这个异常,会引起程序崩溃
② 全局程序域抛出的未处理异常的捕获

AppDomain.CurrentDomain.UnhandledException异常捕获事件介绍:

  • 用途: 用于捕获当前应用程序中抛出的所有未处理的异常,不仅仅局限于UI线程上发生的异常.
  • 特点: 此事件是.NetFramework的一部分,适用于所有类型的.Net应用程序,包括WPF,WinForms,Console等.处理这个事件主要用于记录信息和进行清理工作,因为一旦触发此事件,应用程序将无法阻止关闭
  • 注册方式: 在应用程序的任何地方注册都可以,推荐在app构造函数中注册

下面我们模拟一个在非UI线程上抛出未捕获异常的例子

public partial class App : Application
    {
        public App()
        {
            DispatcherUnhandledException += App_DispatcherUnhandledException;
            AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; ;
        }

        private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
        {
            System.Diagnostics.Debug.WriteLine($"程序域内捕获了异常: {(e.ExceptionObject as Exception)?.Message}");
        }

        private void App_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
        {
            System.Diagnostics.Debug.WriteLine($"UI线程上未处理的异常: {e.Exception.Message}");
            e.Handled = true;  // 这里表示异常已经被处理,不继续往上抛了,程序不会关闭
        }
    }
 public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        // 点击了UI模拟UI线程按钮
        private void Btn_ThrowEOnUIClick(object sender, RoutedEventArgs e)
        {
            // 这里在UI线程上抛出一个异常
            throw new Exception("UI线程上发生了异常");
        }

        private void Btn_ThrowEOnNotUIClick(object sender, RoutedEventArgs e)
        {
            new Thread(() =>
            {
                throw new Exception("非UI线程上发生了异常");
            }).Start();
        }
    }

运行程序,我们会发现点击捕获非UI线程上的异常按钮,会触发异常 new Exception("非UI线程上发生了异常")并且这个异常处理之后,程序就中断了,然后回到了发生异常的地方.

思考: 为什么我们这里要使用Thread()启动线程,假如我们使用了Task来启动会有什么结果呢? 下面我们把抛出异常的线程改成异步任务Task,看看会发生什么吧

        private void Btn_ThrowEOnNotUIClick(object sender, RoutedEventArgs e)
        {
            Task.Run(() =>
            {
                throw new Exception("非UI线程上发生了异常");
            });
        }

可以看到这里程序域异常捕获事件并没有被触发呢,这是为什么呢?
原因:

.Net中,通过Task.Run()方法启动的任务中抛出的异常具有特殊的处理机制.这些异常在任务内部如果没有捕获到,它们会封装在AggregateException对象中.这些异常不会立即触发AppDomain.CurrentDomain.UnhandledException事件,因为任务系统会处理这些异常并将它们存储起来,等待调用方法去查询或者是处理.

这个例子我们收获了什么呢?

  1. 程序域捕获异常之后,不能截获异常,这个时候程序都会终止.
  2. 如果是异步任务Task.Run()触发的异常,程序域异常捕获器并不能捕获到,原因是因为异步任务的异常有特殊的处理方式.
③ 异步Task任务中的异常捕获

TaskScheduler.UnobservedTaskException异常捕获介绍:

  • 用途: 用于捕获在Task中未被观察(即未被await或者访问其Exception属性)的异常
  • 特点: 这个事件提供了一个机会用来处理那些在异步操作中被遗漏的异常
  • 注册方式: 通常在应用陈旭启动时注册.

简单例子,未被捕获的异步任务异常捕获方式:

       private void Btn_ThrowTaskExceptionClick(object sender, RoutedEventArgs e)
        {
            Task.Run(() =>
             {
                 throw new Exception("这是一个未捕获的异步任务中发生的异常");
             });
            // 模拟一段时间后的垃圾回收,通常在应用程序生命周期的某个时刻自然发生
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
        }
namespace DispatcherExceptionHandlerSimple
{
    /// <summary>
    /// Interaction logic for App.xaml
    /// </summary>
    public partial class App : Application
    {
        public App()
        {
            DispatcherUnhandledException += App_DispatcherUnhandledException;
            AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
            TaskScheduler.UnobservedTaskException += Task_UnobservedTaskException;
        }

        private void Task_UnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
        {
            e.SetObserved(); // 标记异常已经被观察,防止进程终止
            var ex = e.Exception; // 获取异常,进行日志记录或者是其他处理
            System.Diagnostics.Debug.WriteLine($"捕获未观察到的Task异常: {ex.Message}");
        }

        private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e)
        {
            System.Diagnostics.Debug.WriteLine($"程序域内捕获了异常: {(e.ExceptionObject as Exception)?.Message}");
        }

        private void App_DispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
        {
            System.Diagnostics.Debug.WriteLine($"UI线程上未处理的异常: {e.Exception.Message}");
            e.Handled = true;  // 这里表示异常已经被处理,不继续往上抛了,程序不会关闭
        }
    }
}

.Net异步异常被触发的时机问题:

上面的代码我在测试的时候发现,异步任务抛出异常之后,即使调用了垃圾回收,但是异常依旧没有被捕获,当点击再次按钮的时候,上次的异步异常才会被捕获.这是什么原因呢,这是因为垃圾回收的不确定性,GC.Collect()是用来建议进行垃圾回收,但是.NET运行时仍然拥有最终的决定权,何时以及什么时候进行垃圾回收.只有在垃圾回收的时候Task相关的异常才会抛出,被TaskScheduler.UnobservedTaskException事件捕获. 有没有办法每次点击按钮的时候都能够捕获到该异常呢,通过在下面添加一段代码就可以,比如await Task.Delay(100)

        private async void Btn_ThrowTaskExceptionClick(object sender, RoutedEventArgs e)
        {
            Task.Run(() =>
             {
                 throw new Exception("这是一个未捕获的异步任务中发生的异常");
             });
            await Task.Delay(100);
             模拟一段时间后的垃圾回收,通常在应用程序生命周期的某个时刻自然发生
            GC.Collect();
            GC.WaitForPendingFinalizers();
            GC.Collect();
        }
    }

具体是什么原因导致的,不知道.还有就是如果Task.Run()这里加上await,比如下面这种

        private async void Btn_ThrowTaskExceptionClick(object sender, RoutedEventArgs e)
        {
            await Task.Run(() =>
             {
                 throw new Exception("这是一个未捕获的异步任务中发生的异常");
             });
        }

这里又是什么原因呢?为什么这里加上await 之后,异常捕获变成在UI线程异常捕获那里捕获到异常了呢?

原因:

  • 异步任务和异步传播

当你使用await关键字等待一个任务的时候,你实际上是在告诉编译器: 我只关心这个任务的结果,如果任务失败了,我想知道失败的原因.因此,如果被等待的Task抛出异常,这个异常会从Task中传播出来,并被重新抛出到await表达式所在的上下文中.而上面例子的上下文就是UI线程,所以这里异常就回到了UI线程.

  • 异常的捕获
  1. UI线程上,由于await通常会在捕获它的同一个上下文(例如上面的UI线程)中继续执行,所以异常会被传回到UI线程并在那里抛出.
  2. TaskScheduler.UnobservedTaskException不再触发,因为异常已经被观察处理(通过await机制),所以不会触发TaskScheduler.UnobservedTaskException事件.

三. 总结

关于异常捕获:

  1. 如果是UI线程异常捕获中,可以设置handle = True来标记这个异常已经被处理,防止异常继续往下传递导致程序崩溃
  2. 如果是Task异步任务捕获,可以设置e.SetObserved()防止应用程序终止,表示这个异常已经被观察到,在.Net Core.Net5之前,如果出现未观察的异步任务异常,会导致程序崩溃,虽然后续改变了这一异常的默认行为,但是设置e.SetObserved()依旧是一个良好的编程习惯.e.SetObserved()还有助于告诉应用程序日志或者其他诊断输出中显示为已经处理,从而清晰的知道这些异常已经被关注到.
  3. 如果UI线程上的异常捕获到没有设置为handle=True,异常会继续上抛,然后程序域异常捕获同样会再次捕获到该异常.如果程序异常走到了程序域异常捕获的时候,这个程序就控制不了,最后就会异常关闭.
  • 21
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值