Dotnet GUI开发

微软官方的跨平台GUI框架MAUI不支持linux,一般还是使用Avalonia,此外还有一个UNO Platform框架,对比可见此文.

目前C#最新版是12,.NET最新的LTS版本是.NET 8.

安装

ubuntu22.04的源里目前只有dotnet7,想要装8的话需要使用微软官方的源,安装方式如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Download Microsoft signing key and repository
wget https://packages.microsoft.com/config/ubuntu/22.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb

# Install Microsoft signing key and repository
sudo dpkg -i packages-microsoft-prod.deb

# Clean up
rm packages-microsoft-prod.deb

# Update packages
sudo apt update && sudo apt install dotnet-sdk-8.0

# 使用国内源
dotnet nuget remove source nuget.org
dotnet nuget add source https://mirrors.cloud.tencent.com/nuget/ -n tencent_nuget

然后,如果要写Avalonia应用的话,建议使用Rider编辑器,并安装相关插件和模板:

1
dotnet new install Avalonia.Templates

基础

C#的基础知识可以看这本比较新的书,或者直接看MSDN也行,微软的文档水平还是没得挑的。

C#语言整体和Java比较像,内置的包管理NuGet比maven好用一点,大部分概念和Java也比较一致。

支持tuple因此可以返回多个值。默认值传递,但是也可以使用ref, in, out修饰符。

比Java更好的是,支持AOT编译成原生程序,类似Golang,可以无运行时启动。

Avalonia

这个框架可以理解为C#版本的flutter,主要还是用skia进行绘制,不依赖平台的界面库。所以好处是在各个平台保持一致性,坏处是性能不如原生的程序。

Avalonia和WPF的很多基础概念是一致的,所以可以通过阅读wpf的书籍快速熟悉相关概念,wpf和COM、silverlight、mfc、winform、visualbasic甚至WindowsPhone一样,早就被微软扔进历史的垃圾堆,很久没更新了,看10年前的资料都行。说到这里不得不吐槽一句,当年幸亏没跟着微软搞技术,不然估计现在找不到工作。 而且微软现在也在搞跨平台UI,即MAUI这个项目,但是他又不愿意投入很大精力,到现在还是个四不像,不如直接收购了Analonia或者UNO Platform.

Avalonia官方的指南写的比较糙,建议先看《深入浅出WPF》,了解一下xlms的语法和常用的标签。这里记录一下重点备忘:

命名空间

这是xml的设计,避免名称冲突的,如:

1
2
3
4
5
6
<Window x:Class="WpfApplication.Window1"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfApplication1"
        Title="Window1" Height="300" Width="300">
</Window>

xmlns:之后的是映射的名称,类似import xx as x的作用,下面引用命名空间的变量时,也要加上命名空间限定。

资源

类似全局变量,如可以设置全局style:

1
2
3
4
5
6
7
<Window.Resources>
	<Style x:Key="{x:Type Button}" TargetType="{x:Type Button}">
    	<Setter Property="Width" Value="60"/>
        <Setter Property="Height" Value="36"/>
        <Setter Property="Margin" Value="5"/>
    </Style>
</Window.Resources>

x命名空间

常用的一些东西都在x里面,但是String并不在,需要通过

1
xmlns:sys="clr-namespace:System;assembly=mscorlib"

引入.其中clr-namespace:也可以用using:替代.

x:Class

根节点用来绑定代码中的类的,类必然是partial

x:ClassModifier

用来修改Class的可见性的,默认是public

x:Name

就是标签的名称,有的标签有Name这个属性,两者是相通的。建议统一使用x:Name来命名。

该名称会创建成类成员变量。

x:FieldModifier

x:Name配合,修改成语变量的可见性,默认是internal.

x:Key

一般用于在Resource里面定义kv值,方便在其他地方复用,如:

1
2
3
<Window.Resources>
	<sys:String x:Key="myString">Hello World</sys:String>
</Window.Resources>

引用方式:

1
<TextBox Text="{StaticResource ResourceKey=myString}" />

代码里面使用this.FindResource("myString") as string也可以引用。

x:Shared

如果x:Key旁边额外使用x:Shared="false"标记元素,则每次获取对象时,获取的是对象的副本,而不是引用。

x:Type

即用字符串来表达一种类型,例如:

1
2
3
<StackPanel>
    <local:MyButton Content="Show" UserWindowType="{x:Type local:MyWindow}" Margin="5"/>
</StackPanel>

这里的local:MyWindow即类的名字,作为一种类型赋给UserWindowType这个参数(类型是Type)。

x:null

对应语言中的null常量

x:array

即直接声明数组,例如:

1
2
3
4
5
6
7
8
9
<ListBox Margin="5">
	<ListBox.ItemsSource>
    	<x:Array Type="sys:String">
        	<sys:String>Tim</sys:String>
            <sys:String>Tom</sys:String>
            <sys:String>Victor</sys:String>
        </x:Array>
    </ListBox.ItemsSource>
</ListBox>

