一. 目标
- 理解和使用UI未捕获异常
DispatcherUnhandledException
的使用方法和触发方式.- 理解和使用程序域未捕获异常
AppDomain.CurrentDomain.UnhandledException
的使用方法和触发方式.- 理解和使用异步代码中未观察的异常
TaskScheduler.UnobservedTaskException
的使用方法和触发方式.
二. 技能介绍
① UI未捕获异常的处理方式
DispatcherUnhandledException
UI线程未处理异常捕获事件介绍
- 用途: 专门用于捕获
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
即可,表示这个异常异常处理了,程序就不会崩了.
上面的代码我学到了哪些技能:
- 调试过程中可以设置异常是否中断来控制调试流程
- 如果是在
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
事件,因为任务系统会处理这些异常并将它们存储起来,等待调用方法去查询或者是处理.
这个例子我们收获了什么呢?
- 程序域捕获异常之后,不能截获异常,这个时候程序都会终止.
- 如果是
异步任务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线程
.
- 异常的捕获
- 在
UI线程上
,由于await
通常会在捕获它的同一个上下文(例如上面的UI线程
)中继续执行,所以异常会被传回到UI
线程并在那里抛出.TaskScheduler.UnobservedTaskException不再触发
,因为异常已经被观察处理(通过await机制),所以不会触发TaskScheduler.UnobservedTaskException
事件.
三. 总结
关于异常捕获:
- 如果是
UI线程
异常捕获中,可以设置handle = True
来标记这个异常已经被处理,防止异常继续往下传递导致程序崩溃- 如果是
Task
异步任务捕获,可以设置e.SetObserved()
防止应用程序终止,表示这个异常已经被观察到,在.Net Core
和.Net5
之前,如果出现未观察的异步任务异常,会导致程序崩溃,虽然后续改变了这一异常的默认行为,但是设置e.SetObserved()
依旧是一个良好的编程习惯.e.SetObserved()
还有助于告诉应用程序日志或者其他诊断输出中显示为已经处理,从而清晰的知道这些异常已经被关注到.- 如果
UI线程
上的异常捕获到没有设置为handle=True
,异常会继续上抛,然后程序域异常捕获
同样会再次捕获到该异常.如果程序异常走到了程序域异常捕获的时候
,这个程序就控制不了,最后就会异常关闭.