4、异常处理与捕获:try-catch最佳实践、全局异常处理器、异常信息记录与分析

异常处理这事儿,说实话,很多桌面应用开发者都低估了它的重要性。

我见过太多应用,一遇到意外就崩溃,连个提示都没有。用户只能看到「程序已停止工作」——然后骂娘。你想想看,这多掉价。

今天我们就聊聊怎么把异常处理做好。不是那种教科书式的写法,而是真正能在实战中扛得住事的方案。

4.1 try-catch 的最佳实践

先说说最基础的 try-catch。很多人觉得这玩意儿简单,不就是包一下嘛。但我在项目中踩过不少坑,总结下来有这么几条铁律。

4.1.1 别吞异常

这是最常见的错误。我曾经接手过一个项目,里面全是这种代码:

try
{
    // 一些操作
    File.ReadAllText(path);
}
catch (Exception ex)
{
    // 啥也不干
}

嗯,这种写法等于告诉用户:「出错了,但我懒得管。」

我个人习惯是:要么处理,要么往上抛,千万别沉默。沉默的异常就像定时炸弹,你不知道它什么时候炸。

4.1.2 捕获具体异常,别用万金油

很多新手喜欢直接 catch (Exception),这其实很偷懒。你应该捕获你能处理的具体异常类型。

// 不推荐
try
{
    var data = File.ReadAllBytes(path);
}
catch (Exception ex)
{
    MessageBox.Show("文件读取失败");
}

// 推荐
try
{
    var data = File.ReadAllBytes(path);
}
catch (FileNotFoundException ex)
{
    Log.Warn($"文件未找到: {path}");
    // 提示用户选择其他文件
}
catch (UnauthorizedAccessException ex)
{
    Log.Warn($"无权限访问: {path}");
    // 提示用户以管理员身份运行
}
catch (IOException ex)
{
    Log.Error($"IO错误: {ex.Message}");
    // 重试或退出
}

为什么?因为不同的异常,处理方式完全不同。你想想看,文件不存在和权限不足,能一样处理吗?

4.1.3 保持 try 块尽量小

我见过有人把整个方法体都包在 try 里。这其实不好。try 块越大,你越难定位问题。

我的习惯:每个 try 只包一个可能出错的原子操作。比如读取文件、网络请求、数据库操作。这样异常发生时,你一眼就能看出是哪行代码出的问题。

4.2 全局异常处理器

try-catch 能解决大部分问题,但总有漏网之鱼。比如事件处理函数里抛出的异常、异步操作中的异常、第三方库内部抛出的异常。

这时候就需要全局异常处理器了。说白了,就是给整个应用装一个「安全网」。

4.2.1 WinForms 中的全局异常处理

// 在 Program.cs 的 Main 方法中
[STAThread]
static void Main()
{
    Application.EnableVisualStyles();
    Application.SetCompatibleTextRenderingDefault(false);

    // 处理 UI 线程异常
    Application.ThreadException += (sender, e) =>
    {
        Log.Fatal(e.Exception, "UI线程未处理异常");
        MessageBox.Show(
            $"程序遇到意外错误,即将退出。\n错误: {e.Exception.Message}",
            "严重错误",
            MessageBoxButtons.OK,
            MessageBoxIcon.Error
        );
        Application.Exit();
    };

    // 处理非 UI 线程异常
    AppDomain.CurrentDomain.UnhandledException += (sender, e) =>
    {
        var ex = e.ExceptionObject as Exception;
        Log.Fatal(ex, "非UI线程未处理异常");
        // 记录日志后,让系统处理
    };

    Application.Run(new MainForm());
}

这里有个坑,我必须要说。UI 线程和非 UI 线程的异常处理机制是分开的。你只注册一个是不行的。

注意:全局异常处理器是最后一道防线。它不能替代 try-catch。它的作用是「兜底」,而不是「偷懒」。

4.2.2 WPF 中的全局异常处理

// App.xaml.cs
public partial class App : Application
{
    protected override void OnStartup(StartupEventArgs e)
    {
        base.OnStartup(e);

        // 处理 Dispatcher 未处理异常
        this.DispatcherUnhandledException += (sender, args) =>
        {
            Log.Fatal(args.Exception, "Dispatcher未处理异常");
            MessageBox.Show($"发生未处理异常: {args.Exception.Message}");
            args.Handled = true; // 标记为已处理,防止应用崩溃
        };

        // 处理 Task 未处理异常
        TaskScheduler.UnobservedTaskException += (sender, args) =>
        {
            Log.Fatal(args.Exception, "Task未处理异常");
            args.SetObserved(); // 标记为已观察
        };
    }
}

