【.NET 深呼吸】全程式碼編寫WPF程式

2023-06-15 21:01:12

學習 Code 總有這樣一個過程:入門時候比較依賴設計器、標示語言等輔助工具;等到玩熟練了就會發現純程式碼寫 UI 其實更高效。而且,純程式碼編寫也是最靈活的。Windows Forms 專案是肯定可以全程式碼編寫的,哪怕你使用了設計器,它最後也是生成程式碼檔案;而 WPF 就值得探索一下了。咱們知道,WPF 使用 XAML 標記來構建 UI 部分。由於 XAML 擴充套件了許多功能,用起來自然比 HTML 舒服。但是,老周向來不喜歡標示語言,這也是我向來不喜歡搞前端的原因。儘管某些前端框架模仿 WPF 也搞出資料繫結、MVVM、資料模板之類的名堂,也很難說用得特舒服。

有很多中小型專案都會把 Web 前端部分外包出去,尤其是給私人做——比如一兩個人或兩三個人做,也不外給其他公司做。有些人總以為前端很火(這裡頭媒體造勢的功勞不小),可往深層一挖,那可不一定了。Web 其實只做了一套 UI 罷了,後端許多是通用模型,既可以和 Web 前端對接,也可以和桌面前端對接,B/S、C/S 通殺的專案也不少。

很多行業軟體,如工業醫療,甚至財務、進銷存等,還是用成熟的技術好,尤其是桌面技術體驗更佳。當然有些行業軟體也有 Web UI,純輔助,一般就是看看報表看看大圖查查訂單而已。生產力悠關的東西,你還得相信桌面的魅力,娛樂相關的就隨便。當然也有用 Web 技術開發桌面UI的框架,這些東西能用但效果不算好,尤其是效能。老周這裡說的效能只是要求較寬的效能,而不是苛刻要求下的效能。啥意思呢,就是說用 Web 技術做桌面程式,存在效能問題不需要專用工具測,肉眼就能感覺到嚴重的效能問題了——吃記憶體特大,佔CPU有億點高(雖說佔得不算恐怖)。這裡所說的效能問題要排除 VS Code,因為這貨是個奇葩,效能表現挺好。

許多人容易被表面現象迷惑,比如認為招聘資訊多的就以為很吃香。那可不一定,有些技術,招聘少並不代表用的人少。老在招聘的頂多說明這些崗位流動性大,這個公司的員工熱愛跳槽罷了。近年來 Python 被「利益攜帶體」們炒得可熱了,甚至一些新手以為 Python 是剛出來的新語言。你想多了,就算沒有 C 語言早(1972),那也是 80 年代末的東東了。我在學 Python 的時候,估計某些小菜鳥還沒出生呢。不要聽那些培訓班胡說八道,它們的目的是你的錢包,而不是你的碼農生涯,它們說話從來不需要負責的。如果你除了 Python 什麼都不會的話,那除了會寫點「腳毛」外,你什麼也幹不成。不管你想玩人工智障、視覺神經還是別的東西,你得掌握C語言,特別是想搞更底層的。只會 Py 沒準連工作都難找,更別說年薪 500W 越南幣了。

在你不知道的領域,你可曾想象,VB6、易語言、Delphi、MFC 還有不少人在用呢。告訴你個祕密,學好組合可能更吃香,以後會這個的人更少了。信不信由你。當然,積極學習新東西是沒錯的,這也老週一向主張。不過,你同時得清楚,許多技術之間並不存在相互替代的關係,只不過是你做什麼樣的程式,就用什麼樣的技術罷了。比如這桌面程式,你不用糾結,很簡單:考慮跨平臺的,首選 Qt;僅考慮 Windows 的,那多了去,隨便,當然,微軟自家自然是最合適的。

可是,一些腦子太靈活的人又糾結了,我選了 Qt,那我用 Widgets 做還是用 QML 做?我選了.NET,那我用 Windows Forms 還是 WPF?還是 MAUI ?對於這種問題,老周送你一句:「像你們這種人是沒法改變的,只有滾出碼農界」。

