站長阿川

站長阿川私房教材:
學 JavaScript 前端,帶作品集去面試!

站長精心設計,帶你實作 63 個小專案,得到作品集!

立即開始免費試讀!

1. 前言

在使用 WPF 開發業務應用程式時,必然會面臨到「如何設計例外處理」這個問題。

當使用者按下畫面上的按鈕,瞬間應用程式崩潰且視窗消失——這一刻對於使用者來說就是「無法信任的軟體」。

「那麼全部用 try-catch 包起來不就好了?」我們可能會這樣想,但這樣會有以下的限制。

  • 在所有程式碼中寫 try-catch 是不現實的(開發效率會下降)
  • 捕捉到的例外該如何處理(通知使用者?僅記錄?繼續執行?)取決於設計
  • 根本上,非 UI 執行緒的例外有時無法僅憑 try-catch 捕捉到(例如:未 awaitTask 例外、背景執行緒的事件處理、經由計時器/回呼的例外等)

此時出現的就是 全局例外處理。為整個應用程式準備一個「最後的防線」,無論例外發生在何處,都能接住,

  • 通知使用者
  • 記錄至日誌
  • 結束應用程式或繼續執行

以及其他的共通處理。

但並不是說「將所有崩潰資訊嬰兒般地保存下來」就足夠了。這可能包含 PII(個人可識別資訊)和敏感資訊

  • 在表單中輸入的使用者名稱或電子郵件地址
  • 資料庫連接字串或密碼
  • 應用程式內部處理的客戶資料

若將這些直接吐出至例外日誌,則有可能成為 安全事故 的根源。

也就是說,重點在於「不將所有資訊全部記錄,而是只記錄與顯示必要的資訊」。

本文將以 WPF/MVVM 應用程式為題材,徹底解說以下內容:

  • 在哪些事件中可以捕捉例外
  • 捕捉後該如何處理
  • FailFast(即時結束)與 Shutdown(安全結束)的區分
  • 如何處理敏感資訊

接下來將帶進入 WPF 應用程式中「三個例外通道」的概述。

2. 什麼是全局例外處理

首先要整理的,是「全局例外處理」這個詞的意義。

這裡所謂的「全局」,是指在整個應用程式中共通運作的最後防線這個含義。

即使發生了單一 try-catch 無法處理的例外,最終仍有一個「在某處捕捉並處理的機制」。

三個通道(UI → 非同步任務 → CLR 整體)

在 WPF 應用程式中,大致上有以下 三個通道。本文將依「UI → 非同步任務 → CLR 整體」的順序進行說明。

  1. UI 執行緒 (Application.DispatcherUnhandledException)

    • WPF 的畫面操作和事件處理是運行在 Dispatcher 執行緒上。
    • 在這個執行緒上未處理的例外,最後可通過 DispatcherUnhandledException 事件捕捉。
    • 典型案例為「在按鈕點擊事件處理中被丟出卻沒有被捕捉到」。
  2. 非同步任務 (TaskScheduler.UnobservedTaskException)

    • 使用 Task 進行的非同步處理例外,如果有 await 則可以用 try-catch 捕捉。
    • 但是 未被 await 的任務忽略結果的任務 的例外會在垃圾收集時以 UnobservedTaskException 的形式顯現。
    • 因此其「觸發時間會隨機」,再現性較低(相同操作的例外發生時機不同或沒有發生例外)是其特徵。
  3. CLR 整體 (AppDomain.CurrentDomain.UnhandledException)

    • 在 UI 執行緒以外(背景執行緒、計時器回呼等)發生的未處理例外,最終會在此通知。
    • 此時 .NET Framework 幾乎總是會導致應用程式強制結束,而 .NET Core / .NET 5+ 以後也原則上會如此。
    • 檢查 UnhandledExceptionEventArgs.IsTerminating 屬性會發現,大多數情況下為 true
    • 因此「通知即是最後記錄日誌或警報的機會」,應該認為無法從這裡讓應用繼續執行

