以簡單的方式實作 INotifyPropertyChanged

已完成

如果您已按部就班進行前面的課程,您可能會認為實作資料繫結負擔繁重。 當您可以直接使用 TimeTextBlock.Text = DateTime.Now.ToLongTime() 來顯示時間時,為何要大費周章實作 INotifyPropertyChanged、到處引發事件? 的確,在簡單的案例中,資料繫結確實看似誇張。

不過,資料繫結的能力可不僅止於此。 它可以在 UI 與程式碼間雙向傳輸資料、顯示項目清單,以及支援資料編輯。 所有這些都是透過可清楚將您邏輯所使用的資料與資料的呈現做分隔的架構完來成。

但我們如何減少開發人員所需撰寫的程式碼數量? 沒有人想要為每個需要宣告的屬性輸入十行程式碼。 幸運的是,我們可以擷取通用功能,並將屬性 setter 縮減為單行程式碼。 本課程將會告訴您作法。

目標

我們的目標是將實作 INotifyPropertyChanged 介面所需的所有連接都移至個別的類別,以將可在屬性變更時通知 UI 之屬性的建立流程簡化。 提醒您,以下是我們想要簡化的程式碼:

private bool _isNameNeeded = true;

public bool IsNameNeeded
{
    get { return _isNameNeeded; }
    set
    {
        if (value != _isNameNeeded)
        {
            _isNameNeeded = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsNameNeeded)));
        }
    }
}

自動屬性 (例如 public bool IsNameNeeded { get; set;}) 無法在這裡使用,因為我們必須在 setter 中做些事。 因此,關於支援欄位 (屬性宣告行),便沒有太多可做的。 透過使用現代化 C# 功能,我們可以將 getter 變更為 get => _isNameNeeded;,但這只能省下幾個按鍵輸入。 因此,我們必須將注意力集中在屬性 setter。 是否可以將其轉換成單一程式碼行?

ObservableObject 類別

我們可以建立新的基底類別:ObservableObject。 它之所以被稱為 Observable \(可觀察的\),是因為 UI 可以使用 INotifyPropertyChanged 介面來觀察它。 資料與邏輯會裝載於繼承自它的類別中,而 UI 也會繫結至這些繼承類別的執行個體。

1. 建立 ObservableObject 類別

讓我們建立名為 ObservableObject 的新類別。 在 [方案總管] 中,以滑鼠右鍵按一下 DatabindingSample 專案、選取 [新增] / [類別],然後輸入 ObservableObject 作為類別的名稱。 選取 [新增] 以建立類別。

1. 建立 ObservableObject 類別

讓我們建立名為 ObservableObject 的新類別。 在 [方案總管] 中,以滑鼠右鍵按一下 DatabindingSampleWPF 專案、選取 [新增 / 類別],然後輸入 ObservableObject 作為類別的名稱。 選取 [新增] 以建立類別。

Screenshot of Visual Studio showing the Add New Item dialog with a Visual C# class type selected.

2. 實作 INotifyPropertyChanged 介面

接下來,必須實作 INotifyPropertyChanged 介面,並使類別成為公用。 變更類別的簽章,使它看起來像這樣:

public class ObservableObject : INotifyPropertyChanged

Visual Studio 會指出數個有關 INotifyPropertyChanged 的問題。 它位於非參考的命名空間。 讓我們新增它,如此處所示。

using System.ComponentModel;

接下來,必須實作介面。 在類別主體內新增此行。

public event PropertyChangedEventHandler? PropertyChanged;

3. 新增 RaisePropertyChanged 方法

在先前的課程中,我們經常在程式碼中引發 PropertyChangedEvent,甚至是在屬性 setter 的外部。 儘管新式的 C# 和 Null 條件運算子 或 (?.) 可讓我們在單行中執行此動作,但我們仍能透過建立如下的便利函式來簡化它:

protected void RaisePropertyChanged(string? propertyName)
{
    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}

現在,在繼承自 ObservableObject 的類別中,我們只需執行下列內容就能引發 PropertyChanged 事件:

RaisePropertyChanged(nameof(MyProperty));

4. Set<T> 方法

但是,我們可以對會檢查值是否維持原樣,並在不是的情況下設定值,然後引發 PropertyChanged 事件的 setter 模式做些什麼? 在理想情況下,我們想將它轉換成單行程式碼,像這樣:

private bool _isNameNeeded = true;

public bool IsNameNeeded
{
    get { return _isNameNeeded; }
    set { Set(ref _isNameNeeded, value); }  // Just one line!
}

這已經是最簡單了。 我們會呼叫函式、將參考傳遞給屬性的支援欄位,並設定新值。 因此,這個 Set 方法看起來是什麼樣子?

