在使用 WPF 開發業務應用程式時,必然會面臨到「如何設計例外處理」這個問題。
當使用者按下畫面上的按鈕,瞬間應用程式崩潰且視窗消失——這一刻對於使用者來說就是「無法信任的軟體」。
「那麼全部用 try-catch
包起來不就好了?」我們可能會這樣想,但這樣會有以下的限制。
try-catch
是不現實的(開發效率會下降)try-catch
捕捉到(例如:未 await
的 Task
例外、背景執行緒的事件處理、經由計時器/回呼的例外等)此時出現的就是 全局例外處理。為整個應用程式準備一個「最後的防線」,無論例外發生在何處,都能接住,
以及其他的共通處理。
但並不是說「將所有崩潰資訊嬰兒般地保存下來」就足夠了。這可能包含 PII(個人可識別資訊)和敏感資訊。
若將這些直接吐出至例外日誌,則有可能成為 安全事故 的根源。
也就是說,重點在於「不將所有資訊全部記錄,而是只記錄與顯示必要的資訊」。
本文將以 WPF/MVVM 應用程式為題材,徹底解說以下內容:
接下來將帶進入 WPF 應用程式中「三個例外通道」的概述。
首先要整理的,是「全局例外處理」這個詞的意義。
這裡所謂的「全局」,是指在整個應用程式中共通運作的最後防線這個含義。
即使發生了單一 try-catch
無法處理的例外,最終仍有一個「在某處捕捉並處理的機制」。
在 WPF 應用程式中,大致上有以下 三個通道。本文將依「UI → 非同步任務 → CLR 整體」的順序進行說明。
UI 執行緒 (Application.DispatcherUnhandledException
)
DispatcherUnhandledException
事件捕捉。非同步任務 (TaskScheduler.UnobservedTaskException
)
Task
進行的非同步處理例外,如果有 await
則可以用 try-catch
捕捉。await
的任務 或 忽略結果的任務 的例外會在垃圾收集時以 UnobservedTaskException
的形式顯現。CLR 整體 (AppDomain.CurrentDomain.UnhandledException
)
UnhandledExceptionEventArgs.IsTerminating
屬性會發現,大多數情況下為 true
。「捕捉所有並吞掉所有」是不可能也不應該的。
不如整理出「在哪裡發生的例外可以繼續執行或無法繼續」的邊界,
在設計階段決定這些,對於實務上是非常重要的。
接下來的章節將根據這些思維,來觀察實際的程式碼實作(SimplePocApplication
和 UIThreadSimpleDispatcher
)。
接下來終於要基於實際的程式碼來看「全貌」。
本次以以下固定提交的 SimplePocApplication
類為題材。
SimplePocApplication
繼承自 Application
,擔任集中管理 WPF 整體例外處理的角色。
重點如下:
OnStartup
中註冊全局例外處理程式
DispatcherUnhandledException
(專為 UI 執行緒)AppDomain.CurrentDomain.UnhandledException
(專為 CLR 整體)TaskScheduler.UnobservedTaskException
(專為非同步任務)ConfirmOrShutdownAsync
Environment.FailFast
,立即結束程序另一個重要的是 UIThreadSimpleDispatcher.cs。
Dispatcher.CheckAccess()
和 Dispatcher.InvokeAsync()
,而是透過這個類來 分離 UI 執行緒控制的責任。如此一來,所有的流路徑都匯聚為 SimplePocApplication
。
接下來的章節將深入探討這「三個例外流路」中的每一個。
首先我們將查看 UI 執行緒的例外處理 (DispatcherUnhandledException
)。
WPF 的畫面顯示或事件處理是在 Dispatcher(UI 執行緒) 上運行。
當未處理的例外發生在這個執行緒時,最後會觸發 Application.DispatcherUnhandledException
。
👉 相關位置: SimplePocApplication.cs : OnDispatcherUnhandledException
try-catch
處理e.Handled = true
設為 抑制即時崩潰Application.Current.Shutdown(exitCode)
ExitCodeForUnhandledException
屬性定義,統一管理應用程式退出代碼UIDispatcher
進行(不從 UI 執行緒以外直接顯示 MessageBox)/// <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 由屬性統一管理DispatcherUnhandledException
的情況)接下來章節將詳解在垃圾收集的時機點出現的非同步任務未觀察例外(TaskScheduler.UnobservedTaskException
)。
C# 的 Task
是非同步處理的基本單元,但不一定會被 await
的方式使用。
例如以「fire-and-forget(丟棄不管)」執行的任務或略過結果而被丟棄的任務便是這樣。
當這些任務在內部丟出例外時,普通的 try-catch
會無法捕捉。
這些結果會在 垃圾收集的時機 被顯現為 TaskScheduler.UnobservedTaskException
。
👉 相關位置: SimplePocApplication.cs : OnUnobservedTaskException
await
的任務(非同步方法)丟出例外時Task.Result
/ Task.Wait()
但被放置的任務因此「例外必然即時顯現」並不一定,其發生的時間主要依運氣而定這一特徵。(雖然有措施可以幾乎強制發生例外)
必須呼叫 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()
以防止進程立即崩潰ConfirmOrShutdownAsync
)將「繼續或結束」的決定委託給使用者ExitCodeForUnobservedTaskException
,目標是構建不依賴執行環境的設計接下來的章節將討論涉及整個應用程式的 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 和 Shutdown 的區別。
到目前為止所見的 UI / 非同步任務 / CLR 整體的 3 個通道,這次設計中都最終流入 共同的通知和結束處理 中。其樞紐便是 ConfirmOrShutdownAsync。
回到 UI 執行緒並顯示確認對話框
UIDispatcher
顯示 MessageBox
,詢問使用者「是否繼續或結束」。統一退出判斷
Application.Current.Shutdown(exitCode)
的呼叫。ExitCodeForUnhandledException
和 ExitCodeForUnobservedTaskException
等屬性統一管理。失敗保護
這裡要整理的是 Environment.FailFast
和 Application.Current.Shutdown
的不同。
項目 | FailFast | Shutdown |
---|---|---|
行為 | 立即結束進程 | 安全結束 WPF 的生命周期 |
finally/dispose | 不會執行 | 會執行 |
生成轉儲 | 生成(依環境而定) | 不生成 |
用途 | 致命例外無法繼續 | 用戶選擇或非致命例外的結束 |
FailFast
UnhandledException
引發的致命錯誤。Shutdown
「是否繼續?」的對話框在 UX 上的平衡是困難的。
為了避免成為「可笑的笑話」,應:
ConfirmOrShutdownAsync
中,統一處理通知和結束UIDispatcher
,同時可以替換測試接下來將討論如何將此實作擴展到實務中——日誌記錄、PII 遮罩、多語言化、DI 等擴展點。