理解每個通道的特性是重要的

  • UI 執行緒 → 相對容易處理。「可以顯示使用者訊息並繼續」,但是事件處理器是同步方法,無法使用 await。因此非同步處理需要以 fire-and-forget 的方式委託給 UI Dispatcher。
  • 非同步任務 → 時間上難以預測。需要以日誌為主的「不吞掉例外」的方針。
  • CLR 整體 → 必定會結束。在這裡需要專注於「保存最後的證據」如記錄、警報、FailFast 等。

設計心態

「捕捉所有並吞掉所有」是不可能也不應該的。

不如整理出「在哪裡發生的例外可以繼續執行或無法繼續」的邊界,

  • 如何通知使用者
  • 要記錄什麼(也包括排除 PII 的設計)
  • 何時結束應用程式

在設計階段決定這些,對於實務上是非常重要的。


接下來的章節將根據這些思維,來觀察實際的程式碼實作(SimplePocApplicationUIThreadSimpleDispatcher)。

3. 樣本實作的全貌

接下來終於要基於實際的程式碼來看「全貌」。
本次以以下固定提交的 SimplePocApplication 類為題材。

👉 SimplePocApplication.cs


程式碼的角色

SimplePocApplication 繼承自 Application,擔任集中管理 WPF 整體例外處理的角色。
重點如下:

  • OnStartup 中註冊全局例外處理程式
    • DispatcherUnhandledException(專為 UI 執行緒)
    • AppDomain.CurrentDomain.UnhandledException(專為 CLR 整體)
    • TaskScheduler.UnobservedTaskException(專為非同步任務)
  • 接收例外後,將處理流程傳遞至共同的確認邏輯 ConfirmOrShutdownAsync
  • 根據需要調用 Environment.FailFast,立即結束程序

UIThreadSimpleDispatcher 的重要性

另一個重要的是 UIThreadSimpleDispatcher.cs

  • 目的: 為了「將處理返回給 UI 執行緒」而設計的一個薄包裝。
  • 設計意圖: 不直接呼叫 Dispatcher.CheckAccess()Dispatcher.InvokeAsync(),而是透過這個類來 分離 UI 執行緒控制的責任
  • 實務上好處: 在測試時可以替換這個類,從而將 UI 相關程式碼模擬化。
    • 例如:在單元測試環境中不呼叫實際的 WPF Dispatcher,而是中間插入模擬的 Dispatcher 實作。

角色整理


全貌圖像

如此一來,所有的流路徑都匯聚為 SimplePocApplication


接下來的章節將深入探討這「三個例外流路」中的每一個。
首先我們將查看 UI 執行緒的例外處理 (DispatcherUnhandledException)

4. UI 執行緒例外的處理

DispatcherUnhandledException 的機制

WPF 的畫面顯示或事件處理是在 Dispatcher(UI 執行緒) 上運行。
當未處理的例外發生在這個執行緒時,最後會觸發 Application.DispatcherUnhandledException

👉 相關位置: SimplePocApplication.cs : OnDispatcherUnhandledException


觸發情況

  • UI 執行緒的事件處理器或資料綁定更新 中被丟出的例外,且未被 try-catch 處理
  • 非 UI 執行緒的例外不會到達這裡,而是會被發送到另一個通道(未觀察 Task / CLR 整體)

處理設計方針

  • e.Handled = true 設為 抑制即時崩潰
  • 將「繼續或結束」的選擇權委託給使用者,根據結果呼叫 Application.Current.Shutdown(exitCode)
  • ExitCode 由 ExitCodeForUnhandledException 屬性定義,統一管理應用程式退出代碼
  • UI 顯示必須經由 UIDispatcher 進行(不從 UI 執行緒以外直接顯示 MessageBox)
  • 考慮到例外訊息中可能包含 PII,希望能夠不要在 UI 上顯示過多細節

樣本實作