x:Static

用以引用定义在代码中的static成员,例如:

1
<TextBlock FontSize="32" Text="{x:Static local:Window1.ShowText}"

x:XData

用以显式声明xml元素,以供其他地方引用,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<Window.Resources>
	<XmlDataProvider x:Key="InventoryData" XPath="Inventory/Books">
    	<x:XData>
        	<Supermarket xmlns="">
            	<Fruits>
                	<Fruit Name="Peach"/>
                </Fruits>
            </Supermarket>
        </x:XData>
    </XmlDataProvider>
</Window.Resources>

控件

这个和一般GUI的控件其实区别不大,这里只记录一些特别的设计。

  • 像素单位:默认肯定是px,如果在数值后面加上*,则表示按比例计算。这个比例按父元素的行或者列的整体高度来计算的;
  • 默认的1*也可以直接写作*Auto则是直接撑满父元素;
  • 如果不使用主题,直接布局的话,建议使用Grid进行(其实相当于html中使用表格布局);

布局

Grid

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<Grid Margin="10">
	<Grid.ColumnDefinitions>
    	<ColumnDefinition Width="Auto" MinWidth="120"/>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="80"/>
        <ColumnDefinition Width="4"/>
        <ColumnDefinition Width="80"/>
    </Grid.ColumnDefinitions>
    <Grid.RowDefinitions>
    	<RowDefinition Height="25"/>
        <RowDefinition Height="4"/>
        <RowDefinition Height="*"/>
        <RowDefinition Height="4"/>
        <RowDefinition Height="25"/>
    </Grid.RowDefinitions>
</Grid>

第一个Auto的意思其实是这一列的宽度由Grid里面的控件宽度来决定。

用的时候:

1
2
3
4
5
<TextBlock Text="Input:" Grid.Column="0" Grid.Row="0" VerticalAlignment="Center"/>
<ComboBox Grid.Column="1" Grid.Row="0" Grid.ColumnSpan="4"/>
<TextBox Grid.Column="0" GridRow="2" Grid.ColumnSpan="5" BorderBrush="Black"/>
<Button Content="ok" Grid.Column="2" Grid.Row="4"/>
<Button Content="cancel" Grid.Column="4" Grid.Row="4"/>

指定行列就行,如果跨多个,使用Span来指定跨度。

StackPanel

用于在纵向或者横向堆叠元素,紧凑排列。

如果将其中某个元素移除,后面的自动移动补全。

Canvas

通过X/Y坐标显式定位,一般不使用。

DockPanel

用以冻结控件位置,就像Mac的dock或者Windows的任务栏一样。

WrapPanel

流式布局,放不下的话会自动换行。

数据绑定

在xml中通过

1
<TextBox x:Name="txt" Text="{Binding Path=Value,ElementName=slider1}" BorderBrush="Black" Margin="5"/>

绑定到变量。

Binding有个属性是Mode,可以指定数据流向,包括:

  • TwoWay: 双向绑定
  • OneWay: 从数据到UI的单项绑定
  • OneTime: 数据初始化会绑定,后面不会传播
  • OneWayToSource: 从UI到数据的单项绑定
  • Default: 默认可以交互的UI控件是TwoWay,不能交互的是OneWay

WPF里面有Source参数,用以指定数据源,但是Avalonia并没有这个属性。

实战

直接看官方的sample代码学习是最快的。

BasicMvvmSample

这是个最基础的例子,主要展示了ReactiveUI和一般的Event驱动的区别。

普通的VM是继承INotifyPropertyChanged,然后在属性改变时,手动出发UI变动:

1
2
3
4
5
6
7
8
9
public class SimpleViewModel: INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;
    
    private void Raise([CallerMemberName] string? property=null)
    {
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
    }
}

而ReactiveUI则是通过订阅属性变更的方式来执行回调:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ReactiveViewModel: ReactiveObject
{
    private string _x;
    public string X
    {
        get
        {
            if (string.isNullOrEmpty(_x))
            {
                return "default";
            }
            else
            {
                return $"Hello {_x}"
            }
        }
        set
        {
            this.RaiseAndSetIfChanged(ref _x, value);
        }
    }
    public ReactiveViewModel()
    {
        //这里的RaisePropertyChanged是ReactiveObject自带的
        this.WhenAnyValue(o=>o.Name)
            .Subscribe(o=>this.RaisePropertyChanged(nameof(X)))
    }
}

除了ReactiveUI之外,C#中还有不少其他的MVVM框架,比如微软官方的CommunityToolkit.MVVM等。

Commands Sample

这个例子展示了Command的使用,这里举例了三种Command的场景,一种是直接绑定函数:

1
2
3
<!-- This button will ask HAL to open the doors -->
<Button Command="{Binding OpenThePodBayDoorsDirectCommand}"
        Content="Open the pod bay doors, HAL." />

对应的方法是:

1
2
3
4
5
6
7
public ICommand OpenThePodBayDoorsDirectCommand { get; }
OpenThePodBayDoorsDirectCommand = ReactiveCommand.Create(OpenThePodBayDoors);
private void OpenThePodBayDoors()
{    
    ConversationLog.Clear();
    AddToConvo( "I'm sorry, Dave, I'm afraid I can't do that.");
}

第二种是将命令的参数从输入控件中读取并传入方法:

1
2
3
4
<TextBox Text="{Binding RobotName}" Watermark="Robot Name" />
<Button Command="{Binding OpenThePodBayDoorsFellowRobotCommand}"
        Content="{Binding RobotName, StringFormat='Open the Pod Bay for {0}'}"
        CommandParameter="{Binding RobotName}" />

相关属性和方法:

1
2
3
4
5
6
public ICommand OpenThePodBayDoorsFellowRobotCommand { get; }
private void OpenThePodBayDoorsFellowRobot(string? robotName)
{
    ConversationLog.Clear();    
    AddToConvo( $"Hello {robotName}, the Pod Bay is open :-)");
}

初始化代码在构造函数中:

1
2
3
4
5
6
7
8
// Init OpenThePodBayDoorsFellowRobotCommand
// The IObservable<bool> is needed to enable or disable the command depending on valid parameters
// The Observable listens to RobotName and will enable the Command if the name is not empty.
IObservable<bool> canExecuteFellowRobotCommand =
    this.WhenAnyValue(vm => vm.RobotName, (name) => !string.IsNullOrEmpty(name));

OpenThePodBayDoorsFellowRobotCommand = 
    ReactiveCommand.Create<string?>(name => OpenThePodBayDoorsFellowRobot(name), canExecuteFellowRobotCommand);

canExecuteFellowRobotCommand是用来判断命名是否可用的谓词,换言之就是按钮是否可点击,这里是通过ReactiveUI生成的,对应的逻辑其实就是RobotName是否为空。

最后一种是将Command关联到异步方法上,避免阻塞UI主线程:

1
2
<Button Command="{Binding OpenThePodBayDoorsAsyncCommand}"
        Content="Start Pod Bay Opening Sequence" />

相关初始化代码:

1
OpenThePodBayDoorsAsyncCommand = ReactiveCommand.CreateFromTask(OpenThePodBayDoorsAsync);

openThePodBayDoorsAsync则是一段异步代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
private async Task OpenThePodBayDoorsAsync()
{
    ConversationLog.Clear();
    AddToConvo( "Preparing to open the Pod Bay...");
    // wait a second
    await Task.Delay(1000);

    AddToConvo( "Depressurizing Airlock...");
    // wait 2 seconds
    await Task.Delay(2000);

    AddToConvo( "Retracting blast doors...");
    // wait 2 more seconds
    await Task.Delay(2000);

    AddToConvo("Pod Bay is open to space!");
}    

运行的时候可以看到,在这段阻塞代码执行的时候,界面的其他元素仍然可以操作,但是对应的按钮则变为灰色不可用状态。

ValueConversionSample

顾名思义,这个是展示UI到VM/M层数据转换的。

Converter这个文件夹里面有三种不同的Converter,分别是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
//基于函数的转换器
public static FuncValueConverter<string?, Brush?> StringToBrushConverter{get;}
//单值转换器接口
public class MathAddConverter: IvalueConverter
{
    public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture);
    public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture);
}
//多值转换器接口
public class MathMultiConverter: IMultiValueConverter
{
    public object? Convert(IList<object>? values, Type targetType, object? parameter, CultureInfo culture);
}

在xaml里面通过resource引用这几个转换器,其中MathAddConverterMathMultiConverter是直接通过x:Key来引用的;而FuncValueConverter由于是static函数,所以直接通过x:Static即可引用。

Converter的作用机理也很简单,将控件绑定到某个属性,指定converter即可,也可以通过ConverterParameter传递相关的参数。当属性变更时,会按converter来修改控件的值;如果是IValueConverter,也需要调用反向的变更:当UI变更时,会调用ConverterBack来计算对应属性的值。【注意,这个转换不支持抛出异常,而是使用返回BindingOperations的值来替代】。

对于计算字段,应当是readonly的,此时通过属性计算出UI应当显示的值即可。可以通过MultiBinding Converter="{StaticResource}" Mode="OneWay",来进行多值绑定(将需要计算的控件值全部传进去)。

Validation Sample

顾名思义,这个例子讲的是如何进行输入的智能判断,这个我们在前端和后端都有类似的逻辑。

首先是最常用的,类似jsr303的注解:

1
2
3
4
5
6
7
8
9
using System.ComponentModel.DataAnnotations;

[Required]
[EmailAddress]
public string? EMail
{
    get {return _Email;}
    set {this.RaiseAndSetIfChanged(ref _Email, value);}
}

其次是你可以直接在set里面写校验逻辑,不正确就抛出对应的异常,

最后就是要自己实现INotifyDataErrorInfo这个接口,维护错误信息,并触发ErrorsChanged这个event.

0%