protected bool Set<T>(
    ref T field,
    T newValue,
    [CallerMemberName] string? propertyName = null)
{
    if (EqualityComparer<T>.Default.Equals(field, newValue))
    {
        return false;
    }

    field = newValue;
    RaisePropertyChanged(propertyName);
    return true;
}

將上述程式碼複製到 ObservableObject 類別的主體。 針對 [CallerMemberName],您還必須在檔案頂端新增下列指令碼行:

using System.Runtime.CompilerServices;

此處提供許多進階的 C# 和編譯器 magic。 讓我們進一步了解。

Set<T> 是一個泛型方法,可協助編譯器確定支援欄位與值均為相同類型。 此方法的第三個參數 (propertyName) 會使用 [CallerMemberName] 屬性來裝飾。 如果我們在呼叫方法時未定義 propertyName,它將採用呼叫成員的名稱,並在編譯階段期間將它放在那裡。 因此,如果我們從 IsNameNeeded 方法的 setter 中呼叫 Set,編譯器就會放置字串常值 "IsNameNeeded" 作為第三個參數。 不需要對字串進行硬式編碼,或甚至使用 nameof()

接下來,Set 方法會叫用 EqualityComparer<T>.Default.Equals,來比較欄位的目前值和新值。 如果新舊值相等,Set 方法就會傳回 false。 如果不是,即會將支援欄位設定為新值,而且會先引發 PropertyChanged 事件,然後再傳回 true。 您可以使用 Set 方法的傳回值,以判斷值是否已變更。

透過實作的 ObservableObject 類別,讓我們看看如何在應用程式中使用它!

5. 建立 MainPageLogic 類別

稍早在此課程中,我們將所有資料和邏輯從 MainPage 類別移出,並移至繼承自 ObservableObject 的類別。

讓我們建立名為 MainPageLogic 的新類別。 在 [方案總管] 中,以滑鼠右鍵按一下 DatabindingSample 專案、選取 [新增] / [類別],然後輸入 MainPageLogic 作為類別的名稱。 選取 [新增] 以建立類別。

變更類別的簽章,使它成為公用並繼承自 ObservableObject

public class MainPageLogic : ObservableObject
{
}

6. 將時鐘功能移至 MainPageLogic 類別

時鐘功能的程式碼是由三個部分所組成:_timer 欄位、在建構函式中設定 DispatcherTimer,以及 CurrentTime 屬性。 以下是我們在第二個課程中所完成的程式碼:

private DispatcherTimer _timer;

public MainPage()
{
    this.InitializeComponent();
    _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };

    _timer.Tick += (sender, o) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CurrentTime)));

    _timer.Start();
}

public string CurrentTime => DateTime.Now.ToLongTimeString();

讓我們將所有必須使用 _timer 來執行的程式碼移至 MainPageLogic 類別。 建構函式中的程式碼行 (適用於 this.InitializeComponent() 呼叫的程式碼除外) 都應該被移至 MainPageLogic 的建構函式。 在上述程式碼中,只有建構函式中的 InitializeComponent 呼叫才應留在 MainPage 中。

public MainPage()
{
    this.InitializeComponent();
}

現在,我們只會處理程式碼的這一部分。 我們將儘快返回 MainPage 類別程式碼的其餘部分。

移動之後,MainPageLogic 類別看起來會像這樣:

public class MainPageLogic : ObservableObject
{
    private DispatcherTimer _timer;

    public MainPageLogic()
    {
        _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };

        _timer.Tick += (sender, o) =>
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CurrentTime)));

        _timer.Start();
    }

    public string CurrentTime => DateTime.Now.ToLongTimeString();
}

請記住,我們提供了一個便於引發 PropertyChanged 事件的功能。 讓我們在 _timer.Tick 處理常式中使用該功能。

_timer.Tick += (sender, o) => RaisePropertyChanged(nameof(CurrentTime));

7. 變更 XAML 以使用 MainPageLogic

如果您嘗試立即編譯專案,您會在MainPage.xaml 中收到錯誤,表示「在類型 'MainPage' 上找不到屬性 'CurrentTime'」。 確實,MainPage 類別已沒有 CurrentTime 屬性。 它已被移至 MainPageLogic 類別。 若要修正此問題,我們必須在 MainPage 類別中建立名為 Logic 的屬性。 其類型將會是 MainPageLogic,且我們將透過它進行所有繫結。

將下列內容新增至 MainPage 類別:

public MainPageLogic Logic { get; } = new MainPageLogic();

接下來,在 MainPage.xaml 中尋找顯示時鐘的 TextBlock

