2022年7月31日 星期日

MAUI : 資料綁定 Data Binding - 3 PropertyChanged.Fody 套件,大幅簡化屬性變更通知程式設計碼

資料綁定 Data Binding - 3 PropertyChanged.Fody 套件,大幅簡化屬性變更通知程式設計碼

在上一篇 資料綁定 Data Binding - 2 設計基底類別,透過繼承可以使用屬性變更通知 文章中,設計了一個抽象類別 BindableBase,並且把相關 INotifyPropertyChanged 介面要實作的方法都設計在這個新類別上,而對於要能夠具有屬性變更通知的類別,僅僅需要繼承這個類別即可使用這裡設計的相關程式碼,如此,大幅降低了相同程式碼重複設計的結果,也提升了整體專案的可維護特性。

在這篇文章中將會使用另外一個做法,可以把整個專案程式碼變得更加精簡,且具有相同的效果,那就是使用 Fody/PropertyChanged 套件,因為這個套件將會提供自動注入相關 PropertyChanged 事件會用到的程式碼,並且適時觸發這個屬性異動的通知事件到屬性內的 set 存取子內,唯一要做的就是這個類別要實作 INotifyPropertyChanged 介面即可,其他的程式碼將會由 PropertyChanged.Fody 幫你來完成。

系列文章清單

1 自行建置 INotifyPropertyChanged 介面

2 設計基底類別,透過繼承可以使用屬性變更通知

3 PropertyChanged.Fody 套件,大幅簡化屬性變更通知程式設計碼

4 CommunityToolkit.Mvvm 套件,透過原始碼產生來簡化屬性變更通知程式設計碼

5 在 Maui 專案內,如何得知 ViewModel 內的屬性產生異動,而 View 可以收到通知呢?

建立新專案

  • 開啟 Visual Studio 2022 開發工具
  • 當 [Visual Studio 2022] 對話窗出現的時候
  • 點選右下角的 [建立新的專案] 按鈕選項
  • 現在將看到 [建立新專案] 對話窗
  • 請選擇 [主控台應用程式] 這個專案範本
  • 點選右下角的 [下一步] 按鈕
  • 此時將會看到 [設定新的專案] 對話窗
  • 在 [專案名稱] 欄位,輸入 csPropertyChangedFody
  • 點選右下角的 [下一步] 按鈕
  • 最後會看到 [其他資訊] 對話窗
  • 請勾選 [不要使用最上層語句] 這個文字檢查盒控制項
  • 點選右下角的 [建立] 按鈕

加入 PropertyChanged.Fody 的 NuGet 套件

  • 滑鼠右擊該專案的 [相依性] 節點
  • 從彈出功能表中選擇 [管理 NuGet 套件] 功能選項
  • 此時,[NuGet: csPropertyChangedFody] 視窗將會出現
  • 點選 [瀏覽] 標籤頁次
  • 在左上方的搜尋文字輸入盒內輸入 PropertyChanged.Fody 關鍵字
  • 現在,將會看到 PropertyChanged.Fody 套件出現在清單內
  • 點選這個 PropertyChanged.Fody 套件,並且點選右上方的 [安裝] 按鈕,安裝這個套件到這個專案內。

使用 PropertyChanged.Fody 提供功能來設計具有屬性變更通知的 Person 類別

底下的範例程式碼,都將會建立在 [Program.cs] 這個檔案內

首先要建立一個 Person 類別,其中將會有兩個屬性,分別為 姓名 Name 與 年紀 Age

完成後的程式碼如下:

public class Person : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    public string Name { get; set; }
    public int Age { get; set; }
}

當完成 Person 類別設計之後,所有的程式設計師將會眼睛一亮,發現到的第一個特色就是,程式碼變得那麼的簡潔與清爽,在這裡僅在類別宣告的後面,加入了宣告要實作 INotifyPropertyChanged 介面,因為加入了要實作 INotifyPropertyChanged 這個介面,因此要在該類別內加入 public event PropertyChangedEventHandler? PropertyChanged; 這行敘述,這樣個過程一切都沒有問題。

現在要來看看這個類別中的兩個屬性:姓名與年紀,在這裡將會使用 自動實作的屬性 方式來進行設計屬性程式碼,而不再需要使用 含有支援欄位的屬性 來進行設計屬性程式碼,這樣的設計方式頓時讓整個程式碼明亮了許多,可是,這裡產生了一個問題,那就是當屬性值有異動的時候,該如何觸發屬性異動的通知事件呢?

