學習 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.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 什麼的,咱們下次再探討。