<TextBlock Text="{x:Bind CurrentTime, Mode=OneWay}"
           HorizontalAlignment="Right"
           Margin="10"/>

此外,透過將 Logic. 新增到其中來變更繫結。

<TextBlock Text="{x:Bind Logic.CurrentTime, Mode=OneWay}"
           HorizontalAlignment="Right"
           Margin="10"/>

現在應用程式會進行編譯,而且如果您執行該應用程式,時鐘應該會如預期般滴答運行。 很好!

8. 移動其餘的邏輯

讓我們加快進度。 將 MainPage 類別中程式碼的其餘部分移至 MainPageLogic。 只應留下 Logic 屬性、建構函式及 PropertyChanged 事件。

9. 簡化 IsNameNeeded

在 MainPageLogic.cs 中,使用對新 Set 方法的呼叫來取代 IsNameNeeded 屬性 setter。

public bool IsNameNeeded
{
    get { return _isNameNeeded; }
    set { Set(ref _isNameNeeded, value); }
}

10. 修正 OnSubmitClicked 方法

就邏輯層級而言,我們已不在乎按鈕點選事件的傳送端或事件引數。 重新考慮方法的名稱也是很好的做法。 我們已經不會再執行按鈕點選,而是會提交邏輯。 因此,讓我們將 OnSubmitClicked 方法重新命名為 Submit,使其成為公用,並移除參數。

在方法內部,有引發 PropertyChanged 事件的舊方法。 使用對 ObservableObject.RaisePropertyChanged 的呼叫來取代它。 最後,整個方法看起來應該像這樣:

public void Submit()
{
    if (string.IsNullOrEmpty(UserName))
    {
        return;
    }

    IsNameNeeded = false;
    RaisePropertyChanged(nameof(GetGreetingVisibility));
}

11. 變更 XAML 以參考 Logic

接下來,返回 MainPage.xaml 並變更其餘的繫結,以使其通過 Logic 屬性。 全部完成時,Grid 看起來應該像這樣:

<Grid>
    <TextBlock Text="{x:Bind Logic.CurrentTime, Mode=OneWay}"
               HorizontalAlignment="Right"
               Margin="10"/>

    <StackPanel HorizontalAlignment="Center"
                VerticalAlignment="Center"
                Orientation="Horizontal"
                Visibility="{x:Bind Logic.IsNameNeeded, Mode=OneWay}">
        <TextBlock Margin="10"
                   VerticalAlignment="Center"
                   Text="Enter your name: "/>
        <TextBox Name="tbUserName"
                 Margin="10"
                 Width="150"
                 VerticalAlignment="Center"
                 Text="{x:Bind Logic.UserName, Mode=TwoWay}"/>
        <Button Margin="10"
                VerticalAlignment="Center"
                Click="{x:Bind Logic.Submit}" >Submit</Button>
    </StackPanel>

    <TextBlock Text="{x:Bind sys:String.Format('Hello {0}!',  tbUserName.Text), Mode=OneWay}"
               Visibility="{x:Bind Logic.GetGreetingVisibility(), Mode=OneWay}"
               HorizontalAlignment="Left"
               VerticalAlignment="Top"
               Margin="10"/>
</Grid>

請注意,就算 Button.Click 事件可以繫結至 MainPageLogic 類別中的 Submit 方法。

如果您立即編譯專案,您仍會得到一則警告,指出未曾使用 MainPage.PropertyChanged

12. 整理 MainPage 類別

警告出現的原因,是因為我們不再需要 MainPage 類別上的 INotifyPropertyChanged 介面。 因此,讓我們從類別宣告中,將它連同 PropertyChanged 事件一起移除。

最後,整個 MainPage 類別看起來會像這樣:

public sealed partial class MainPage : Page
{
    public MainPageLogic Logic { get; } = new MainPageLogic();

    public MainPage()
    {
        this.InitializeComponent();
    }

}

這會盡可能保持整齊。

13. 執行應用程式

如果一切順利,此時您應該能夠執行應用程式,然後確認它是否與先前的運作方式完全相同。 恭喜!

摘要

那麼,透過所有這些工作,我們達到了什麼目的? 雖然應用程式運作方式與以前相同,但我們的架構變成可擴展、持久且可測試。

MainPage 類別現在非常簡單。 它包含對邏輯的參考,並且只會接收和轉送按鈕點選事件。 邏輯與 UI 間的所有資料流程都會透過資料繫結進行,既快速、穩固又經實證可行。

