
簡介這份資源面向VB.NET開發(fā)者尤其是需要為WinForms或WPF項目構(gòu)建穩(wěn)定異常處理機制的中級程序員。它系統(tǒng)講解如何通過訂閱AppDomain.UnhandledException與Application.ThreadException事件集中捕獲應用程序域和UI線程中的未處理異常避免在每個函數(shù)中重復編寫Try...Catch代碼從而提升代碼可維護性與程序健壯性。壓縮包共34個文件約225KB包含9個vb源碼文件、3個resx資源文件、3個exe可執(zhí)行程序及pdb調(diào)試符號、xml配置、dll庫等覆蓋完整示例工程與運行結(jié)果便于直接對照調(diào)試。目前已有1206人學習下載。讀者可從中獲得全局異常處理的完整實現(xiàn)思路、事件注冊與錯誤恢復策略以及將異常邏輯封裝為獨立模塊的工程化做法適合用于排查程序崩潰、記錄錯誤日志和優(yōu)化用戶體驗等實際場景。1. 程序又雙叒叕崩了為什么你的 VB.NET 全局異常捕獲形同虛設剛泡好的咖啡還沒喝一口測試同事就發(fā)來消息“軟件又閃退了彈了個看不懂的框點一下就沒了?!蹦愦蜷_代碼明明在Application.Run外面包了Try...Catch甚至AppDomain的UnhandledException事件也掛了處理函數(shù)可程序該崩還是崩錯誤日志里干干凈凈連個堆棧都找不到。這種“全局異常捕獲失效”的故障幾乎每個維護過 VB.NET WinForms 或 WPF 項目的開發(fā)者都踩過。它折騰人的地方在于代碼看起來完全正確編譯不報錯調(diào)試時偶爾能捕獲一發(fā)布到客戶環(huán)境就變成黑匣子。這篇筆記就是寫給正在被這類問題卡住的你——無論你是剛接手老項目的新手還是想徹底理清 .NET 異常傳播機制的老手下面會從報錯現(xiàn)象一路拆到根因再給出能直接抄的配置和驗證步驟。2. 全局異常處理失效三種典型報錯與根因拆解2.1 現(xiàn)象一UnhandledException事件不觸發(fā)程序直接退出最常見的翻車場景是這樣的你在ApplicationEvents.vb或Program.vb里寫了AddHandler AppDomain.CurrentDomain.UnhandledException, AddressOf OnUnhandledException斷點也打上了但程序崩潰時斷點根本不進。先別懷疑人生這通常不是代碼寫錯了而是異常發(fā)生的線程或時機不對。AppDomain.UnhandledException只對非主線程上未處理的異常生效而且它有一個致命特性它只能通知你不能阻止程序終止。也就是說即使事件觸發(fā)了e.IsTerminating也是True你只能寫日志不能“吞掉”異常讓程序繼續(xù)跑。很多開發(fā)者誤以為掛上這個事件就等于全局捕獲結(jié)果發(fā)現(xiàn)主線程 UI 異常根本不走這里。更隱蔽的一種情況是異常發(fā)生在Application.Run之前。比如在Sub Main里初始化配置、連接數(shù)據(jù)庫時拋了異常此時消息循環(huán)還沒建立AppDomain的處理器雖然會觸發(fā)但如果你在處理器里又調(diào)用了需要消息循環(huán)的 UI 組件比如MessageBox.Show就會二次崩潰日志都來不及寫。 錯誤示范在 UnhandledException 里直接彈 UI 框 Private Sub OnUnhandledException(sender As Object, e As UnhandledExceptionEventArgs) 如果異常發(fā)生在主線程且消息循環(huán)已退出這行會再次拋出異常 MessageBox.Show(出錯了 e.ExceptionObject.ToString()) End Sub上面這段代碼的問題在于UnhandledException觸發(fā)時CLR 已經(jīng)開始卸載應用程序域此時任何依賴 UI 線程的操作都是不安全的。正確做法是只做純文件或事件日志寫入并且用Try...Catch把日志操作本身也包起來防止“日志異?!备采w“原始異常”。2.2 現(xiàn)象二ThreadException捕獲到了但堆棧信息丟失WinForms 項目里UI 線程異常應該由Application.ThreadException捕獲。你寫了處理函數(shù)也彈出了友好提示但日志里記錄的Exception.StackTrace是Nothing或者只有at System.Windows.Forms...這種框架層信息定位不到自己寫的業(yè)務代碼。根因通常有兩個。第一你捕獲的是ThreadExceptionEventArgs它的Exception屬性在某些 .NET Framework 版本下確實可能丟失原始堆棧尤其是異常經(jīng)過跨線程封送之后。第二更常見的是你在Catch塊里重新Throw New Exception(...)而沒有保留內(nèi)部異常導致原始堆棧被截斷。 正確做法保留原始異常作為 InnerException Try 業(yè)務代碼 Catch ex As Exception 記錄完整堆棧不要只記 ex.Message File.AppendAllText(error.log, DateTime.Now.ToString() - ex.ToString() Environment.NewLine) 如果必須包裝后拋出務必傳入 ex Throw New ApplicationException(業(yè)務處理失敗, ex) End Try注意ex.ToString()和ex.Message的區(qū)別前者包含異常類型、消息和完整堆棧后者只有一行描述。很多“日志沒信息”的故障就是因為日志代碼里寫的是ex.Message。另外Application.ThreadException只對主 UI 線程生效你在Task.Run或BackgroundWorker里拋出的異常它一概不管。2.3 現(xiàn)象三異步方法里的異?!皯{空消失”VB.NET 里用Async/Await寫異步方法時異常傳播規(guī)則和同步代碼完全不同。一個Async Sub事件處理器里拋出的異常不會走ThreadException也不會走UnhandledException而是直接讓進程崩潰連Try...Catch都可能攔不住——因為Async Sub的異常是在SynchronizationContext上重新拋出的時機取決于當前上下文。 危險寫法Async Sub 里不包 Try...Catch Private Async Sub btnLoad_Click(sender As Object, e As EventArgs) Handles btnLoad.Click Dim data Await FetchDataAsync() 如果這里拋異常進程直接崩 Label1.Text data End Sub 安全寫法所有 Async Sub 內(nèi)部必須自行處理異常 Private Async Sub btnLoad_Click(sender As Object, e As EventArgs) Handles btnLoad.Click Try Dim data Await FetchDataAsync() Label1.Text data Catch ex As Exception LogError(ex) MessageBox.Show(加載失敗請重試) End Try End Sub這里的關(guān)鍵認知是Async Sub只應該用于事件處理器且必須在內(nèi)部捕獲所有異常。對于非事件處理器一律用Async Function ... As Task讓調(diào)用方Await時自然捕獲。如果你在Async Sub里漏了Catch那這個異常就成了“孤兒異?!比痔幚砥饕簿炔涣恕?. 手把手修復從入口點開始搭建可靠的異常捕獲鏈3.1 重寫Sub Main把全局捕獲放在消息循環(huán)之前WinForms 項目默認的啟動對象是窗體但要做全局異常處理必須把啟動對象改成Sub Main手動控制初始化順序。下面是一個經(jīng)過驗證的入口點模板適用于 .NET Framework 4.x 的 VB.NET WinForms 項目。 在項目屬性里把啟動對象設置為 Sub Main Module Program STAThread Sub Main() 第一步掛接 UI 線程異常處理器 AddHandler Application.ThreadException, AddressOf OnUiThreadException 第二步強制 UI 線程異常走 ThreadException而不是直接崩潰 Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException) 第三步掛接非 UI 線程異常處理器只能記錄不能阻止終止 AddHandler AppDomain.CurrentDomain.UnhandledException, AddressOf OnDomainException 第四步掛接 Task 未觀察異常處理器.NET 4.5 AddHandler TaskScheduler.UnobservedTaskException, AddressOf OnTaskException Try Application.EnableVisualStyles() Application.SetCompatibleTextRenderingDefault(False) Application.Run(New MainForm()) Catch ex As Exception 兜底捕獲 Application.Run 本身拋出的異常 LogError(Main, ex) End Try End Sub Private Sub OnUiThreadException(sender As Object, e As ThreadExceptionEventArgs) LogError(UI線程, e.Exception) MessageBox.Show(程序遇到問題但已記錄日志您可以繼續(xù)操作。, 提示, MessageBoxButtons.OK, MessageBoxIcon.Warning) End Sub Private Sub OnDomainException(sender As Object, e As UnhandledExceptionEventArgs) Dim ex TryCast(e.ExceptionObject, Exception) If ex IsNot Nothing Then LogError(非UI線程, ex) 注意這里不要彈 MessageBox進程即將終止 End Sub Private Sub OnTaskException(sender As Object, e As UnobservedTaskExceptionEventArgs) LogError(Task異常, e.Exception) e.SetObserved() 標記為已觀察防止進程崩潰 End Sub Private Sub LogError(source As String, ex As Exception) Try Dim logPath IO.Path.Combine(Application.StartupPath, error.log) Dim msg $[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [{source}] {ex.ToString()}{Environment.NewLine} IO.File.AppendAllText(logPath, msg) Catch 日志寫入失敗時靜默處理避免二次崩潰 End Try End Sub End Module這段代碼里有幾個參數(shù)和調(diào)用順序需要特別說明。Application.SetUnhandledExceptionMode必須在創(chuàng)建任何窗體之前調(diào)用否則會拋InvalidOperationException。傳入UnhandledExceptionMode.CatchException表示所有 UI 線程異常都交給ThreadException事件處理如果傳ThrowException則異常會直接拋到Application.Run外面由Try...Catch捕獲。兩種模式各有適用場景但絕大多數(shù)業(yè)務系統(tǒng)用CatchException更穩(wěn)妥。TaskScheduler.UnobservedTaskException是 .NET 4.5 引入的專門處理那些沒有被Await或.Wait()觀察到的Task異常。在 .NET 4.0 時代這類異常會直接讓進程崩潰4.5 之后默認策略改為忽略但如果你在app.config里配置了ThrowUnobservedTaskExceptions enabledtrue/它又會崩潰。所以顯式掛接這個事件并調(diào)用SetObserved()是最保險的做法。3.2 配置文件里的隱藏開關(guān)app.config必須檢查的兩處很多“全局捕獲失效”的故障根因不在代碼而在app.config里一個不起眼的配置項。打開你的配置文件確認以下兩個節(jié)點。configuration runtime !-- 確保未觀察的 Task 異常不會直接終止進程 -- ThrowUnobservedTaskExceptions enabledfalse/ /runtime system.windows.forms !-- 某些舊項目會在這里設置但推薦用代碼控制 -- !-- applicationThreadExceptionMode valueCatchException/ -- /system.windows.forms /configurationThrowUnobservedTaskExceptions如果設為true那么任何沒有被處理的Task異常都會觸發(fā)AppDomain.UnhandledException并終止進程。在 .NET 4.5 之后的運行時里默認值是false但如果你從舊項目升級上來這個配置可能被顯式寫成了true。我一般會把它顯式設為false然后在代碼里用UnobservedTaskException事件兜底記錄日志。另一個容易忽略的點是如果你在 Visual Studio 的“異?!痹O置里勾選了“引發(fā)時中斷”調(diào)試時會感覺全局捕獲沒生效其實只是 IDE 在異常拋出的瞬間就斷下來了。發(fā)布版本不會有這個問題所以排查時一定要用 Release 模式跑一遍或者按CtrlAltE把Common Language Runtime Exceptions的“引發(fā)時中斷”取消勾選。3.3 驗證捕獲鏈是否生效三個必做的測試用例寫完代碼別急著交付用下面三個場景驗證一遍能過濾掉九成的“假捕獲”。第一個用例在主窗體Load事件里直接Throw New Exception(測試UI異常)。預期結(jié)果是彈出友好提示error.log里出現(xiàn)完整堆棧程序不退出。第二個用例在Task.Run里拋異常且不Await、不.Wait()。預期結(jié)果是UnobservedTaskException觸發(fā)日志記錄程序繼續(xù)運行。注意這個事件觸發(fā)有延遲通常要等 GC 回收 Task 對象后才觸發(fā)所以測試時可以手動調(diào)用GC.Collect()加速。第三個用例在Sub Main里、Application.Run之前拋異常。預期結(jié)果是Try...Catch捕獲日志記錄程序優(yōu)雅退出而不是彈 Windows 錯誤報告。 測試用例驗證非 UI 線程異常是否被記錄 Private Sub TestBackgroundException() Task.Run(Sub() Throw New InvalidOperationException(后臺線程測試異常) End Sub) 強制 GC 觸發(fā) UnobservedTaskException GC.Collect() GC.WaitForPendingFinalizers() End Sub如果這三個用例都通過了說明你的全局異常捕獲鏈基本可靠。如果某個用例失敗回到對應章節(jié)檢查事件掛接順序和配置項。4. 避坑指南五個讓全局捕獲翻車的常見操作4.1 在UnhandledException里彈 MessageBox現(xiàn)象程序崩潰時彈出“程序已停止工作”日志文件為空。原因AppDomain.UnhandledException觸發(fā)時 CLR 正在終止進程此時調(diào)用MessageBox.Show會阻塞在消息循環(huán)上而消息循環(huán)已經(jīng)不可用導致二次異常覆蓋原始異常。解決該事件處理器里只做文件寫入或EventLog寫入所有 UI 操作一律禁止。4.2 用On Error Resume Next代替結(jié)構(gòu)化異常處理現(xiàn)象異常被吞掉業(yè)務邏輯繼續(xù)執(zhí)行但數(shù)據(jù)錯亂。原因VB.NET 兼容舊版On Error語句但它和Try...Catch混用時行為不可預測且無法捕獲Async方法異常。解決全面禁用On Error Resume Next在項目屬性里開啟“Option Strict On”和“Option Explicit On”強制結(jié)構(gòu)化異常處理。4.3 忘記處理Async Sub的內(nèi)部異?,F(xiàn)象點擊按鈕后程序直接消失無任何日志。原因Async Sub的異常在SynchronizationContext上重新拋出不經(jīng)過ThreadException。解決所有Async Sub必須包含Try...Catch或者改用Async Function并讓調(diào)用方Await。4.4 在Catch塊里只記ex.Message現(xiàn)象日志里只有“對象引用未設置為對象的實例”不知道哪一行出錯。原因ex.Message不包含堆棧。解決始終記錄ex.ToString()如果異常有InnerException也要遞歸展開。4.5 發(fā)布時用了 Debug 配置現(xiàn)象本地測試正常客戶環(huán)境崩潰無日志。原因Debug 配置下 IDE 可能攔截了異常且DEBUG常量影響日志代碼路徑。解決發(fā)布前用 Release 模式完整跑一遍異常測試用例確認日志文件能正常生成。5. 快速定位全局異常捕獲問題的排查路徑當你面對一個“全局捕獲不生效”的項目時不要急著改代碼按下面這個順序走一遍通常十分鐘內(nèi)就能鎖定根因。第一步確認啟動對象。打開項目屬性 → 應用程序 → 啟動對象如果是某個窗體而不是Sub Main那么你寫在Module里的全局捕獲代碼可能根本沒執(zhí)行。這是最高頻的翻車點我見過至少三個項目栽在這里。第二步檢查事件掛接順序。Application.SetUnhandledExceptionMode必須在Application.Run之前、且在創(chuàng)建任何窗體之前調(diào)用。如果你在MainForm_Load里才掛接ThreadException那之前發(fā)生的異常都捕獲不到。第三步用一張對照表快速判斷異常類型和對應的處理器。異常發(fā)生位置應觸發(fā)的處理器能否阻止程序退出主 UI 線程同步代碼Application.ThreadException能非 UI 線程同步代碼AppDomain.UnhandledException不能只能記錄Task內(nèi)未觀察異常TaskScheduler.UnobservedTaskException能調(diào)用SetObservedAsync Sub內(nèi)異常無全局處理器必須內(nèi)部Catch不能Application.Run之前Sub Main里的Try...Catch能第四步檢查app.config里的ThrowUnobservedTaskExceptions和legacyUnhandledExceptionPolicy。后者是 .NET 1.1 時代的兼容開關(guān)如果被設為trueUnhandledException的行為會變得和現(xiàn)代 .NET 完全不同。除非維護的是十幾年前的老系統(tǒng)否則一律刪掉這個節(jié)點。第五步寫一個最小復現(xiàn)用例。新建一個空白 WinForms 項目只放一個按鈕按鈕里Throw New Exception然后把你項目里的全局捕獲代碼原樣復制過去。如果最小項目能捕獲而你的項目不能說明問題出在項目配置或第三方庫干擾如果最小項目也不能捕獲那就是代碼本身的問題。從那以后我每次接手一個 VB.NET 老項目第一件事就是按這個順序過一遍全局異常捕獲鏈確認error.log能正常寫入、三個測試用例全部通過才敢開始改業(yè)務代碼。希望這些踩坑經(jīng)驗能幫你少走彎路。本文還有配套的精品資源點擊獲取