這一切的疑問都在於 [PropertyChanged.Fody] 這個套件來幫忙處理了,因為,會用到屬性異動通知的程式碼, PropertyChanged.Fody 套件會在建置期間,自動注入到你的專案內。

要明瞭 PropertyChanged.Fody 在背後做了甚麼事情,可以透過市面上任何一套 .NET 反組譯工具,來觀察這個專案建置工具,來觀察這個專案建置後的組件 Assembly 檔案,底下將會是使用 ILSpy 這套工具所觀察到最終的 Person 類別在組件內的程式碼。

// csPropertyChangedFody.Person
using System;
using System.CodeDom.Compiler;
using System.ComponentModel;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using csPropertyChangedFody;

public class Person : INotifyPropertyChanged
{
	public string Name
	{
		[CompilerGenerated]
		get
		{
			return <Name>k__BackingField;
		}
		[CompilerGenerated]
		set
		{
			if (!string.Equals(<Name>k__BackingField, value, StringComparison.Ordinal))
			{
				<Name>k__BackingField = value;
				<>OnPropertyChanged(<>PropertyChangedEventArgs.Name);
			}
		}
	}

	public int Age
	{
		[CompilerGenerated]
		get
		{
			return <Age>k__BackingField;
		}
		[CompilerGenerated]
		set
		{
			if (<Age>k__BackingField != value)
			{
				<Age>k__BackingField = value;
				<>OnPropertyChanged(<>PropertyChangedEventArgs.Age);
			}
		}
	}

	public event PropertyChangedEventHandler? PropertyChanged;

	[GeneratedCode("PropertyChanged.Fody", "3.4.1.0")]
	[DebuggerNonUserCode]
	protected void <>OnPropertyChanged(PropertyChangedEventArgs eventArgs)
	{
		this.PropertyChanged?.Invoke(this, eventArgs);
	}
}

首先,將會看到 [PropertyChanged.Fody] 這個套件自動在這個類別內產生了 <>OnPropertyChanged 方法,這個方法將會接收到 PropertyChangedEventArgs 參數,並且透過這個參數來執行 this.PropertyChanged?.Invoke(this, eventArgs); 程式碼,觸發屬性變動的通知事件。看到這裡不會有看不懂得地方,因為這些程式碼在此之前,都是我們自己要寫出來的。

現在來觀察這個類別內的屬性,在此僅觀察 姓名 Name 這個屬性的最終程式碼

雖然在原始碼階段使用了 自動實作的屬性 方式來進行設計屬性程式碼,可是,編譯器還是會將這個自動實作屬性展開為 含有支援欄位的屬性 實作,畢竟,在一個類別內,真正的狀態,還是要透過類別內的欄位來儲存呀,屬性終究是兩個方法的組成,其他法有地方可以來儲存狀態值。

在此特別的查看姓名屬性的 set 存取子,首先,將會使用 String.Equals 方法 來檢查現在欄位值與要變動的屬性值是否相同,若是不相同,則會把新的屬性值設定給該姓名的欄位內,接著呼叫 <>OnPropertyChanged(<>PropertyChangedEventArgs.Name); 方法,觸發屬性變動的通知事件,這裡是不是很神奇呀,這一切的程式碼都會自動產生,並且注入內這個類別內。

確認採用 PropertyChanged.Fody 套件的設計是否可正常運作

在這裡將會採用同樣的測試程式碼,如下所示

internal class Program
{
    static void Main(string[] args)
    {
        Person person = new Person();
        person.PropertyChanged += (s, e) =>
        {
            WriteLine($"屬性 {e.PropertyName} 已經變更");
        };

        WriteLine("準備要修改 Name 屬性值");
        person.Name = "Vulcan Lee";

        WriteLine("Press any key for continuing...");
        ReadKey();

        WriteLine("準備要修改 Age 屬性值");
        person.Age = 25;

        WriteLine("Press any key for continuing...");
        ReadKey();
    }
}

在這裡先建立一個型別為 [Person] 的物件,指派給 person 變數內

接著,因為每個 [Person] 物件,都有個公開的 [PropertyChanged] 事件,因此,在此將需要訂閱這個事件,在此使用 Lambda 運算式 設計一個匿名委派方法來綁定這個事件,如此,當有屬性變更的事件產生的時候,將會觸發這裡綁定的 Lambda 委派方法,也就是會在螢幕上顯示出哪個屬性值已經變更了。

完成的變更屬性的事件訂閱與綁定設計,接下來就是要開始針對這個 person 物件的兩個屬性值進行變動,看看是否會有屬性變動通知事件產生,底下是執行後的結果內容。