好了,上面扯了幾段「廢腑」之話,迴歸正題,咱們討論 WPF,老周這裡說的是完全用程式碼寫,指的是一行 XAML 都沒有。當然,大夥伴們肯定說那沒問題的。構建常規介面絕對行得通,但遇到像資料模板、控制元件模板、資源字典這些,就得費一點點程式碼。雖然網上能找到幾位同道中人寫的小作文,但要麼版本太舊,要麼過於粗糙。於是老周逮住了這個機會,可以瞎扯蛋一回了。

前文多次強調,咱們就純程式碼寫 WPF 的,無一行 XAML。所以,預設的 WPF 專案模板咱們就不用了。咱們用控制檯應用的模板就行了。來,動手練習一下。

首先,建立一個控制檯專案。

dotnet new console -n MyApp -o .

dotnet new 命令知道乎?嗯,用來建立專案的,然後是專案模板的名稱,console 表示控制檯應用程式。模板名字我記不住喲。記它幹嗎,執行一下下面這一句就能看各種模板了:

dotnet new list

這裡你可別理解歪了,它不是說用名叫 list 的模板建立專案啊,list 是列出可用的專案模板。然後,你能得到這個表:

模板名                                   短名稱               語言        標記
---------------------------------------  -------------------  ----------  --------------------------------
ASP.NET Core gRPC 服務                   grpc                 [C#]        Web/gRPC
ASP.NET Core Web API                     webapi               [C#],F#     Web/WebAPI
ASP.NET Core Web 應用                    webapp,razor         [C#]        Web/MVC/Razor Pages
ASP.NET Core Web 應用(模型-檢視-控制器)  mvc                  [C#],F#     Web/MVC
ASP.NET Core 與 Angular                  angular              [C#]        Web/MVC/SPA
ASP.NET Core 與 React.js                 react                [C#]        Web/MVC/SPA
ASP.NET Core 空                          web                  [C#],F#     Web/Empty
Blazor Server 應用                       blazorserver         [C#]        Web/Blazor
Blazor Server 應用空                     blazorserver-empty   [C#]        Web/Blazor/Empty
Blazor WebAssembly 應用                  blazorwasm           [C#]        Web/Blazor/WebAssembly/PWA
Blazor WebAssembly 應用空                blazorwasm-empty     [C#]        Web/Blazor/WebAssembly/PWA/Empty
dotnet gitignore 檔案                    gitignore                        Config
Dotnet 本地工具清單檔案                  tool-manifest                    Config
EditorConfig 檔案                        editorconfig                     Config
global.json file                         globaljson                       Config
MSBuild Directory.Build.props 檔案       buildprops                       MSBuild/props
MSBuild Directory.Build.targets 檔案     buildtargets                     MSBuild/props
MSTest Test Project                      mstest               [C#],F#,VB  Test/MSTest
MVC ViewImports                          viewimports          [C#]        Web/ASP.NET
MVC ViewStart                            viewstart            [C#]        Web/ASP.NET
NuGet 設定                               nugetconfig                      Config
NUnit 3 Test Item                        nunit-test           [C#],F#,VB  Test/NUnit
NUnit 3 Test Project                     nunit                [C#],F#,VB  Test/NUnit
Razor 類庫                               razorclasslib        [C#]        Web/Razor/Library
Razor 元件                               razorcomponent       [C#]        Web/ASP.NET
Razor 頁面                               page                 [C#]        Web/ASP.NET
Web 設定                                 webconfig                        Config
Windows 表單應用                         winforms             [C#],VB     Common/WinForms
Windows 表單控制元件庫                       winformscontrollib   [C#],VB     Common/WinForms
Windows 表單類庫                         winformslib          [C#],VB     Common/WinForms
WPF 應用程式                             wpf                  [C#],VB     Common/WPF
WPF 使用者控制元件庫                           wpfusercontrollib    [C#],VB     Common/WPF
WPF 類庫                                 wpflib               [C#],VB     Common/WPF
WPF 自定義控制元件庫                         wpfcustomcontrollib  [C#],VB     Common/WPF
xUnit Test Project                       xunit                [C#],F#,VB  Test/xUnit
協定緩衝區檔案                           proto                            Web/gRPC
介面                                     interface            [C#],VB     Common
控制檯應用                               console              [C#],F#,VB  Common/Console
列舉                                     enum                 [C#],VB     Common
類                                       class                [C#],VB     Common
類庫                                     classlib             [C#],F#,VB  Common/Library
結構                                     struct,structure     [C#],VB     Common
解決方案檔案                             sln,solution                     Solution
記錄                                     record               [C#]        Common
輔助角色服務                             worker               [C#],F#     Common/Worker/Web

咱們平常用得多的都是前幾那幾個,比如 mvc、web、wpf、classlib 等。我們在命令中參照的就是專案模板的短名稱即可。比如控制檯就是 console。

-n 引數指定專案的名稱,我這裡是「MyApp」,-o 引數指定專案存放目錄,「.」 表示當前目錄。

接下來要改一下專案檔案(*.csproj)。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net7.0-windows</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <UseWPF>true</UseWPF>
  </PropertyGroup>

</Project>

1、新增 MSBuild 屬性 UseWPF,且設定為 true。有了這個你才能在專案中參照 WPF 有關的程式集。同理,如果要使用 Windows Forms,就將 UseWindowsForms 屬性設定為 true。

2、TargetFramework 要在.NET版本後加上「-windows」,表示這是 Windows 平臺特定的,Linux 上不可用的。類似的如 net7.0-android 等。

至於 OutputType 屬性要不要改為 WinExe,.NET 5 以上是不需要的,它會自動判斷啟不啟動控制檯視窗。

好了,儲存,關閉專案檔案。可以寫程式碼了。

在寫程式碼前,咱們先理清楚一些核心物件的關係。你才會知道怎麼寫。Application 類是 WPF 程式啟動的核心物件,通常表示該應用程式相關的初始化。所以,在 Main 方法中記得 new 一個。你可別太聰明,千萬不要直接從 Application.Current 靜態屬性來獲取。因為這時應用程式還沒初始化呢,Current 屬性還是 null。Current 屬性適合在專案的其他程式碼中方便存取 Application 物件而使用的。

如果你沒別的東西初始化,那就呼叫 Application 物件的 Run 方法。應用程式正式啟動,並且主執行緒會卡在(也不是真的卡)這裡,直到程式要退出了才從 Run 方法返回。其間,排程器會不斷排程/處理各執行緒上的訊息,直到訊息迴圈終止。

視窗應用程式當然要一個主視窗。表示視窗的基礎類別是 Window,可以直接用它,也可以派生出自己的類,然後初始化要在視窗上顯示的控制元件。

public class MyWindow:Window
{
    public MyWindow()
    {
        InitUI();
    }

    private void InitUI()
    {
        // 本視窗的屬性
        this.Title = "鼠爺快樂園";
        this.Height = 225;
        this.Width = 315;
        // 啟動時視窗在螢幕中央
        WindowStartupLocation = WindowStartupLocation.CenterScreen;
        // 整點控制元件
        // 兩個block
        TextBlock tb1 = new()
        {
            Text = "每天斃一鼠",
            TextAlignment = TextAlignment.Center,
            // 文字顏色
            Foreground = new SolidColorBrush(Color.FromRgb(12, 50, 208))
        };
        TextBlock tb2 = new()
        {
            Text = "添壽又增福",
            TextAlignment = TextAlignment.Center
        };
        // 再加一個按鈕
        Button btn = new()
        {
            Content = "行動起來",
            Margin = new Thickness(0d, 15d, 0d, 2d)
        };
        // 單擊事件
        btn.Click += OnClick;
        // 佈局控制元件
        StackPanel panel = new();
        // 垂直方向
        panel.Orientation = Orientation.Vertical;
        // 新增子元素
        panel.Children.Add(tb1);
        panel.Children.Add(tb2);
        panel.Children.Add(btn);
        // 作為視窗的內容
        this.Content = panel;
    }

    private void OnClick(object sender, RoutedEventArgs e)
    {
        MessageBox.Show("自在其間樂");
    }
}

Windows 屬於內容控制元件,公開 Content 屬性,用來設定單個物件參照。上述程式碼先建立兩個 TextBlock 範例和一個 Button 範例,然後把它們塞進 StackPanel 中,再把 StackPanel 範例賦值給視窗的 Content 屬性。

視窗類寫好後,在 Main 方法中,呼叫 Run 方法時把視窗範例傳進去。

    [STAThread]
    static void Main(string[] args)
    {
        Application app = new();
        app.Run(new MyWindow());
    }

這個程式已經可以執行了。

 

************************************************************************************************

咱們繼續探索。如果要用到資料繫結呢。在 XAML 中是用 {Binding} 擴充套件標記的,而在程式碼中對應的是 Binding 類,位於 System.Windows.Data 名稱空間。

Binding 類別建構函式可以傳遞一個字串常數,對應 {Binding Path=... } 中的 Path,即要繫結的物件路徑。資料來源則由 Source 屬性設定。

public Binding(string path);

關聯絡結用的是 BindingOperations 類的靜態方法 SetBinding,要獲取已關聯的 Binding 物件就呼叫 GetBinding 方法。

public static Binding GetBinding(DependencyObject target, DependencyProperty dp);
public static BindingExpressionBase SetBinding(DependencyObject target, DependencyProperty dp, BindingBase binding);

BindingOperations 類本身是靜態類,所以它的成員自然也是靜態的。target 引數是繫結目標,即 WPF 物件,dp 是要繫結的依賴屬性,binding 就是Binding物件。

咱們舉個例子。

假設用以下類作為資料來源。

public class Student
{
    public int ID { get; set; }
    public string? Name { get; set; }
    public int Age { get; set; }
}

視窗的結構:內容根為 Grid 物件,它包含三行兩列,用來放六個 TextBlock 控制元件。

        Grid layout = new();
        // 設定為視窗內容
        this.Content = layout;
        // 設定邊距
        layout.Margin = new Thickness(13.5d);
        // 三行兩列
        layout.ColumnDefinitions.Add(new ColumnDefinition()
        {
            Width = GridLength.Auto
        });
        layout.ColumnDefinitions.Add(new ColumnDefinition(){
            // 星號,即 1*
            Width = new GridLength(1.0d, GridUnitType.Star)
        });
        layout.RowDefinitions.Add(new()
        {
            Height = GridLength.Auto
        });
        layout.RowDefinitions.Add(new()
        {
            Height = GridLength.Auto
        });
        layout.RowDefinitions.Add(new()
        {
            Height = GridLength.Auto
        });

ColumnDefinitions 用來定義列。上述程式碼中,第一列的寬度為 Auto,第二列的寬度為 *。

RowDefinitions 集合用來定義行。上述程式碼中,三行的高度都是 Auto。

然後 new 出六個 TextBlock,三個用來顯示欄位標籤,三個用於資料繫結,顯示屬性值。

        // 六個block
        var tbIDf = new TextBlock(){
            Text = "學號:"
        };
        var tbNamef = new TextBlock()
        {
            Text = "姓名:"
        };
        var tbAgef = new TextBlock()
        {
            Text = "年齡:"
        };

        TextBlock tbIDv = new();
        var tbNamev = new TextBlock();
        var tbAgev = new TextBlock();

把六個 TextBlock 控制元件新增到 Grid 的子級中。

        layout.Children.Add(tbIDf);
        layout.Children.Add(tbIDv);
        layout.Children.Add(tbNamef);
        layout.Children.Add(tbNamev);
        layout.Children.Add(tbAgef);
        layout.Children.Add(tbAgev);

設定子元素所在行、列的 Grid.Row 和 Grid.Column 是附加屬性,以 Grid.SetXXX 方法呼叫。

    layout.Children.Add(tbIDf);
    layout.Children.Add(tbIDv);
    layout.Children.Add(tbNamef);
    layout.Children.Add(tbNamev);
    layout.Children.Add(tbAgef);
    layout.Children.Add(tbAgev);
    // Row、Column 附加屬性
    // 第一行第一列
    Grid.SetRow(tbIDf, 0);
    Grid.SetColumn(tbIDf, 0);
    // 第一行第二列
    Grid.SetRow(tbIDv, 0);
    Grid.SetColumn(tbIDv, 1);
    // 第二行第一列
    Grid.SetRow(tbNamef, 1);
    Grid.SetColumn(tbNamef, 0);
    // 第二行第二列
    Grid.SetRow(tbNamev, 1);
    Grid.SetColumn(tbNamev, 1);
    // 第三行第一列
    Grid.SetRow(tbAgef, 2);
    Grid.SetColumn(tbAgef,0);
    // 第三行第二列
    Grid.SetRow(tbAgev, 2);
    Grid.SetColumn(tbAgev, 1);

最後是建立三個 Binding ,為 Student 類的三個屬性做繫結。

    /* ID */
    Binding bindID = new(nameof(Student.ID))
    {
        Source = this.stu
    };
    BindingOperations.SetBinding(tbIDv, TextBlock.TextProperty, bindID);
    /* Name */
    Binding bindName = new(nameof(Student.Name))
    {
        Source = stu
    };
    BindingOperations.SetBinding(tbNamev, TextBlock.TextProperty, 
bindName);
    /* Age */
    Binding bindAge = new(nameof(Student.Age))
    {
        Source = stu
    };
    BindingOperations.SetBinding(tbAgev, TextBlock.TextProperty, bindAge);

完整的初始化方法程式碼如下:

private void InitUI()
{
    Title = "資料繫結";
    Width = 240;
    Height = 185;
    // 建立Grid
    Grid layout = new();
    // 設定為視窗內容
    this.Content = layout;
    // 設定邊距
    layout.Margin = new Thickness(13.5d);
    // 三行兩列
    layout.ColumnDefinitions.Add(new ColumnDefinition()
    {
        Width = GridLength.Auto
    });
    layout.ColumnDefinitions.Add(new ColumnDefinition(){
        // 星號,即 1*
        Width = new GridLength(1.0d, GridUnitType.Star)
    });
    layout.RowDefinitions.Add(new()
    {
        Height = GridLength.Auto
    });
    layout.RowDefinitions.Add(new()
    {
        Height = GridLength.Auto
    });
    layout.RowDefinitions.Add(new()
    {
        Height = GridLength.Auto
    });
    
    // 六個block
    var tbIDf = new TextBlock(){
        Text = "學號:"
    };
    var tbNamef = new TextBlock()
    {
        Text = "姓名:"
    };
    var tbAgef = new TextBlock()
    {
        Text = "年齡:"
    };
    TextBlock tbIDv = new();
    var tbNamev = new TextBlock();
    var tbAgev = new TextBlock();
    // 把六個block放到 grid 上
    layout.Children.Add(tbIDf);
    layout.Children.Add(tbIDv);
    layout.Children.Add(tbNamef);
    layout.Children.Add(tbNamev);
    layout.Children.Add(tbAgef);
    layout.Children.Add(tbAgev);
    // Row、Column 附加屬性
    // 第一行第一列
    Grid.SetRow(tbIDf, 0);
    Grid.SetColumn(tbIDf, 0);
    // 第一行第二列
    Grid.SetRow(tbIDv, 0);
    Grid.SetColumn(tbIDv, 1);
    // 第二行第一列
    Grid.SetRow(tbNamef, 1);
    Grid.SetColumn(tbNamef, 0);
    // 第二行第二列
    Grid.SetRow(tbNamev, 1);
    Grid.SetColumn(tbNamev, 1);
    // 第三行第一列
    Grid.SetRow(tbAgef, 2);
    Grid.SetColumn(tbAgef,0);
    // 第三行第二列
    Grid.SetRow(tbAgev, 2);
    Grid.SetColumn(tbAgev, 1);
    // 資料繫結
    /* ID */
    Binding bindID = new(nameof(Student.ID))
    {
        Source = this.stu
    };
    BindingOperations.SetBinding(tbIDv, TextBlock.TextProperty, bindID);
    /* Name */
    Binding bindName = new(nameof(Student.Name))
    {
        Source = stu
    };
    BindingOperations.SetBinding(tbNamev, TextBlock.TextProperty, 
bindName);
    /* Age */
    Binding bindAge = new(nameof(Student.Age))
    {
        Source = stu
    };
    BindingOperations.SetBinding(tbAgev, TextBlock.TextProperty, bindAge);
}

執行效果如下:

 

這時候,估計你也想到了一件事—— WPF 元素之間的繫結咋弄?對應的 XAML 擴充套件標記 {Binding ElementName=..., Path=... }。這個用程式碼寫起來也不難,Binding 類有 ElementName 屬性,可以參照已命名的物件。但是,在程式碼裡面,咱們是直接用變數名來參照物件的,並沒有分配物件名稱。雖然 FrameworkElement 類的子類都繼承了 Name 屬性,但,設定這個 Name 屬性 Binding.ElementName 是找不到的,必須要在 NameScope 物件裡註冊到XAML名稱空間後才能被 ElementName 參照。

NameScope 類其實是個 Key=String, Value = Object 的字典,維護當前名稱空間範圍內的物件列表。對應的是 XAML 中的 x:Name = ...。
NameScope 類定義了 NameScope 附加屬性,允許將 NameScope 範例設定到目標物件上。XAML 語法是
<NameScope.NameScope>
    <NameScope />
</NameScope.NameScope>

但我們在寫 XAML 時是不需要,都是自動新增的,用 x:Name 就行了。

在程式碼中用 SetNameScope 方法設定。

 

看看下面的例子。

void InitUI()
{
    Title = "元素之間繫結";
    // 根據內容自動調整視窗大小
    SizeToContent = SizeToContent.WidthAndHeight;
    StackPanel panel = new(){
        Orientation = Orientation.Vertical,
        Margin = new Thickness(15.0d)
    };
    this.Content = panel;   // 佈局
    // 文字輸入控制元件
    TextBox txt = new TextBox();
    txt.Margin = new Thickness(3.0d, 5.0d, 3.0d, 8.5d);
    // 給它分配一個名字,繫結時用到
    NameScope myScope = new();
    NameScope.SetNameScope(this, myScope);
    myScope.RegisterName("txtInput", txt);
    // 文字塊
    TextBlock tb = new TextBlock();
    tb.Margin = new Thickness(5.0d, 0d, 5.0d, 0d);
    // 繫結
    Binding bind = new();
    bind.ElementName = "txtInput";
    bind.Path = new PropertyPath(TextBox.TextProperty);
    bind.Mode = BindingMode.OneWay;
    // 在文字方塊更改時更新資料
    bind.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged;
    BindingOperations.SetBinding(tb, TextBlock.TextProperty, bind);
    // 新增到佈局
    panel.Children.Add(txt);
    panel.Children.Add(tb);
}

這個方法也是寫在 Window 的派生類中,SizeToContent = SizeToContent.WidthAndHeight 表示本視窗的寬度和高度會根據它要顯示的內容自動調整。

由於 TextBlock 控制元件的文字來源於 TextBox,因此,要為 TextBox 註冊一個名字「txtInput」。由於 FrameworkElement 類有 RegisterName 方法,所以,註冊名稱的程式碼也可以這樣寫:

NameScope.SetNameScope(this, new NameScope());
this.RegisterName("txtInput", txt);

設定 bind.UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged 允許在輸入的內容更改後就馬上更新系結資料,能做到實時顯示輸入的內容。

效果如下:

好了,今天咱們先說到這兒,剩下的如模板、樣式、動畫、3D 什麼的,咱們下次再探討。