/// <summary>
/// 處理 UI 執行緒上的未處理例外。
/// </summary>
/// <remarks>
/// 這裡將例外設為 Handled = true,
/// 最後的繼續/結束判斷委託給 ConfirmOrShutdownAsync。
/// </remarks>
protected virtual void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
    Exception ex = e.Exception;
    var msg = $"""
        【DispatcherUnhandledException】

        在 UI 執行緒上發生了未處理的例外。

        {ex.GetType().Name}
        {ex.Message}

        是否繼續?
        """;
    // 非同步顯示確認對話框,根據結果執行結束處理
    _ = ConfirmOrShutdownAsync(msg, "UI例外", shutdownAsync: () =>
    {
        // 當使用者選擇「不繼續」時,試圖以 WPF 的安全方式結束
        Current?.Shutdown(ExitCodeForUnhandledException);
        return Task.CompletedTask;
    });

    // 避免立即崩潰,保留繼續的可能性
    e.Handled = true;
}

本樣本實作的總結

  • DispatcherUnhandledException 是專為 UI 執行緒的例外設計的最後通道
  • Handled = true 可讓流程流向「使用者選擇結束」而非「應用崩潰」
  • 結束基礎上以 Current.Shutdown(exitCode) 為主,且 ExitCode 由屬性統一管理
  • 事前決定不包含 PII/進行遮罩的政策,對於實務運行來說是重要課題(這並不僅限於 DispatcherUnhandledException 的情況)

接下來章節將詳解在垃圾收集的時機點出現的非同步任務未觀察例外(TaskScheduler.UnobservedTaskException)。

5. 非同步任務的未觀察例外

TaskScheduler.UnobservedTaskException 是什麼

C# 的 Task 是非同步處理的基本單元,但不一定會被 await 的方式使用
例如以「fire-and-forget(丟棄不管)」執行的任務或略過結果而被丟棄的任務便是這樣。

當這些任務在內部丟出例外時,普通的 try-catch 會無法捕捉。
這些結果會在 垃圾收集的時機 被顯現為 TaskScheduler.UnobservedTaskException

👉 相關位置: SimplePocApplication.cs : OnUnobservedTaskException


觸發情況

  • 當未 await 的任務(非同步方法)丟出例外時
  • 沒有呼叫 Task.Result / Task.Wait() 但被放置的任務
  • 在任務被 GC 收集的時機點,即會被通知為「未觀察的例外」

因此「例外必然即時顯現」並不一定,其發生的時間主要依運氣而定這一特徵。(雖然有措施可以幾乎強制發生例外


處理設計方針

  • 必須呼叫 e.SetObserved()
    不呼叫該方法,可能會導致進程立即結束的行為。
    → 強制先 SetObserved() 以防止崩潰再進行後續處理是底線。

  • UI 通知及繼續/結束判斷
    例外發生在背景中,因此需要「返回 UI 執行緒並通知使用者」。
    因此實作時呼叫 ConfirmOrShutdownAsync,以 Yes/No 對話框詢問「是否繼續」。

  • 統一管理 ExitCode
    未觀察的任務用的結束代碼由 ExitCodeForUnobservedTaskException 屬性定義,結束時會呼叫 Application.Current.Shutdown(exitCode)


樣本實作

/// <summary>
/// 處理未觀察的 Task 例外(在 GC 最終化時等會顯現)。
/// </summary>
/// <remarks>
/// 呼叫 e.SetObserved() 以防止進程崩潰。
/// 隨後進行 UI 通知 → 繼續/結束判斷。
/// </remarks>
protected virtual void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
    AggregateException agg = e.Exception;
    var flat = agg.Flatten();
    var inners = string.Join(Environment.NewLine,
        flat.InnerExceptions.Select((ex, i) =>
            $"  [{i + 1}] {ex.GetType().Name}: {ex.Message}"));

    var msg = $"""
        【UnobservedTaskException】

        在背景 Task 中發生了未觀察的例外。

        {agg.GetType().Name}
        {agg.Message}

        ---- 內部例外 ----
        {inners}

        是否繼續?
        """;
    // 非同步顯示確認對話框,根據結果執行結束處理
    _ = ConfirmOrShutdownAsync(msg, "非同步例外", shutdownAsync: () =>
    {
        // 使用者若選擇「不繼續」,將首先試圖以 WPF 的安全方式結束
        Current?.Shutdown(ExitCodeForUnobservedTaskException);
        return Task.CompletedTask;
    });

    // 設為觀察,從而避免立即結束
    e.SetObserved();
}