這裡將會是整個完整的測試程式碼

internal class Program
{
    static void Main(string[] args)
    {
        Person person = new Person();
        person.PropertyChanged += (s, e) =>
        {
            WriteLine($"屬性 {e.PropertyName} 已經變更");
        };

        WriteLine("準備要修改 Name 屬性值");
        person.Name = "Vulcan Lee";

        WriteLine("Press any key for continuing...");
        ReadKey();

        WriteLine("準備要修改 Age 屬性值");
        person.Age = 25;

        WriteLine("Press any key for continuing...");
        ReadKey();
    }
}
public class Person : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    #region 針對屬性的成員,都都採用自動屬性方式來宣告即可
    #region 姓名
    public string Name { get; set; }
    #endregion

    #region 年紀
    public int Age { get; set; }
    #endregion
    #endregion
}

internal class Program
{
    static void Main(string[] args)
    {
        Person person = new Person();
        person.PropertyChanged += (s, e) =>
        {
            WriteLine($"屬性 {e.PropertyName} 已經變更");
        };

        WriteLine("準備要修改 Name 屬性值");
        person.Name = "Vulcan Lee";

        WriteLine("Press any key for continuing...");
        ReadKey();

        WriteLine("準備要修改 Age 屬性值");
        person.Age = 25;

        WriteLine("Press any key for continuing...");
        ReadKey();
    }
}








2022年7月30日 星期六

MAUI : 資料綁定 Data Binding - 2 設計基底類別,透過繼承可以使用屬性變更通知

資料綁定 Data Binding - 2 設計基底類別,透過繼承可以使用屬性變更通知

在上一篇 資料綁定 Data Binding - 1 自行建置 INotifyPropertyChanged 介面 文章中,設計了一個類別,為了要能夠知道這個類別所建立的執行個體內的哪個屬性有變更異動發生了,因此,可以觸發一個已經事先訂閱的事件,而這個事件處理常式將會被執行,並且得到究竟是哪個屬性發生了變更行為。

若是採用這樣的設計方式,將會產生一個不好的結果發生,那就是每次要設計一個新的類別,並且該類別想要具有 PropertyChanged 屬性變更通知效果的時候,那就只好在這些新設計的類別上,都要實作出 [INotifyPropertyChanged] 介面,而且,對於該類別內的屬性,都需要使用 完整屬性用法 含有支援欄位的屬性 來進行設計屬性程式碼,而不能夠使用 自動實作的屬性 方式來進行設計屬性程式碼。

從上述的描述可以看出,這樣將會大量使用到 剪貼 技能來進行程式碼的設計,這樣的設計將會是得未來程式碼變得不好維護,因為,在這裡可以選擇使用物件導向程式設計技能,將這些共通的程式碼,設計到一個基底類別 Base Class 內,下次要設計一個新的類別,並且想要提供屬性變更通知這樣機制的時候,便可以繼承這個基底類別即可。

系列文章清單

1 自行建置 INotifyPropertyChanged 介面

2 設計基底類別,透過繼承可以使用屬性變更通知

3 PropertyChanged.Fody 套件,大幅簡化屬性變更通知程式設計碼

4 CommunityToolkit.Mvvm 套件,透過原始碼產生來簡化屬性變更通知程式設計碼

5 在 Maui 專案內,如何得知 ViewModel 內的屬性產生異動,而 View 可以收到通知呢?

建立新專案

  • 開啟 Visual Studio 2022 開發工具
  • 當 [Visual Studio 2022] 對話窗出現的時候
  • 點選右下角的 [建立新的專案] 按鈕選項
  • 現在將看到 [建立新專案] 對話窗
  • 請選擇 [主控台應用程式] 這個專案範本
  • 點選右下角的 [下一步] 按鈕
  • 此時將會看到 [設定新的專案] 對話窗
  • 在 [專案名稱] 欄位,輸入 csBindableBase
  • 點選右下角的 [下一步] 按鈕
  • 最後會看到 [其他資訊] 對話窗
  • 請勾選 [不要使用最上層語句] 這個文字檢查盒控制項
  • 點選右下角的 [建立] 按鈕

設計 BindableBase 基底類別 - 提供屬性綁定需要的相關功能

首先, 先來設計這個基底類別,為了簡化整體解說與操作過程,底下的程式碼將都會設計到 [Program.cs] 檔案內

  • 找到 [namespace csBindableBase] 宣告
  • 請將底下程式碼設計到 [namespace csBindableBase] 宣告之後
