標準的 .NET 事件模式

上一個

.NET 事件通常會遵循少數已知的模式。 在這些模式上標準化表示開發人員可以套用這些標準模式的知識,這些模式可以套用至任何 .NET 事件程式。

讓我們瀏覽這些標準模式,讓您具備建立標準事件來源所需的所有知識,並在程式碼中訂閱和處理標準事件。

事件委派簽章

.NET 事件委派的標準簽名是︰

void EventRaised(object sender, EventArgs args);

此標準簽章提供使用事件時機的深入解析:

  • 傳回類型為 void。 事件可以有從零到多個監聽器。 引發事件會通知所有監聽器。 一般而言,接聽程式不會提供值來回應事件。
  • 事件指出傳送者:事件簽章包含引發事件的物件。 這可提供任何接聽程式與傳送者通訊的機制。 sender 的編譯時期類型是 System.Object,即使您可能知道一向正確之衍生程度較高的類型。 依照慣例使用 object。
  • 事件會封裝單一結構中的詳細資訊:參數 args 是衍生自 System.EventArgs 的類型,其中包含任何其他必要資訊。 (您將在 下一節中看到 此慣例不再強制執行。)如果您的事件類型不再需要任何其他參數,您仍必須提供這兩個參數。 有一個特殊值 EventArgs.Empty,你應該使用來表示你的事件不包含任何其他資訊。

我們要組建一個類別,列出目錄中的檔案,或其遵循模式的任一子目錄。 每次找到符合模式的檔案,此元件都會引發事件。

使用事件模型可提供一些設計優勢。 您可以建立多個事件接聽程式,於找到搜尋的檔案時,執行不同的動作。 結合不同的接聽程式可以建立更強固的演算法。

以下是尋找檔案的初始事件參數宣告:

public class FileFoundArgs : EventArgs
{
    public string FoundFile { get; }

    public FileFoundArgs(string fileName) => FoundFile = fileName;
}

