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