public abstract class BindableBase : INotifyPropertyChanged
{
    #region 實作出 INotifyPropertyChanged 的程式碼
    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged(PropertyChangedEventArgs args)
    {
        PropertyChanged?.Invoke(this, args);
    }
    #endregion

    protected virtual bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
    {
        // 先比對新舊的物件值是否相同,若不同,才會觸發 屬性變更 的通知事件
        if (EqualityComparer<T>.Default.Equals(storage, value)) return false;

        // 將此次設定的物件值,指派給該欄位
        storage = value;
        // 觸發 屬性變更 的通知事件
        RaisePropertyChanged(propertyName);

        return true;
    }

    protected void RaisePropertyChanged([CallerMemberName] string propertyName = null)
    {
        OnPropertyChanged(new PropertyChangedEventArgs(propertyName));
    }
}

現在,已經在這個命名空間內建立一個抽象類別,其名稱為 BindableBase ,該類別需要實作 INotifyPropertyChanged

因為有要實作出 INotifyPropertyChanged ,所以需要設計 public event PropertyChangedEventHandler PropertyChanged; 事件成員的宣告,接著,也設計出一個 [OnPropertyChanged] 方法,可以傳入一個 PropertyChangedEventArgs 參數,這將會提供 PropertyChanged 事件的所需要用到的資料。

接下來要簡化與強化 含有支援欄位的屬性 設計上的需求,例如,在前一篇 資料綁定 Data Binding - 1 自行建置 INotifyPropertyChanged 介面 文章中,若設定該屬性的物件值與現行該物件中的欄位值相同的時候,同樣的會觸發屬性變動的事件通知,這樣的設計其實不是很好。

因此,在這個 BindableBase 類別中,將會設計一個 SetProperty<T> 泛型方法 來處理這樣的需求。在這個方法內,將會使用 參考 ref 方式來傳入一個該物件的欄位,接著,傳入這次要變更的新設定值,透過了 EqualityComparer<T>.Default.Equals(storage, value) 表示式 ( 這裡有 EqualityComparer 類別 更多說明 ) ,來確認與比較這兩個值是否相同,若是相同則不會接下來的觸發屬性變更通知的行為。

若新舊值不相同,則會透過 RaisePropertyChanged(propertyName); 方法來觸發屬性變動的事件通知,而這裡的 propertyName 將會是該方法傳入的一個參數,代表此次要變動的屬性名稱,而在這個 propertyName 參數前面,使用了 CallerMemberName , 這是一個 C# 屬性 [Attribute] ,其目的是可讓您取得方法呼叫端的方法或屬性名稱,這個屬性是在 C# 6.0 推出的新功能,相當的好用,可以簡化過多不必要的程式碼,減少寫錯的機會,提升整體專案可維護性;因此,透過了 CallerMemberName 功能,無縫取得當時的屬性名稱字串,便可以將這個屬性字串名稱送到屬性變更的觸發事件引數內。

在 RaisePropertyChanged 方法內,將會建立 PropertyChange 事件會用到的 PropertyChangedEventArgs 參數,該型別的參數需要一個字串,這裡的字串將會表示現在正在變動的屬性名稱,如此,便可以透過 [OnPropertyChanged] 方法來執行 PropertyChanged?.Invoke(this, args); 敘述,拋出該屬性異動的事件通知訊息了。

重新設計 Person 類別

接下來將會要把前一篇 資料綁定 Data Binding - 1 自行建置 INotifyPropertyChanged 介面 文章,所設計的 Person 類別重新設計一遍,底下將會是設計完成後的程式碼。

public class Person : BindableBase
{
    #region 針對每個具有 PropertyChanged 的屬性,都需要有底下的程式碼設計方式

    #region 姓名
    private string name;

    public string Name
    {
        get { return name; }
        set { SetProperty(ref name, value); }
    }
    #endregion

    #region 年紀
    private int age;

    public int Age
    {
        get { return age; }
        set { SetProperty(ref age, value); }
    }

    #endregion
    #endregion
}

首先,在 Person 類別將不再直接實作 [INotifyPropertyChanged] 這個介面,而是修該成為繼承剛剛設計好的 [BindableBase] 抽象類別,在這個抽象類別內,將會已經有實作出 [INotifyPropertyChanged] 這個介面。

在這個類別內的兩個屬性,姓名與年紀,同樣的還是需要使用 含有支援欄位的屬性 方式來進行屬性成員的設計,也就是說,要先設計一個 欄位 field 成員,接著,使用 get 存取子 與 set 存取子 來設計這個屬性的讀寫動作。