即使這個類型看起來像是小型的僅限資料類型,您也應該遵循規範,讓它成為參考 (class) 類型。 這表示參數物件會以傳址方式傳遞,而且所有訂閱者都會檢視數據的任何更新。 第一個版本是不可變的物件。 您應該更偏好讓事件引數類型中的屬性成為不可變。 如此一來,一個訂閱者就無法在另一個訂閱者看到值之前變更這些值。 (這種做法有例外,如您稍後所見。

接下來,我們需要在 FileSearcher 類別中建立事件宣告。 使用 System.EventHandler<TEventArgs> 類型表示您不需要建立另一個類型定義。 您只要使用泛型專門化。

我們要填寫 FileSearcher 類別,以搜尋符合模式的檔案,並在發現相符項目時引發正確的事件。

public class FileSearcher
{
    public event EventHandler<FileFoundArgs>? FileFound;

    public void Search(string directory, string searchPattern)
    {
        foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
        {
            FileFound?.Invoke(this, new FileFoundArgs(file));
        }
    }
}

定義和觸發欄位型事件

將事件新增至類別的最簡單方式,是將該事件宣告為公用欄位,如上例所示︰

public event EventHandler<FileFoundArgs>? FileFound;

它看起來像是在宣告公共欄位,這似乎是一種不佳的物件導向做法。 您想要透過屬性或方法保護資料存取。 雖然此程式代碼看起來可能很糟糕,但編譯程式所產生的程式代碼會建立包裝函式,讓事件物件只能以安全的方式存取。 類似欄位的事件上唯一可用的作業是 新增 和 移除 處理程式:

var fileLister = new FileSearcher();
int filesFound = 0;

EventHandler<FileFoundArgs> onFileFound = (sender, eventArgs) =>
{
    Console.WriteLine(eventArgs.FoundFile);
    filesFound++;
};

fileLister.FileFound += onFileFound;
fileLister.FileFound -= onFileFound;

處理程式有局部變數。 如果您使用 Lambda 主體, remove 處理程式將無法正常運作。 它將是不同的委派執行個體,並會悄然不執行任何動作。

類別外部的程式代碼無法引發事件,也無法執行任何其他作業。

從 C# 14 開始,事件可以宣告為 部分成員。 部分事件宣告必須包含 定義宣告 和 實作宣告。 定義宣告必須使用類似欄位的事件語法。 實作宣告必須宣告 add 和 remove 處理程式。

從事件訂閱者傳回值

您的簡單版本運作得很好。 讓我們新增另一項功能︰取消。

當您觸發 Found 事件時,若此文件是最後需要的文件,監聽器應該能夠停止進一步的處理。

事件處理程式不會傳回值,因此您需要以另一種方式進行通訊。 標準事件模式使用 EventArgs 物件包含事件訂閱者可用於通訊取消的欄位。

根據「取消」合約的語意,有兩種不同的模式可以使用。 在這兩種情況下,您會將布爾值字段新增至找到的檔案事件的 EventArguments。

一種模式可讓任何一位訂閱者取消作業。 針對此模式,新的欄位會初始化為 false。 任何訂閱者都可以將它變更為 true。 在為所有訂閱者引發事件之後,FileSearcher 元件會檢查布林值並採取相應行動。

如果所有的訂閱者都想要取消作業,第二個模式只會取消作業。 在此模式中,新的欄位會初始化以指出應該取消的作業,而任何訂閱者皆可將它變更為表示作業應該繼續進行。 在所有訂閱者處理引發的事件之後,FileSearcher 元件會檢查布爾值並採取動作。 此模式中有一個額外的步驟:元件必須知道是否有任何訂閱者回應事件。 如果沒有任何訂閱者,該欄位會錯誤地顯示為已取消。

我們要實作本例的第一個版本。 您需要將名為 CancelRequested 的布林值欄位加入到 FileFoundArgs 類型:

public class FileFoundArgs : EventArgs
{
    public string FoundFile { get; }
    public bool CancelRequested { get; set; }

    public FileFoundArgs(string fileName) => FoundFile = fileName;
}

這個新欄位會自動初始化為 false,因此您不會不小心取消。 元件的唯一其他變更是在觸發事件之後檢查旗標,以確認是否有任何訂閱者請求取消。

private void SearchDirectory(string directory, string searchPattern)
{
    foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
    {
        var args = new FileFoundArgs(file);
        FileFound?.Invoke(this, args);
        if (args.CancelRequested)
            break;
    }
}

此模式的優點之一是,它不是一項重大變更。 之前沒有任何訂閱者要求取消,而且到現在還是沒有。 除非訂閱者程式代碼想要支援新的取消通訊協定,否則沒有任何訂閱者程式代碼需要更新。

我們需要更新訂閱服務,使其在找到第一個可執行檔後,自動發出取消請求。

EventHandler<FileFoundArgs> onFileFound = (sender, eventArgs) =>
{
    Console.WriteLine(eventArgs.FoundFile);
    eventArgs.CancelRequested = true;
};

新增另一個事件宣告

我們要再新增一項功能,示範事件的其他語言慣例。 讓我們為 Search 方法添加一個重載,使其能遍歷所有子目錄以搜尋檔案。

這個方法在具有許多子目錄的目錄中可能會變成一個費時的操作。 讓我們來新增一個在每個新目錄搜尋開始時引發的事件。 此事件可讓訂閱者追蹤進度,並向使用者更新進度情況。 到目前為止所建立的所有範例都是公用的。 讓我們將此事件設為內部事件。 這表示您也可以將自變數類型設為內部。

首先,您可以建立新的 EventArgs 衍生類別來報告新的目錄和進度。

internal class SearchDirectoryArgs : EventArgs
{
    internal string CurrentSearchDirectory { get; }
    internal int TotalDirs { get; }
    internal int CompletedDirs { get; }

    internal SearchDirectoryArgs(string dir, int totalDirs, int completedDirs)
    {
        CurrentSearchDirectory = dir;
        TotalDirs = totalDirs;
        CompletedDirs = completedDirs;
    }
}

同樣地,您可以依照建議,為事件引數建立不可變的參考類型。

接下來定義事件。 這次,您會使用不同的語法。 除了使用字段語法之外,您還可以使用 add 和 remove 處理程式明確建立事件屬性。 在此範例中,您不需要這些處理程式的額外程式代碼,但這會示範如何建立它們。

internal event EventHandler<SearchDirectoryArgs> DirectoryChanged
{
    add { _directoryChanged += value; }
    remove { _directoryChanged -= value; }
}
private EventHandler<SearchDirectoryArgs>? _directoryChanged;

在許多方面,您在此處撰寫的程式代碼會鏡像編譯程式針對您稍早看到的欄位事件定義所產生的程式代碼。 您可以使用類似 屬性的語法來建立事件。 請注意,處理常式有不同的名稱︰add 和 remove。 系統會呼叫這些存取子,用來執行事件訂閱或取消訂閱的功能。 請注意,您也必須宣告私有備用欄位來儲存事件變數。 此變數會初始化為 null。

接下來,我們要新增 Search 方法的多載,此方法會周遊子目錄並引發這兩個事件。 最簡單的方式是使用預設自變數來指定您想要搜尋所有目錄:

public void Search(string directory, string searchPattern, bool searchSubDirs = false)
{
    if (searchSubDirs)
    {
        var allDirectories = Directory.GetDirectories(directory, "*.*", SearchOption.AllDirectories);
        var completedDirs = 0;
        var totalDirs = allDirectories.Length + 1;
        foreach (var dir in allDirectories)
        {
            _directoryChanged?.Invoke(this, new (dir, totalDirs, completedDirs++));
            // Search 'dir' and its subdirectories for files that match the search pattern:
            SearchDirectory(dir, searchPattern);
        }
        // Include the Current Directory:
        _directoryChanged?.Invoke(this, new (directory, totalDirs, completedDirs++));
        SearchDirectory(directory, searchPattern);
    }
    else
    {
        SearchDirectory(directory, searchPattern);
    }
}

private void SearchDirectory(string directory, string searchPattern)
{
    foreach (var file in Directory.EnumerateFiles(directory, searchPattern))
    {
        var args = new FileFoundArgs(file);
        FileFound?.Invoke(this, args);
        if (args.CancelRequested)
            break;
    }
}

此時,您可以執行呼叫 多載的應用程式來搜尋所有子目錄。 新的 DirectoryChanged 事件上沒有訂閱者,但使用 ?.Invoke() 方法可確保其正常運作。

我們要新增一個處理常式,在主控台視窗中寫入一行顯示進度的文字。

fileLister.DirectoryChanged += (sender, eventArgs) =>
{
    Console.Write($"Entering '{eventArgs.CurrentSearchDirectory}'.");
    Console.WriteLine($" {eventArgs.CompletedDirs} of {eventArgs.TotalDirs} completed...");
};

您已看到在整個 .NET 生態系統中遵循的模式。 藉由學習這些模式和慣例,您會快速撰寫慣用 C# 和 .NET。

另請參閱

接下來,您會在最新版的 .NET 中看到這些模式中的一些變更。