MainPageLogic 類別現在不受特定 UI 限制。 無論時鐘是顯示在 TextBlock 還是其他控制項中,都已經沒有差異。 表單提交能以數種方式發生。 這些方式包括按鈕點擊、按 Enter 鍵,或是能偵測微笑的臉部辨識演算法。 表單也可以透過以邏輯為目標並確保它會根據專案需求運作的自動單元測試來提交。

基於上述及其他原因,理想的做法通常是在頁面的程式碼後置中僅包含 UI 相關的功能,然後將邏輯另外放在不同的類別中。 更複雜的應用程式可能也具有動畫控制項,以及其他具體的 UI 相關功能。 當您處理更複雜的應用程式時,將會激賞我們在此課程中所建立 UI 與邏輯分隔的處理方式。

您可以在自己的專案中重複使用 ObservableObject 類別。 在經過一些練習之後,您將發現以此方式處理問題實際上會更快且更簡單。 您也可以利用能依循您在此課程模組中所學的準則,並以其作為建置基礎的現有已建立程式庫,例如 MVVM 工具組 \(部分機器翻譯\)。

5. 修改 Clock 類別以利用 ObservableObject

請變更 Clock 的簽章,讓它繼承自 ObservableObject 而不是 INotifyPropertyChanged

public class Clock : ObservableObject

現在,我們在 Clock 類別及其基底類別中皆已定義 PropertyChanged 事件,這導致產生編譯器警告。 請從 Clock 類別中刪除 PropertyChanged 事件。

為了引發 PropertyChanged 事件,我們已在 ObservableObject 類別中建立一個便利的函式。 若要使用它,請將 _timer.Tick 行取代為下列項目:

_timer.Tick += (sender, o) => RaisePropertyChanged(nameof(CurrentTime));

Clock 類別已經變得較簡單。 但讓我們看看可以使用更複雜的 MainWindowDataContext 類別來做些什麼。

6. 修改 MainWindowDataContext 類別以利用 ObservableObject

就像使用 Clock 類別一樣,我們會先從變更類別宣告以使其繼承自 ObservableObject 開始。

public class MainWindowDataContext : ObservableObject

在這裡,請務必一併刪除 PropertyChanged 事件。

請看看 IsNameNeeded 屬性的 setter。 它現在看起來像這樣:

set
{
    if (value != _isNameNeeded)
    {
        _isNameNeeded = value;
        PropertyChanged?.Invoke(
            this, new PropertyChangedEventArgs(nameof(IsNameNeeded)));
        PropertyChanged?.Invoke(
            this, new PropertyChangedEventArgs(nameof(GreetingVisibility)));
    }
}

這是標準的 INotifyPropertyChanged 模式,其也會在新的 IsNameNeeded 屬性值不同時額外引動 PropertyChanged 事件。

這就是建立 ObservableObject.Set 函式時所要針對的情況。 Set 函式甚至會傳回指出屬性的舊值與新值是否不同的 bool 值。 因此,上述屬性 setter 可以簡化成像這樣:

if (Set(ref _isNameNeeded, value))
{
    RaisePropertyChanged(nameof(GreetingVisibility));
}

真不錯!

7. 執行應用程式

如果一切順利,此時您應該能夠執行應用程式,然後確認它是否與先前的運作方式完全相同。 恭喜!

摘要

那麼,透過所有這些工作,我們達到了什麼目的? 雖然應用程式運作方式與以前相同,但我們的架構變成可擴展、持久且可測試。

MainWindow 類別非常簡單。 它包含對邏輯的參考,並且只會接收和轉送按鈕點選事件。 邏輯與 UI 間的所有資料流程都會透過資料繫結進行,既快速、穩固又經實證可行。

MainWindowDataContext 類別現在不受特定 UI 限制。 無論時鐘是顯示在 TextBlock 還是其他控制項中,都已經沒有差異。 表單提交能以數種方式發生。 這些方式包括按鈕點擊、按 Enter 鍵,或是能偵測微笑的臉部辨識演算法。 表單也可以透過以邏輯為目標並確保它會根據專案需求運作的自動單元測試來提交。

基於上述及其他原因,理想的做法通常是在視窗的程式碼後置中僅包含 UI 相關的功能,然後將邏輯另外放在不同的類別中。 更複雜的應用程式可能也具有動畫控制項,以及其他具體的 UI 相關功能。 當您處理更複雜的應用程式時,將會激賞我們在此課程中所建立 UI 與邏輯分隔的處理方式。

您可以在自己的專案中重複使用 ObservableObject 類別。 在經過一些練習之後,您將發現以此方式處理問題實際上會更快且更簡單。 您也可以利用能依循您在此課程模組中所學的準則,並以其作為建置基礎的現有已建立程式庫,例如 MVVM 工具組 \(部分機器翻譯\)。