在讀取這個屬性的時候,對於 [get 存取子] 程式碼沒有變動,還是直接回傳指定欄位的值,不過,對於 [set 存取子] 而言,將會修該成為呼叫該方法 SetProperty(ref name, value); ;從這個敘述可以看出,在這裡呼叫了 SetProperty 這個泛型型別方法,並且傳入的姓名欄欄位的參考,如此,在這個 SetProperty 方法內,便可以直接地變更、修正這裡傳入的 name 變數值,第二個引數將會是現在這個屬性所持有的值,第三個引數在這裡沒有寫出來,可是,在 SetProperty 的函式簽章中,可以看出第三個參數的型別定義為 [CallerMemberName] string propertyName = null ,因此,第三個參數將會得到這個 姓名 屬性的名稱,在這個例子中,將會是 Name 這個字串

這樣設計出來的 Person 類別程式碼,是否已經清爽許多,而且不再存在弱型別的問題,因為,若是有弱型別而產生的問題,將會導致於在執行階段產生詭異現象或者得到不正確的執行結果,甚至有可能得到例外異常而導致該應用程式崩潰。

確認採用 BindableBase 抽象類別的設計是否可正常運作

在這裡將會採用同樣的測試程式碼,如下所示

internal class Program
{
    static void Main(string[] args)
    {
        Person person = new Person();
        person.PropertyChanged += (s, e) =>
        {
            WriteLine($"屬性 {e.PropertyName} 已經變更");
        };

        WriteLine("準備要修改 Name 屬性值");
        person.Name = "Vulcan Lee";

        WriteLine("Press any key for continuing...");
        ReadKey();

        WriteLine("準備要修改 Age 屬性值");
        person.Age = 25;

        WriteLine("Press any key for continuing...");
        ReadKey();
    }
}

在這裡先建立一個型別為 [Person] 的物件,指派給 person 變數內

接著,因為每個 [Person] 物件,都有個公開的 [PropertyChanged] 事件,因此,在此將需要訂閱這個事件,在此使用 Lambda 運算式 設計一個匿名委派方法來綁定這個事件,如此,當有屬性變更的事件產生的時候,將會觸發這裡綁定的 Lambda 委派方法,也就是會在螢幕上顯示出哪個屬性值已經變更了。

完成的變更屬性的事件訂閱與綁定設計,接下來就是要開始針對這個 person 物件的兩個屬性值進行變動,看看是否會有屬性變動通知事件產生,底下是執行後的結果內容。

這裡將會是整個完整的測試程式碼

public abstract class BindableBase : INotifyPropertyChanged
{
    #region 實作出 INotifyPropertyChanged 的程式碼
    public event PropertyChangedEventHandler PropertyChanged;

    protected virtual void OnPropertyChanged(PropertyChangedEventArgs args)
    {
        PropertyChanged?.Invoke(this, args);
    }
    #endregion

    protected virtual bool SetProperty<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
    {
        // 先比對新舊的物件值是否相同,若不同,才會觸發 屬性變更 的通知事件
        if (EqualityComparer<T>.Default.Equals(storage, value)) return false;

        // 將此次設定的物件值,指派給該欄位
        storage = value;
        // 觸發 屬性變更 的通知事件
        RaisePropertyChanged(propertyName);

        return true;
    }

    protected void RaisePropertyChanged([CallerMemberName] string propertyName = null)
    {
        OnPropertyChanged(new PropertyChangedEventArgs(propertyName));
    }
}

public class Person : BindableBase
{
    #region 針對每個具有 PropertyChanged 的屬性,都需要有底下的程式碼設計方式

    #region 姓名
    private string name;

    public string Name
    {
        get { return name; }
        set { SetProperty(ref name, value); }
    }
    #endregion

    #region 年紀
    private int age;

    public int Age
    {
        get { return age; }
        set { SetProperty(ref age, value); }
    }

    #endregion
    #endregion
}

internal class Program
{
    static void Main(string[] args)
    {
        Person person = new Person();
        person.PropertyChanged += (s, e) =>
        {
            WriteLine($"屬性 {e.PropertyName} 已經變更");
        };

        WriteLine("準備要修改 Name 屬性值");
        person.Name = "Vulcan Lee";

        WriteLine("Press any key for continuing...");
        ReadKey();

        WriteLine("準備要修改 Age 屬性值");
        person.Age = 25;

        WriteLine("Press any key for continuing...");
        ReadKey();
    }
}