6

【.NET 深呼吸】全代码编写WPF程序 - 东邪独孤

 1 year ago
source link: https://www.cnblogs.com/tcjiaan/p/17482371.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

【.NET 深呼吸】全代码编写WPF程序

学习 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());
    }

这个程序已经可以运行了。

367389-20230615114531773-595956465.png

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

咱们继续探索。如果要用到数据绑定呢。在 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);
}

运行效果如下:

367389-20230615164134548-626135789.png

这时候,估计你也想到了一件事—— 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 允许在输入的内容更改后就马上更新绑定数据,能做到实时显示输入的内容。

效果如下:

367389-20230615174600835-1540222183.png

好了,今天咱们先说到这儿,剩下的如模板、样式、动画、3D 什么的,咱们下次再探讨。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK