)
簡介面向C#初學(xué)者的實例講解資料聚焦委托與事件兩大核心概念。C#委托是類型安全的方法引用事件則基于委托實現(xiàn)對象間通知文中從delegate定義、EventHandler預(yù)定義委托講起逐步說明如何用event關(guān)鍵字聲明事件、使用“”訂閱處理方法并在按鈕事件中觸發(fā)自定義事件最終由處理方法完成響應(yīng)。資源包內(nèi)為單個PDF文件大小僅34KB篇幅精簡適合碎片時間閱讀或作為復(fù)習(xí)速查。已有534人瀏覽學(xué)習(xí)對入門者具有一定參考價值。示例給出了完整可運行的C#窗體代碼在Form1構(gòu)造函數(shù)中將d方法綁定到事件abutton1_Click內(nèi)觸發(fā)a(ss)調(diào)用d并顯示消息框清晰展示了“聲明—訂閱—觸發(fā)—處理”的完整鏈路。讀者按示例操作即可理解委托與事件在真實UI程序中的協(xié)作方式并為多窗體通信、組件解耦等擴(kuò)展場景提供思路基礎(chǔ)。1. 先說結(jié)論C#事件處理和委托是解耦的鑰匙理解這兩個概念比記住語法更重要很多人在C#窗體程序里寫過這樣的代碼A窗體要通知B窗體刷新數(shù)據(jù)直接把B窗體的公共方法拿來調(diào)用結(jié)果界面耦合得厲害改一處動全身。后來某位開發(fā)者把調(diào)用改成事件觸發(fā)代碼清爽了一個量級。這篇筆記圍繞C#事件處理和委托event delegate的實例展開用最簡單可運行的方式說明聲明、訂閱、觸發(fā)的完整鏈路再補(bǔ)充實際項目中真正會踩的坑。適合剛學(xué)完C#語法、想搞懂事件機(jī)制的新手也適合寫了幾個月窗體程序但一直對event和delegate邊界模糊的開發(fā)者。讀完能自己搭出一個跨窗體通知的骨架知道觸發(fā)時為什么要判空知道多線程下為什么會報“線程間操作無效”。先建立正確的心智模型再動手寫代碼后面就順了。2. 把委托和事件分開看為什么裸委托在窗體程序里是隱患2.1 委托一個類型安全的函數(shù)指針能裝一個也能裝一串委托的本質(zhì)是“方法的類型”。int能聲明整數(shù)變量string能聲明字符串變量委托類型能聲明“方法變量”。定義委托語法很簡單public delegate void NotifyHandler(string message);這句聲明定義了一種方法形狀返回值void參數(shù)一個string。之后任何“返回void、只有一個string參數(shù)”的方法都可以被這個委托引用。聲明的關(guān)鍵是簽名完全一致不一致編譯就報錯這就是“類型安全”的含義。NotifyHandler _handler; _handler ShowMessage; // 方法名直接賦值 _handler(hello); // 通過委托變量調(diào)用方法 void ShowMessage(string msg) Console.WriteLine(msg);邏輯說明第一行聲明委托變量第二行把ShowMessage方法“裝進(jìn)”委托變量第三行通過委托調(diào)用。參數(shù)說明委托變量可以指向?qū)嵗椒ā㈧o態(tài)方法、甚至匿名方法和Lambda。實際項目中常用的是多播——用把多個方法掛到同一個委托變量上NotifyHandler _handler MethodA; _handler MethodB; _handler(多播測試);此時調(diào)用一次委托MethodA和MethodB按訂閱順序依次執(zhí)行。多播委托返回值只取最后一個方法的結(jié)果所以委托設(shè)計上一般用void返回這是經(jīng)驗的總結(jié)。2.2 事件對外只開放“訂閱”和“退訂”觸發(fā)權(quán)鎖在類內(nèi)部事件可以理解為“受限的委托字段”。聲明方式加上event關(guān)鍵字public event NotifyHandler NotifyOccurred;與直接public委托字段相比區(qū)別很關(guān)鍵外部代碼可以對事件使用訂閱、-退訂但不能直接賦值更不能在類外部觸發(fā)。下面這段代碼在類外觸發(fā)會被編譯器攔住// 下面兩行都編譯不過 someObject.NotifyOccurred AnotherMethod; someObject.NotifyOccurred(外部直接觸發(fā));編譯器報錯類似“事件只能出現(xiàn)在或-的左邊”。這個限制不是形式主義事件源類掌握觸發(fā)時機(jī)訂閱者只能被動響應(yīng)雙方通過事件契約解耦。某位開發(fā)者早期圖省事用public委托字段做跨窗體通知結(jié)果別的窗體在任意時刻都能觸發(fā)對方的事件調(diào)試時根本分不清是誰調(diào)用的。改成event之后觸發(fā)入口收窄到事件源類內(nèi)部問題定位快得多。2.3 選型回調(diào)、接口還是事件按場景定委托和事件不是互斥方案。委托適合“方法作為參數(shù)傳入”把行為注入到另一個方法中典型是List的Where、Select。事件適合“某個動作發(fā)生后通知外部”典型是按鈕點擊、數(shù)據(jù)加載完成、客戶端斷開連接。中間還有接口方案適合“訂閱方必須實現(xiàn)一組固定方法”的時候。方案適用場景優(yōu)點注意點委托回調(diào)參數(shù)、方法注入靈活、類型安全慎用public字段外部可任意觸發(fā)事件狀態(tài)變化通知、跨窗體通信觸發(fā)權(quán)受控、解耦好需要判空、注意退訂接口固定行為契約結(jié)構(gòu)清晰訂閱方必須實現(xiàn)全部方法從工程習(xí)慣來看窗體程序里做“某某窗體通知某某窗體”優(yōu)先用事件做通用工具庫、需要調(diào)用方傳入邏輯時用委托。這個選擇沒有絕對的對錯但從維護(hù)成本看事件在解耦上的收益通常更大。3. 完整實例拆解從最小可運行代碼到跨窗體通知3.1 最小實例事件聲明、訂閱與觸發(fā)的完整骨架先看一段來自原筆記的簡潔實例它用最簡單的方式演示了事件處理的核心環(huán)節(jié)。代碼在WinForms環(huán)境下運行窗體上有一個按鈕public delegate void CustomHandler(string s); // 自定義委托簽名void(string) public partial class Form1 : Form { public event CustomHandler NotifyEvent; // 聲明事件類型是上面的委托 public Form1() { InitializeComponent(); NotifyEvent HandleNotify; // 在構(gòu)造函數(shù)中訂閱事件 } private void button1_Click(object sender, EventArgs e) { MessageBox.Show(即將引發(fā)事件); NotifyEvent?.Invoke(ss); // 觸發(fā)事件帶上字符串參數(shù) MessageBox.Show(事件處理完畢繼續(xù)執(zhí)行后續(xù)邏輯); } public void HandleNotify(string message) { MessageBox.Show(d. message); } }邏輯說明按鈕點擊后先彈出“即將引發(fā)事件”接著觸發(fā)NotifyEvent事件綁定的HandleNotify方法被同步調(diào)用彈出“d.ss”然后回到按鈕點擊方法繼續(xù)執(zhí)行最后彈出“事件處理完畢”。訂閱動作放在構(gòu)造函數(shù)里意味著窗體一創(chuàng)建事件就綁好了。這里用?.Invoke替代直接調(diào)用是為了在無人訂閱時不拋空引用異常。參數(shù)說明委托CustomHandler接收string參數(shù)觸發(fā)時傳入“ss”符合委托簽名。如果想傳多個數(shù)據(jù)可以繼續(xù)加參數(shù)比如void CustomHandler(string senderName, int code)同步修改觸發(fā)處的實參即可。但這個寫法有個副作用事件處理和觸發(fā)在同一個線程里同步執(zhí)行后續(xù)會說到如何在異步場景中處理。3.2 從單窗體到跨窗體子窗體通知父窗體刷新的常見做法實際項目里最常見的是跨窗體通知?,F(xiàn)在做一個帶標(biāo)準(zhǔn)EventHandler簽名的版本。標(biāo)準(zhǔn)委托是public delegate void EventHandler(object sender, EventArgs e)事件攜帶兩個參數(shù)——發(fā)送方和事件數(shù)據(jù)。下面用子窗體去通知父窗體刷新列表// 子窗體類 public partial class ChildForm : Form { // 聲明標(biāo)準(zhǔn)事件使用系統(tǒng)定義的事件委托 public event EventHandler DataSaved; private void btnSave_Click(object sender, EventArgs e) { // 保存業(yè)務(wù)數(shù)據(jù)省略 MessageBox.Show(數(shù)據(jù)已保存); // 觸發(fā)事件把當(dāng)前窗體作為sender傳入 DataSaved?.Invoke(this, EventArgs.Empty); } }父窗體一側(cè)的訂閱// 父窗體中打開子窗體的位置 var child new ChildForm(); child.DataSaved RefreshListView; // 訂閱子窗體事件 child.Show(); private void RefreshListView(object sender, EventArgs e) { // 從數(shù)據(jù)庫重新加載數(shù)據(jù) LoadDataToListView(); }邏輯說明子窗體保存按鈕點擊后保存自身業(yè)務(wù)數(shù)據(jù)然后觸發(fā)DataSaved事件父窗體因為在打開子窗體時訂閱了這個事件所以自動執(zhí)行RefreshListView方法完成列表刷新。整個過程子窗體不直接持有父窗體的引用兩者只在事件上關(guān)聯(lián)。參數(shù)說明sender傳的是子窗體實例this這樣父窗體在處理時能通過(ChildForm)sender拿到來源對象做強(qiáng)類型操作。這里用EventArgs.Empty作為空數(shù)據(jù)沒有需要傳遞的信息。如果想帶數(shù)據(jù)替換為public event EventHandlerDataSavedEventArgs DataSaved;自定義事件參數(shù)類繼承EventArgs并攜帶信息。3.3 從string參數(shù)到自定義事件參數(shù)攜帶結(jié)構(gòu)化數(shù)據(jù)的標(biāo)準(zhǔn)做法當(dāng)事件需要傳遞的信息超過一個簡單字符串工程上習(xí)慣是定義專門的事件參數(shù)類public class DataSavedEventArgs : EventArgs { public string FileName { get; } public long FileSize { get; } public DataSavedEventArgs(string fileName, long fileSize) { FileName fileName; FileSize fileSize; } } public partial class ChildForm : Form { // 泛型事件委托攜帶自定義參數(shù) public event EventHandlerDataSavedEventArgs DataSaved; private void btnSave_Click(object sender, EventArgs e) { var args new DataSavedEventArgs(report.xlsx, 20480); DataSaved?.Invoke(this, args); } }訂閱方接收時從事件參數(shù)里取值private void HandleDataSaved(object sender, DataSavedEventArgs e) { statusLabel.Text $已保存{e.FileName}大小{e.FileSize}字節(jié); }邏輯說明自定義事件參數(shù)類讓事件攜帶的信息從“裸字符串”變成了“有名字的字段”調(diào)用方不再靠記憶參數(shù)順序而是通過屬性名取值。這個模式在.NET類庫中大量使用是最值得養(yǎng)成的習(xí)慣。參數(shù)說明事件參數(shù)類命名統(tǒng)一以EventArgs結(jié)尾泛型委托EventHandler 讓編譯器自動約束事件參數(shù)類型寫錯類型在編譯期就暴露而不是運行期才翻車。4. 避坑與排查事件和委托的五條實戰(zhàn)踩坑記錄4.1 觸發(fā)事件時報“未將對象引用設(shè)置到對象的實例”現(xiàn)象代碼寫NotifyEvent(ss)運行時彈出NullReferenceException。原因事件字段是委托類型初始值是null。如果沒有任何訂閱者調(diào)用過觸發(fā)時就是調(diào)用null變量必然拋異常。原筆記直接寫a(ss)能跑通是因為構(gòu)造函數(shù)中訂閱了d方法但換個場景、訂閱代碼放錯位置或被移除馬上就崩。解決統(tǒng)一使用空條件運算符觸發(fā)即NotifyEvent?.Invoke(ss)。這個寫法一行解決判空問題后續(xù)所有新代碼都用這個模式。另有一個隱藏細(xì)節(jié)使用局部變量復(fù)制再調(diào)用也能避免多線程下的競態(tài)條件但窗體程序里更多用?.Invoke簡單直接。4.2 另一個線程觸發(fā)了事件窗體控件更新報“線程間操作無效”現(xiàn)象后臺線程處理完數(shù)據(jù)后觸發(fā)事件事件處理方法里更新label.Text運行時拋InvalidOperationException提示“線程間操作無效”。原因WinForms的UI控件只能在創(chuàng)建它的線程上更新。委托事件本身不負(fù)責(zé)線程切換后臺線程觸發(fā)事件時執(zhí)行上下文還在后臺線程直接更新控件就撞上了界面線程安全檢查。解決事件處理方法里用this.Invoke((MethodInvoker)delegate { label.Text ...; });來切換回UI線程。更優(yōu)雅的是用async/await配合Task.Run讓await返回后自然回到UI上下文。注意一點事件觸發(fā)處不要自己加線程切換那是處理方職責(zé)否則會泄漏線程模型到事件源。4.3 多次打開子窗體事件處理方法被重復(fù)執(zhí)行現(xiàn)象父窗體打開子窗體后關(guān)閉再打開再關(guān)閉第三次打開時事件觸發(fā)后RefreshListView被調(diào)用了三次。原因每次打開子窗體都會執(zhí)行child.DataSaved RefreshListView而事件字段持有的是委托的多播列表。子窗體關(guān)閉后垃圾回收不會自動收回事件訂閱舊實例的訂閱鏈還在新實例又加了一次。解決在父窗體關(guān)閉子窗體時顯式退訂或者干脆在子窗體Load事件里訂閱、在FormClosed里退訂。對于單例子窗體每次打開前先child.DataSaved - RefreshListView再就能防重復(fù)。核心習(xí)慣是誰訂閱誰退訂訂閱和退訂配對出現(xiàn)。4.4 事件處理方法里拋異常后續(xù)訂閱者不再執(zhí)行現(xiàn)象事件上方兩個訂閱方法第一個拋出異常第二個沒有被調(diào)用整個程序像卡了一下。原因多播委托是順序執(zhí)行的某個方法的異常會中止后續(xù)方法的執(zhí)行。這和“廣播通知”的直覺不符很多人以為事件應(yīng)該隔離異常。解決在事件源類內(nèi)部對訂閱者的調(diào)用做統(tǒng)一包裝——遍歷委托調(diào)用列表逐個try/catch讓一個訂閱者失敗不影響其他訂閱者。另一個方案是委托返回bool每個處理方法返回是否成功但改動大不推薦。最實用的還是try/catch隔離把事件處理方法當(dāng)成黑匣子誰出錯誰自己處理。4.5 委托與事件簽名不匹配編譯期不報錯但運行期觸發(fā)失敗現(xiàn)象聲明委托public delegate void NotifyHandler(string s)后面改成public delegate void NotifyHandler(string s, int code)但事件聲明處忘了改編譯報錯。原因事件聲明依賴委托類型委托類型一變事件簽名跟著變所有訂閱和觸發(fā)點都要對齊。手改很容易漏掉某一處。解決從一開始就用框架內(nèi)置的EventHandlerEventHandler 不需要自定義委托就盡量不自定義。對大多數(shù)窗體通信場景EventHandlerT覆蓋了所有需求而且編譯器會在類型不匹配時給出明確提示。自定義委托只用在“返回值或參數(shù)形狀與標(biāo)準(zhǔn)模式差距很大”的時候。下面把常見問題整理成一張對照表方便快速定位錯誤現(xiàn)象主要原因修復(fù)方向NullReferenceException事件無訂閱者觸發(fā)?.Invoke判空觸發(fā)線程間操作無效后臺線程更新UI處理方Invoke或await處理方法重復(fù)執(zhí)行重復(fù)訂閱未退訂-與成對出現(xiàn)異常中斷后續(xù)訂閱者多播順序執(zhí)行無隔離遍歷調(diào)用鏈try/catch編譯報錯簽名不符委托類型不一致統(tǒng)一用EventHandler5. 進(jìn)階用法泛型事件參數(shù)與異常隔離的組合實踐5.1 用EventHandler 替代自定義委托寫事件處理代碼時我一般會默認(rèn)走EventHandlerT。這樣事件參數(shù)有了強(qiáng)類型約束訂閱方拿到的不僅是數(shù)據(jù)還有類型信息。比如做下載任務(wù)通知可以定義一個DownloadEventArgs攜帶文件名、進(jìn)度百分比、是否成功三個字段事件簽名保持EventHandler 后續(xù)加字段只改參數(shù)類不改事件聲明。public class DownloadEventArgs : EventArgs { public string FileName { get; } public int ProgressPercent { get; } public bool Succeeded { get; } public DownloadEventArgs(string fileName, int percent, bool ok) { FileName fileName; ProgressPercent percent; Succeeded ok; } }好處是觸發(fā)方和訂閱方的編譯期契約非常明確。原實例那種裸string參數(shù)在快速演示時夠用但到了多人協(xié)作的項目里字段名取代魔法字符串能省很多傳參錯誤。5.2 在觸發(fā)處做參數(shù)快照避免上下文被外部篡改委托是引用類型事件參數(shù)對象同樣在堆上共享。如果訂閱方在事件處理方法中修改了傳入對象的狀態(tài)觸發(fā)方后續(xù)代碼可能感知到變化。常見做法是讓事件參數(shù)類盡量只讀——構(gòu)造函數(shù)一次性賦值屬性不帶setter。上面DownloadEventArgs里屬性只有g(shù)etter就是這個考慮。另一個技巧是使用record類型自帶值語義但會引入較新的語言特性按團(tuán)隊習(xí)慣取舍。5.3 事件觸發(fā)上下文中的異常隔離習(xí)慣某個模擬項目X中日志訂閱方法里拋了異常導(dǎo)致主業(yè)務(wù)方法中斷排查了很久才發(fā)現(xiàn)是日志系統(tǒng)自身的問題。從此之后我在事件觸發(fā)位置做兩層防護(hù)觸發(fā)處判空事件處理邏輯內(nèi)try/catch。這是強(qiáng)制習(xí)慣。private void RaiseDataSaved(string fileName, long fileSize) { var handler DataSaved; // 局部變量防止多線程競態(tài) if (handler null) return; var args new DataSavedEventArgs(fileName, fileSize); var invocationList handler.GetInvocationList(); foreach (Delegate single in invocationList) { try { ((EventHandlerDataSavedEventArgs)single)(this, args); } catch (Exception ex) { Trace.WriteLine($事件處理方法異常{ex.Message}); } } }這段代碼把多播委托里的每個訂閱者單獨取出逐個調(diào)用并包裹異常一個訂閱者掛掉不影響其他訂閱者。局部變量handler避免觸發(fā)瞬間另一線程退訂引起的委托鏈變化這是考慮多播委托不變性后的寫法。參數(shù)說明GetInvocationList返回Delegate數(shù)組按訂閱順序排列強(qiáng)轉(zhuǎn)成具體委托類型再調(diào)用。Trace.WriteLine只記錄不拋出日志隔離在這層做掉。從那以后我每次寫事件觸發(fā)都強(qiáng)制走一遍“判空-快照-逐項try/catch”的流程已經(jīng)很少遇到事件鏈上連環(huán)崩的情況。希望幫到你。本文還有配套的精品資源點擊獲取