本樣本實作的總結

  • UnobservedTaskException 是再現性薄且依賴時間的通知事件
  • 首先呼叫 e.SetObserved() 以防止進程立即崩潰
  • 通過 UI 通知(ConfirmOrShutdownAsync)將「繼續或結束」的決定委託給使用者
  • ExitCode 統一為 ExitCodeForUnobservedTaskException,目標是構建不依賴執行環境的設計

接下來的章節將討論涉及整個應用程式的 CLR 層級未處理例外 (AppDomain.CurrentDomain.UnhandledException)。

6. CLR 整體的例外處理

AppDomain.CurrentDomain.UnhandledException 是什麼

無法通過 UI 執行緒或 Task 捕捉到的例外,最終會到達 CLR 整體的出口,即 AppDomain.CurrentDomain.UnhandledException

👉 相關位置: SimplePocApplication.cs : OnUnhandledException


觸發情況

  • 在背景執行緒或計時器回呼中發生的未處理例外
  • 在進行本地互操作時發生的部分例外
  • 涉及整個應用程式的致命錯誤(例如:主執行緒上的未處理例外)

當這個事件被觸發時,應用原則上無法繼續
在 .NET Framework 中總是會結束,而在 .NET Core 以後,UnhandledExceptionEventArgs.IsTerminating 在許多情況下也會返回 true


設計方針

  • 多重觸發保護
    為避免例外連鎖導致事件多次被呼叫,使用 _fatalEntered 標誌,只在第一次處理。同一進程第二次及以後的發生則立即結束。

  • 嘗試通知使用者
    呼叫 ConfirmOrShutdownAsync 以在 UI 顯示訊息,但考量到 UI 可能會凍結,因此設定了等待時間 TimeoutForUnhandledExceptionNotification(本實作為 15 秒)。

  • 最後使用 FailFast
    無論如何無法繼續,因此在記錄和通知完成後,使用 Environment.FailFast() 立即結束。
    → 使用 FailFast 的理由 是「若留下 dispose/finally 的執行空間反而危險(狀態可能已損壞)」。


樣本實作(最新版)

/// <summary>
/// 處理 CLR 整體的未處理例外(原則上無法回復)。
/// </summary>
/// <remarks>
/// 嘗試進行 UI 通知(最多等待 TimeoutForUnhandledExceptionNotification),
/// 最後使用 Environment.FailFast 立即結束。
/// 為避免例外迴圈,多重觸發由 _fatalEntered 進行保護。
/// </remarks>
protected virtual void OnUnhandledException(object? sender, UnhandledExceptionEventArgs e)
{
    var ex = e.ExceptionObject as Exception;

    // 防止多重發火(如果已經顯示致命對話框則立即結束)
    if (Interlocked.Exchange(ref _fatalEntered, 1) == 1)
    {
        Environment.FailFast(ex?.Message ?? "UnhandledException (reentered)");
        return;
    }

    var msg = $"""
        【UnhandledException】

        CLR 整體發生了未處理的例外。

        {ex?.GetType().Name}
        {ex?.Message}

        此例外無法繼續。
        應用程式將結束。
        """;
    var sem = new Semaphore(0, 1);
    ShutdownWithNotification(msg, "無法繼續的例外", sem);

    // 等待 UI 通知完成或是超時
    var shown = sem.WaitOne(TimeoutForUnhandledExceptionNotification);

    // 無條件立即結束(注意 finally/dispose 不會執行)
    Environment.FailFast((ex?.Message ?? "UnhandledException") + $"- MessageBox Shown: {shown}");

    // 本地函式:在 UI 通知後釋放信號
    async void ShutdownWithNotification(string msg, string title, Semaphore semaphore)
    {
        await ConfirmOrShutdownAsync(msg, title, isFatal: true);
        semaphore.Release();
    }
}

