學習 Code 總有這樣一個過程:入門時候比較依賴設計器、標記語言等輔助工具;等到玩熟練了就會發現純代碼寫 UI 其實更高效。而且,純代碼編寫也是最靈活的。Windows Forms 項目是肯定可以全代碼編寫的,哪怕你使用了設計器,它最後也是生成代碼文件;而 WPF 就值得探索一下了。咱們知道,WP ...
學習 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 什麼的,咱們下次再探討。