注:本文章全部出自我阅读《深入浅出WPF》并实际应用之后的想法和记录,感谢原作者刘铁猛老师
1 概述
WPF是微软继MFC之后推出的界面框架,Windows10主打的UWP界面框架就是基于WPF优化而来的。标签语言XAML(我个人读作Ik’s-am(ə)l)在Windows窗体应用程序开发中,起到了与BS结构中的与HTML+CSS+Javascript一样的作用。这类标签语言的好处就是可以很轻松的和后台逻辑代码解耦和。
WPF中的XAML文件在.NET框架当中的位置:
由于XAML被设计用来专门编写Windows窗口程序的,与BS架构区分客户端和服务器端不同,所以与HTML在浏览器上被解析不同,XAML也有自己的编译器
- XAML与CS文件一样,也有自己的编译器,同样被编译成中间语言在CLR平台上共同运行
- 所以:
- 一个XAML可以结合多个.cs文件共同编译
- XAML能访问的.cs也能访问,反之依然
1.1 应该明确的基础知识点
1.1.1 XAML文件头中都是什么意思
当我们新建了一个WPF程序之后,VS会自动为我们生成一个MainWindow.xaml以及它对应的MainWindow.cs。正如概览中所说,XAML在C#程序中负责用户UI并且有自己的编译器,它与一个或多个***.cs共同组成了一个windows窗体,并且与C#代码一样,也可以引用命名空间
根据上边的图示,可以很清楚的体会XAML在C#程序中所在的位置了,它可以很轻松的和***.cs文件打通关系
1.1.2 WPF当中的Resource(资源)
如果对MFC熟悉的话,应该知道,MFC中非常重要的**.rc文件,这个文件主要保存了MFC界面当中的所有和显示有关的内容
WPF也部分的继承了这一点,几乎所有的控件,都有自己的Recource集(包括最底层的Window),Resource属性都可以包含以下前端显示可以包括的内容:
文本、对象模板(控件组合)、样式(CSS)、笔刷、图片、音频、视频、等等~~~
1.1.3 WPF当中的控件类型
以上这所有的控件分类,大致分为了布局控件、内容控件和条目控件,这个控件的分类的详细说明我会在后续继续详细说明的
1.2 x名称空间
x名称空间可以称之为“XAML名称空间”,在这个名称空间内,通常可以访问到所有XAML的通用属性,用这些属性来控制用户界面上的所有控件。
1.2.1 x:Name
为XAML的一个控件实例起名字,虽然在默认的命名空间中也有Name可以使用,但是x:Name适用性更广,建议统一使用x:Name
<StackPanel>
<TextBox x:Name="text" Text="{Binding m_Name}" Height="50"></TextBox>
<Button Click="Button_Click" Height="50">Button</Button>
</StackPanel>
private void Button_Click(object sender, RoutedEventArgs e)
{
this.text.Text = "Modify the Text";
}
1.2.2 x:Key
是一个Key-Value键值对,通常用于控件的Resource当中,这个Key就是这个资源的名字
<Style x:Key="BlueButton" TargetType="Button">
<Setter Property="Foreground" Value="Blue"/>
</Style>
1.2.3 x:Type
x:Type相当于指定控件中的某一个属性对应的具体是哪个类型
这个在使用的时候,有以下几种场景:
- 1、在Style当中,指定这个Style的目标类型
<Style x:Key="ScrollBarBaseStyle" TargetType="{x:Type ScrollBar}" />
- 2、在自定义控件当中,往往有一些自定义的类型
- 比如自定义的TabControl当中,页签所对应的内容,如下例就是
<core:LiveExplorerTreeViewItem Header="Category Ordering"
SampleType="{x:Type samples:PropertyGrid.Views.PropertyGridCategoryOrderView}" />
public static readonly DependencyProperty SampleTypeProperty =
DependencyProperty.Register("SampleType", typeof(Type),
typeof(LiveExplorerTreeViewItem), new UIPropertyMetadata(null));
public Type SampleType
{
get
{
return (Type)GetValue(SampleTypeProperty);
}
set
{
SetValue(SampleTypeProperty, value);
}
}
WPF核心MVVM数据驱动模式中重要一环:Binding
1.3 Binding
Binding是xaml和cs沟通的桥梁,Binding的对象就是所有cs代码对外暴露的属性或方法的返回值
++Binding的应用直接反映了MVVM架构的思想++,具体的MVVM架构可参考下一篇文章
public class Binding : BindingBase
{
public Binding(string path);
…………
}
Binding关联的核心:
Binding关联核心——DataContext:
DataContext的类型是Object,可以关联任何的类型实例。每个控件都有自己的DataContext属性,只要将该属性赋一个实例值,这个控件的每一个显示属性就都可以使用binding来关联这个实例的属性了
在MSDN上是这么说的:
获取或设置 FrameworkElement 参与数据绑定时的数据上下文。
数据上下文是对象可从对象关系层次结构中的后续父对象继承数据绑定信息的概念。
数据上下文中最重要的一个层面就是用于数据绑定的数据源。DataContext 的典型用法是将其直接设置为数据源对象。此数据源可以是类的实例,例如业务对象。或者您可以作为可观测集合创建一个数据源,从而让数据上下文环境启用对支持集合的更改进行检测。如果数据源是由一个也包含在项目中的库定义的,设置 DataContext 通常也伴随有在 ResourceDictionary 中将数据源作为一个关键资源进行初始化,然后在含有 StaticResource 引用的 XAML 中设置 DataContext。
1.3.1 使用Binding关联
1、使用binding关联XAML元素和C#抽象类的属性
binding主要用来在三层软件架构当中使逻辑层和展示层进行通信的。
所以Data binding可以剥离显示层与逻辑层,使之解耦和,这也是MVVM框架的一个基础核心(下一节会详细讲解)
// 抽象类当中的属性;
private string _brithday;
public string m_Birthday
{
get
{
return _brithday;
}
set
{
_brithday = value;
RaisePropertyChanged("m_Birthday");
}
}
// 前端的显示控件;
<TextBox x:Name="Birthday" Height="50"></TextBox>
<!-- 或者通过前端的binding实现 -->
<TextBox x:Name="Birthday" Height="50" Text={Binding path="m_Birthday"}></TextBox>
// 在初始化的时候,设置关联;
this.Birthday.SetBinding(TextBox.TextProperty, new Binding("m_Birthday") { Source = PageMain });
2、使用Binding和ObjectDataProvider关联XAML和C#抽象类中的方法返回值
ObjectDataProvider odp = new ObjectDataProvider();
odp.ObjectInstance = new Calc();
odp.MethodName = "Add";
odp.MethodParameters.Add("0");
odp.MethodParameters.Add("0");
// 这个控件的Text属性,通过odp关联了方法的第一个参数;
this.CalcX.SetBinding(TextBox.TextProperty, new Binding("MethodParameters[0]") {
Source = odp,
BindsDirectlyToSource = true,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged
});
// 这个控件的Text属性,通过odp关联了方法的返回值;
this.CalcRes.SetBinding(TextBox.TextProperty, new Binding(".")
{
Source = odp
});
3、使用binding关联两个XAML元素
- binding也可以关联同样是前端的元素
<Label>控件绑定前端元素:</Label>
<TextBox Text="{Binding ElementName=slider1, Path=Value, Mode=OneWay}"></TextBox>
<Slider x:Name="slider1" Maximum="20"></Slider>
使用binding关联ItemControl类型的控件
前边的简单例子都是关联了只具有单一属性的控件,比如Text这样的控件,而类似treeview或者ListBox这样的控件则需要用一个容器与之匹配
<ListBox x:Name="PersonList"></ListBox>
//后台使用binding关联
List<Person> persons = new List<Person>();
// 这个是ItemControl类的控件使用binding关联数据源的方法;
this.PersonList.ItemsSource = persons;
this.PersonList.DisplayMemberPath = "m_Name";
1.3.2 binding的属性
-
binding的数据源:Source
binding的源是数据的来源,如何使用binding关联后台的控制层,有以下几种方法: -
binding的关联方向:Mode
Binding是源和目标的桥梁,而这座桥梁的通行方向也是可以被设置的,比如想设置成单行道还是双向道甚至是一次性的都是可以的。
属性 | 内容 | 说明 |
Mode | Default | Mode的默认值,默认为双向binding |
TwoWay | 双向binding(即读写) | |
OneWay | 更改binding源的时候,更新binding目标(View)。(即只读) | |
OnTime | 实际上是OneWay的一种简化形式 | |
OneWayToSource | 目标(View)更改关联到源(即只写) |
- binding的路径:Path
binding源的属性很多,需要通过path指定。path是PropertyPath类型
可以在Binding的构造函数或者属性中设置path,如下两种方式关联path属性
<!-- 前端Binding,上下两个是等价的 -->
<TreeView Name="tree">
<TreeViewItem Header="Root">
<TreeViewItem x:Name="Tree1" Header="{Binding Path = m_A}"></TreeViewItem>
<TreeViewItem Header="{Binding m_B}"></TreeViewItem>
</TreeViewItem>
</TreeView>
// 或者使用后台代码进行Binding
this.Tree1.SetBinding(TextBox.TextProperty, new Binding("m_A") {});
1.3.3 自定义控件使用Binding
在系统提供的现有控件当中,可以使用Binding和类型的属性进行关联,但如果是自定义的用户控件,则需要使用与属性概念相对应的一个叫做依赖属性实现
依赖属性,顾名思义就是它自己本身没有值,需要通过Binding从一个数据源上获取(依赖在别人身上)。TextBox中的Text,DataGridTextColumn中的Header等等都是依赖属性,只不过这些依赖属性已经被微软定义好的,当然,依赖属性也可以在自定义控件中自定义
WPF三大前后端解耦利器:依赖属性、路由事件、命令
–以上是WPF程序的最基本使用方法,使用以上技能,就可以编写非常简单的windows窗体应用了,但是这样做就会造成一个非常严重的问题,就是几乎的界面设计和逻辑处理,全部堆叠在了MainWindow的xmal和cs文件当中。
–而WPF设计的初衷其实就是能够将逻辑代码与前端代码尽量的解耦和,所以WPF为我们提供了更加高级的一些功能,诸如:依赖属性、路由事件、命令等等等等
1.4 WPF中的属性系统(依赖属性)
我们对于C#类型当中的属性都是非常了解的,即使用C#2.0更新的Get、Set封装器所实现的类型的属性(在本节,为了区分所谓的属性,我称这种属性为CLR属性)。
但是比如一个TextBox它总共有138个属性,如果每个属性都被初始化的话,但一般来说,只有Text这个属性才是最常用的,所以这种做法对于实例化一个控件来说,是十分浪费内存的,所以WPF引入了一个依赖属性机制
- 一个依赖属性是如何定义的:
// 一个依赖属性,这个属性是依赖于其他属性的;
// 参数一:指明哪一个CLR属性作为
// 参数二:
// 参数三:
public static readonly DependencyProperty NameProperty = DependencyProperty.Register(
"Name", typeof(string), typeof(DepPropertyBase));
// 微软规定,CLR属性要与定义依赖属性时候的Register中的名称一致;
public string Name
{
get{ return (string)GetValue(NameProperty); }
set{ SetValue(NameProperty, value); }
}
- 设置依赖属性的值:
DepPropertyBase dpb = new DepPropertyBase();
// 依赖属性可以通过Set\Get设置和获取数值
dpb.SetValue(DepPropertyBase.NameProperty, this.StyleWPF.Content);
string ret = (string)dpb.GetValue(DepPropertyBase.NameProperty);
// 同样也可以通过CLR封装器进行访问
dpb.Name = (string)this.StyleWPF.Content;
ret = (string)dpb.Name;
- 设置依赖属性的依赖:
// 设置依赖属性的依赖;
// 将名为StyleWPF实例的Text属性附加到依赖属性中;
Binding bd = new Binding("Text") {
Source = this.StyleWPF
};
BindingOperations.SetBinding(dpb, DepPropertyBase.NameProperty, bd);
以上这个就是从定义一个依赖属性,再到把一个对象的属性值附加到依赖属性当中的整体流程。如果是自定义控件的话,就可以直接继承对应的控件然后进行改写了:
public enum ButtonState
{
None,
Red,
Green
}
public class MetroButton : Button
{
// 一个控制Button颜色的依赖属性;
public static readonly DependencyProperty MetroButtonStateProperty =
DependencyProperty.Register("MetroButtonStateProperty",Typeof(ButtonState),
Typeof(MetroButton), new PropertyMetadata(ButtonState.None));
public ButtonState MetroButtonState
{
get { return (ButtonState)GetValue(MetroButtonStateProperty); }
set { SetValue(MetroButtonStateProperty, value); }
}
public MetroButton()
{
Utility.Refresh(this);
}
}
当然,依赖属性还能够独立于WPF进行使用,普通的非WPF控件对象的属性也可能存在依赖于其他对象属性的情况出现,这是就可以直接借用WPF中的依赖属性的思想了
1.5 WPF中的事件系统(路由事件)
WPF的主要构成就是控件,而控件天然就包含这非常多的事件(就是CLR提供的事件机制),比如Button的Click事件、ComboBox的SelectChange事件以及用户的IO设备,比如键盘的KeyDown事件、鼠标的MouseMove等等~~~
什么是事件
在C#中为我们提供了事件机制,而事件机制的原理则是基于委托的函数回调机制,那么就可以引申出WPF事件模型中的三个关键点:
- 事件的拥有者
消息发送者,可以在某些条件下激发它拥有的事件。事件被触发即消息被发送 - 事件的响应者
消息的接收者和处理者 - 事件订阅关系
比如事件的响应者A和事件的拥有者B,如果A关注了B的某一个事件是否产生,就称之为A订阅了B的事件,实际上就是B.Event关联了A.EventHandler。所谓的事件触发,就是B.Event被调用,此时A.EventHandler也会被调用
那么最简单的例子就是:
- XAML:
<Button Click="StartEquatable_1">比较两个值—没有继承IEquatable时</Button>
- 主程序当中:
private void StartEquatable_1(object sender, RoutedEventArgs e)
{
……
}
在以上这个简单的例子中,就是主程序(事件的响应者)订阅了Button按钮(事件的拥有者)的Click事件。Click事件被调用,那Click的EventHandler:OnClick也会被调用
由于之前做过WinForm的开发,所以最开始接触WPF的时候,我自然而然的把程序中所有的事件订阅都写成了这样的形式,但是这样做有一个致命的缺点,就是我发现,界面中所有显示出来的控件,事件的订阅者全部都是MainWindow主程序订阅的,主程序文件到最后变得特别长,特别冗余,前边引用名称空间的地方,几乎把解决方案中的所有项目……
什么是路由事件
路由事件其实就是用来解决我上边提到的这个缺点的,路由事件首先就是降低了这种事件订阅来带的代码耦合度的。
- 明确的前提:
WPF中的事件系统基于控件的树形结构的,即随便指定一个UI中的控件,WPF都有能力通过树形结构索引到UI当中的任何一个其他控件
也就是说:比如在一个控件树中,一个Button被点击了,它就会喊一声“我被单击了”,这个Click事件就会在整个控件树中以某一种规则一个节点一个节点地传递下去,直到最终的根节点
正如上例看到的,如果是普通的直接事件的话,会存在以下两个弊端:
- 事件的发送者并不知道事件订阅者是谁(虽然程序员知道,都是主程序……)
- 代码的耦合程度较高
自定义控件中的路由事件
通过自定义路由事件,可以更新某个控件被执行操作之后,触发的功能,比如一个Button控件,被点击的时候,会通过路由事件的事件参数传递出去。
在下边的例子中,重新定义的ButtonTime的功能是,点击这个按钮之后,该按钮会将点击的时间通过路由事件的参数发送出去
/// <summary>
/// 路由事件的参数;
/// ClickTime:当前点击时候的系统时间;
/// </summary>
public class ReportTimeEvtArgs : RoutedEventArgs
{
public ReportTimeEvtArgs(RoutedEvent re, object source)
: base(re, source){ }
public DateTime ClickTime { get; set; }
}
/// <summary>
/// 自定义一个Button按钮,这个按钮支持自定义的路由事件;
/// 这个路由事件就是上报了一个当前按钮的点击时间;
/// </summary>
class ButtonTime : Button
{
// 一个路由事件静态实例;
// 注意:路由事件的第一个名字需要和CLR事件的名称一致才行!!;
public static readonly RoutedEvent ReportRoutedEvent = EventManager.RegisterRoutedEvent
("ReportTime", RoutingStrategy.Bubble, typeof(EventHandler<ReportTimeEvtArgs>), typeof(ButtonTime));
// CLR事件包装;
public event RoutedEventHandler ReportTime
{
add { this.AddHandler(ReportRoutedEvent, value); }
remove { this.RemoveHandler(ReportRoutedEvent, value); }
}
// 当发生点击的时候,可以通过更新事件参数,将该自定义的路由事件参数传递出去;
protected override void OnClick()
{
base.OnClick();
ReportTimeEvtArgs args = new ReportTimeEvtArgs(ReportRoutedEvent, this);
args.ClickTime = DateTime.Now;
this.RaiseEvent(args);
}
}
<!-- 通过在XAML前端,增加路由事件的监听,并指定对应监听到路由事件后的对应的处理函数 -->
<Grid Grid.Row="0" x:Name="grid1" routeevt:ButtonTime.ReportTime="RptTime2">
<StackPanel x:Name="sp1" routeevt:ButtonTime.ReportTime="RptTime">
<routeevt:ButtonTime x:Name="timebutton" Height="100"
routeevt:ButtonTime.ReportTime="RptTime">
触发路由事件
</routeevt:ButtonTime>
</StackPanel>
</Grid>
// 上边XAML注册的路由事件监听的函数处理;
private void RptTime(object sender, ReportTimeEvtArgs e)
{
Console.WriteLine("路由内容;" + (sender as FrameworkElement).Name + "点击时间;"+ e.ClickTime.ToString());
}
private void RptTime2(object sender, ReportTimeEvtArgs e)
{
Console.WriteLine("路由内容2;" + (sender as FrameworkElement).Name + "点击时间2;" + e.ClickTime.ToString());
}
// 也可以在C#代码中使用AddHandler增加对路由事件的处理;
RouteEventGrid1.AddHandler(ButtonTime.ReportRoutedEvent,
new RoutedEventHandler((object sender, RoutedEventArgs e) =>
{
Console.WriteLine("The " + sender.GetType() +
" Receive a Button Event and the Button is " +
(e.OriginalSource as ButtonTime).Content.ToString() +
"and ClickTime is:" + (e as ReportTimeEvtArgs).ClickTime);
}));
以上就是路由事件的基本原型,按照这个方式,程序员就可以将想要实现的按钮抽象出来,并把这个控件的事件做成一个自定义的路由事件,这样,就可以很有效的把控件与主程序解耦和,更加方便后续的维护。
但是路由事件有一个比较致命的缺点,就是,路由事件仅限在一棵控件树中传递事件,到目前为止我还没有想到有一个什么很好的解决方案,我理解,这个限制大大的缩减了路由事件的应用场景,比如在一个界面中,有一个搜索按钮,这个搜索按钮可以控制界面中的多个其他控件,但这其他控件又不在搜索按钮的控件树之上,这样该如何处理呢???
1.6 WPF中的命令系统
与刚刚的路由事件不同,在路由事件中,作为触发源的控件,只能控制事件如何触发,但是不能控制触发后,这个事件如何进行处理,这个事件的处理,完完全全是由路由事件的注册者执行的。
而WPF提供的命令系统主要的作用恰恰相反,它的作用是让控件的某一个事件的触发源完完全全的控制整个事件发生后的是如何执行的。命令这个词也非常非常的贴切,就像是命令的触发源在发号施令,指挥着命令接收者如何进行工作。
所以,WPF中的命令系统则必须具备以下几个元素:
- 命令本身
- 命令的触发源
- 命令的目标
- 命令的关联关系
比如,有两个控件,这两个控件分别是命令的触发源和命令的目标,它的作用就是简单的用一个按钮清空TextBox中的内容:
<!-- WPF中的命令系统 -->
<Grid Grid.Row="1" Margin="0,10">
<StackPanel x:Name="CommandStackPanel">
<TextBox x:Name="CommandTextBox"></TextBox>
<Button x:Name="CommandButton">清空命令(清空以上输入)</Button>
</StackPanel>
</Grid>
在主程序代码当中,就可以通过代码进行命令源、命令目标之间关系的注册:
// 微软CLR默认使用了左单击时为执行命令的时机;
this.CommandButton.Command = this.clearCMD; // 为控件即命令源添加命令;
this.CommandButton.CommandTarget = this.CommandTextBox; // 设置命令的目标;
this.CommandButton.CommandParameter = "CommandParameter"; // 设置命令参数,命令源就是用这个来传参的;
this.clearCMD.InputGestures.Add(new KeyGesture(Key.C, ModifierKeys.Alt)); // 为命令添加快捷键;
// 创建命令关联;
CommandBinding cb = new CommandBinding();
cb.Command = this.clearCMD; // CommandBinding的Command
cb.CanExecute += Cb_CanExecute; // CommandBinding对应命令可以执行的监听;
cb.Executed += Cb_Executed; // CommandBinding对应命令可以执行的处理;
this.CommandStackPanel.CommandBindings.Add(cb); // 在控件树上的上游节点添加这个CommandBinding;
需要注意的是:在WPF中,在默认情况下,只有实现了IcommandSource的控件类型才能作为触发源使用,因为只有他们才具备发送命令的能力,比较有代表性的就是Button和MenuItem。所有支持命令源的控件如下:
另外,把CommandBinding实例放在了外层的StackPanel的主要作用就是,利用Button自带的路由事件,让处于上层的StackPanel能够监控到它触发的事件源,并且,一旦StackPanel附加上了这个CommandBinding之后,它的CanExecute就会一直在后台启动一个监听线程,一直观察着这个命令是否能够被触发源执行。
当以上工作完成之后,实现CanExecute和Executed的实现即可:
/// <summary>
/// 这个附加在Button所在控件树之上的StackPanel控件上的监听线程;
/// 微软默认控制了Button的Enable属性,如果返回false,则Enable属性则也false;
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void Cb_CanExecute(object sender, CanExecuteRoutedEventArgs e)
{
if(string.IsNullOrEmpty(this.CommandTextBox.Text))
{
e.CanExecute = false;
}
else
{
e.CanExecute = true;
}
e.Handled = true;
}
private void Cb_Executed(object sender, ExecutedRoutedEventArgs e)
{
this.CommandTextBox.Clear();
//Console.WriteLine("命令的参数是:" + e.Parameter.ToString());
}
最终,通过WPF的命令系统,首先做到的就是将命令和处理事件的函数解耦和了,主程序中,重点要做的,就是将命令触发源和命令的目标关联上即可,那么至于命令的处理,在实际编程过程中,有大部分的处理,基本上是相同的,这时可以应用一些诸如策略模式这样非常简单的设计模式,就能够让我们的工程的代码结构更加清晰整洁,更加便于维护
非常遗憾,WPF的命令系统,同样受制于路由事件的限制,如果CommandTarget和Command的源头不再同一个控件树当中,那么这个命令是无法送达这个Target的
另外,本文所有代码的例子,都可以在以下工程中找到,VS2015和VS2017都可以编译通过
https://github.com/visiontrail/CSharpKnowledge