本樣本實作的總結

  • UnhandledException 是以無法繼續為前提的最後通道
  • _fatalEntered 保護多重觸發
  • 嘗試用 ConfirmOrShutdownAsync 進行通知,而在 UI 無反應時也設置了超時後強制結束
  • 最後必須使用 Environment.FailFast 進行立即結束(因為假設狀態已損壞)

實務提示

  • FailFast 是「最後的最後」。應該僅在這裡呼叫
  • 通知和日誌必須在 FailFast 前完成
  • 在開發中確認「是否真的使用 FailFast 結束」的有效方法是,故意丟出例外來驗證

下一章將整理到目前為止的共同處理,即用戶通知和結束策略的角色與 FailFast 和 Shutdown 的區別。

7. 共同處理:用戶通知和結束策略

到目前為止所見的 UI / 非同步任務 / CLR 整體的 3 個通道,這次設計中都最終流入 共同的通知和結束處理 中。其樞紐便是 ConfirmOrShutdownAsync


ConfirmOrShutdownAsync 的角色

  • 回到 UI 執行緒並顯示確認對話框

    • 通過 UIDispatcher 顯示 MessageBox,詢問使用者「是否繼續或結束」。
  • 統一退出判斷

    • 根據用戶的選擇「是/否」或「確定」,以及 Application.Current.Shutdown(exitCode) 的呼叫。
    • 離開代碼由 ExitCodeForUnhandledExceptionExitCodeForUnobservedTaskException 等屬性統一管理。
  • 失敗保護

    • 當對話框顯示失敗時,將其視為「繼續處理」,以避開崩潰(SafeShow 的設計)。

FailFast 與 Shutdown 的區別

這裡要整理的是 Environment.FailFastApplication.Current.Shutdown 的不同

項目 FailFast Shutdown
行為 立即結束進程 安全結束 WPF 的生命周期
finally/dispose 不會執行 會執行
生成轉儲 生成(依環境而定) 不生成
用途 致命例外無法繼續 用戶選擇或非致命例外的結束
  • FailFast

    • 當 CLR / 應用整體有損壞的高風險時使用。
    • 例:透過 UnhandledException 引發的致命錯誤。
    • 在完成日誌和通知後,立即結束進程。
  • Shutdown

    • 遵循 WPF 的生命週期,「關閉視窗→ 終止 Dispatcher → 退出事件」的「正常結束程序」。
    • 適合於使用者選擇「不繼續」或非同步任務例外的安全結束時。

UX 作為設計考量

  • 「是否繼續?」的對話框在 UX 上的平衡是困難的。

    • 在開發過程中選擇「是」很方便。
    • 在發佈版本中也可能建議選擇「否(結束)」。
  • 為了避免成為「可笑的笑話」,應:

    • 不轉儲包含 PII 的資訊。
    • 對話框的內容儘可能友好。
    • 根本上優先設計 不讓例外洩漏到這裡(在 ViewModel/Model 層處理)。

本樣本實作的總結

  • 所有的處理都流到 ConfirmOrShutdownAsync 中,統一處理通知和結束
  • FailFastShutdown 的劃分清楚
  • UI 顯示僅限於經由 UIDispatcher,同時可以替換測試
  • 在實務中與 IDialogService 等介面結合,可進一步提升擴展性

接下來將討論如何將此實作擴展到實務中——日誌記錄、PII 遮罩、多語言化、DI 等擴展點。


原文出處:https://qiita.com/cozyupk/items/a1c4072f629af5799080


共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
站長阿川

站長阿川私房教材:
學 JavaScript 前端,帶作品集去面試!

站長精心設計,帶你實作 63 個小專案,得到作品集!

立即開始免費試讀!