4.3 异常信息记录与分析

捕获异常只是第一步。真正有价值的是——你怎么记录和分析这些异常。

我曾经在一个项目中,异常日志里全是「Object reference not set to an instance of an object」。你猜怎么着?根本没法排查。因为没有上下文信息。

4.3.1 记录什么信息

一个好的异常日志,至少应该包含这些:

信息项 说明 示例
异常类型 具体的异常类名 System.IO.FileNotFoundException
异常消息 异常的 Message 属性 找不到文件 'C:\data\config.xml'
堆栈跟踪 完整的调用栈 at MyApp.DataLoader.Load() in ...
时间戳 精确到毫秒 2024-01-15 14:30:22.456
用户信息 当前登录用户 DOMAIN\zhangsan
上下文数据 关键变量值 FilePath = "C:\data\config.xml"
操作系统版本 环境信息 Windows 10.0.19045

4.3.2 一个实用的日志工具类

public static class ExceptionLogger
{
    public static void Log(Exception ex, 
                           [CallerMemberName] string callerName = "",
                           Dictionary<string, object> context = null)
    {
        var sb = new StringBuilder();
        sb.AppendLine($"时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}");
        sb.AppendLine($"调用者: {callerName}");
        sb.AppendLine($"异常类型: {ex.GetType().FullName}");
        sb.AppendLine($"异常消息: {ex.Message}");
        sb.AppendLine($"堆栈跟踪: {ex.StackTrace}");

        if (context != null)
        {
            sb.AppendLine("上下文数据:");
            foreach (var kvp in context)
            {
                sb.AppendLine($"  {kvp.Key} = {kvp.Value}");
            }
        }

        if (ex.InnerException != null)
        {
            sb.AppendLine("内部异常:");
            sb.AppendLine($"  类型: {ex.InnerException.GetType().FullName}");
            sb.AppendLine($"  消息: {ex.InnerException.Message}");
        }

        // 写入文件
        File.AppendAllText("error.log", sb.ToString() + Environment.NewLine);
    }
}

// 使用示例
try
{
    var data = LoadConfig("config.xml");
}
catch (Exception ex)
{
    ExceptionLogger.Log(ex, context: new Dictionary<string, object>
    {
        ["ConfigPath"] = "config.xml",
        ["Environment"] = "Production"
    });
}
核心观点:异常日志不是写给机器看的,是写给未来的你(或者其他开发者)看的。所以,多写点上下文信息,别吝啬那几行代码。

4.3.3 异常分析三板斧

记录完日志,怎么分析?我一般按这个顺序来:

  1. 看异常类型和消息——先搞清楚是什么类型的错误。是 IO 错误?网络错误?还是业务逻辑错误?
  2. 看堆栈跟踪——找到出错的代码行。注意看调用栈的顶部,那才是真正出错的地方。
  3. 看上下文数据——结合当时的变量值,复现问题。比如文件路径、用户输入、网络状态等。

举个例子。有一次线上反馈说「保存失败」。我一看日志:

  • 异常类型:System.UnauthorizedAccessException
  • 消息:对路径“C:\Program Files\MyApp\data\”的访问被拒绝
  • 上下文:FilePath = "C:\Program Files\MyApp\data\config.json"

嗯,问题很明显了。程序试图往 Program Files 目录写数据,但普通用户没有写权限。解决方案:把数据存到 AppData 目录下。

你看,如果没有上下文数据,你根本不知道是哪个文件、哪个路径出的问题。

4.4 避坑指南

最后,分享几个我踩过的坑:

我曾经...在 finally 块里又抛了异常。结果 try 块里的异常被覆盖了。后来我养成了习惯:finally 块里只做清理工作,不做可能抛异常的操作。
注意:不要在构造函数里抛异常。尤其是 WinForms 和 WPF 的控件构造函数。构造函数抛异常会导致对象创建失败,而且很难捕获。

还有一个常见的坑:异步方法里的异常。如果你用 async void,异常会直接飞到上层,很难捕获。我个人习惯是:能用 async Task 就别用 async void。事件处理函数除外,但事件处理函数里一定要包 try-catch。

好了,异常处理这块就聊这么多。记住一句话:异常不是 bug,是信息。你处理得越好,应用就越稳定,用户就越省心。