Marvin's Blog【程式人生】

Ability will never catch up with the demand for it

02 Aug 2020

介绍UWP中的XAML

XAML是Extensible Application Markup Language地缩写,是微软推出的图形界面描述语言。在XAML platform可以找到相关的文档。

为什么需要XAML

图形界面一般都是采用面向对象的方式编写。界面上的所有图形对象构成了一个颗倒生的树。树的根节点要么是窗口对象,或者是窗口对象的下一级对象。这棵倒生树的形态是单亲多子。也就是一个节点只能有一个上一级节点,但是却可以有多个下一级节点。由于具有这样固定的结构,很容易用一种支持嵌套的语言来描述。XAML就是基于XML规范,每个元素都可以包含嵌套其他元素,满足单亲多子的要求。

那为什么不使用直接使用代码来描述界面,而要使用XAML?代码的确表述能力比XAML强。但是使用XAML这种简单化的格式,可以在不同的代码之间共享界面描述。这种语言中立性带来的通用性还表现于,可以使用同一种设计工具来生成XAML描述,以便在不同的编程语言中使用。在流程上可以把设计者和实现者分开。

XAML可以说是把代码数据化了。好处是可以在代码中使用编程接口,加载一个含有XAML描述的字符串,然后生成相应的对象树。如果直接使用代码来描述界面的话,不是所有的程序语言都可以动态加载自身的代码,编译性的程序语言一般不提供这种支持。

真正在应用程序中使用XAML,其实有两道工序,第一道工序是编译,将XAML中定义的对象实例化以后存储在执行文件中;第二道工序是在程序加载的时候,根据XAML的描述,将对象实例从执行文件中恢复出来,并且组织成树状结构。生成的XAML代码中通常有一个InitializeComponent()函数,用来初始化XAML元素所代表的对象。

将代码数据化的好处是将代码中的逻辑变得更加通用。代码其实用于编译过程的数据,专为编译设计,通用性较差。将代码中的逻辑等同转化为XML这种通用数据类型,使得数据可以被多用工具处理,比如就有专门的可视化工具用来设计XAML界面。所付出的代价是在代码中使用XAML,需要通过一个解析器将XAML进行转化,才能和代码配合操作。

一个例子

一个例子:

<Grid>
    <TextBlock Text="one" />
    <TextBlock Text="two" />
    <TextBlock Text="three" />
</Grid>

Charles Petzold的Programming Windows, 6th Edition(其示例代码可以在Examples for Programming Windows 6th Edition获取)。

上面的Grid其实是WinRT提供的一个对象,这个对象有一个部属(Property)Children,可以接受其他对象的集合,这里Children被赋予三个TextBlock对象的集合,每个TextBlock的权属被赋予不同的值,分别是"one”、“two”和“three”。这样,一个UI对象树就构建起来了。编译以后,会在界面联排显示三段文字,分别是"one”、“two”和“three”。

Children作为Grid的默认内容部属,可以不指定,也可以显示指定。上面的代码等同于:

<Grid>
    <Grid.Children>
        <TextBlock Text="one" />
        <TextBlock Text="two" />
        <TextBlock Text="three" />
    </Grid.Children>
</Grid>

上面的使用元素<Grid.Children>来指定属性的办法叫做property element syntax

能够在XAML中使用的对象,必须支持无参数构造,也就是说,单单指定<TextBlock/>,而不指定其Text属性,必须也能工作。

XAML和WinRT是什么关系

XAML描述的是界面的蓝图,实际的界面要根据这个蓝图来构建。WinRT(也就是Windows Runtime)就是为这个蓝图提供各种各样的工具和对象来帮助其构造。

一个比喻,可以把XAML看成一块计算机主板,上面规划有电路图,然后所需的各色芯片部件则是由WinRT提供的。

另一个不那么技术性的比喻,XAML只是菜单,上菜则需要WinRT。XAML加载之后,需要实例化其中描述的对象,而这些个对象们则来自WinRT。WinRT并不是唯一能够给XAML上菜的系统,.NET的WPF也可以给XAML上菜。

据说XAML最早起源于Silverlight?

那可能有人要问,XAML可以用于Win32吗,也就是传统的C语言接口,以及COM接口?答案是不行的。XAML初始化的时候需要根据指定的类型信息来实例化对象。C语言接口并不是面向对象的;而COM虽然可以面向对象,但是只含有接口的描述,缺乏必要的类型信息。WinRT虽然是从COM演化而来,但是从.NET那里借用了生成类型信息的机制,从而其组件的DLL中包含有对象的类型,这样XAML就可以找到需要实例化的对象了。

XAML的语法

XAML是基于XML规范。即便你可能对XML不熟,但是对于另一个基于XML规范的HTML,相信很多人都有接触过。这两者有点相像,都倾向于把描述性的东西跟逻辑性的东西分离,但是具体操作起来还是有很大的不同的。

还是要对XML进行一些简单的介绍。XML定义的是一种标签语法,适合用来描述单亲多子的倒生树结构。树的每个节点是一个标签,标签由元素名和属性集合构成,下面是一个book和page的例子:

<book price="17" author="John Doe">
    <page number="1" chapter="Preface" />
    <page number="2" chapter="The Birth of John" />
</book>

其中book是一个元素的名字,而price和author则是这个元素的属性集合。值得注意的是,XML规范中只定义语法,而不定义元素及属性。因为XML不知道知道应用场景中需要什么样的元素及属性,book元素在书店场景下是有意义的,但是对于一个杂货店,就没有太大意义了。所以每种XML的应用要定义自己的标签集合。HTML就定义了许多标签,例如<a>或者<p>。虽然没有直接定义元素及属性,但是XML提供了一种机制叫做命名空间,用来管理标签集合。应用可以根据命名空间,来判断哪些元素及属性是有效的。作为XML的一种应用,XAML也使用了命名空间作为管理机制。例子如下:

<Page
    x:Class="Application1.BlankPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
>
...

上面涉及到了两个命令空间,一个用"xmlns="指定的默认不加前缀的命名空间,这个命名空间包含了WinRT对象,Page元素就是这个命名空间中定义的对象;另一个命名空间用"xmlns:x="指定(前缀为x:,也就是XAML语言本身的命名空间),x:Class这个作用在Page元素上的属性就来自于前缀为x:的命名空间。

XAML语言本身的命名空间

XAML语言本身的命名空间通常都是采用前缀x:,其中包含的常见元素有x:Key、x:Class、x:Name、x:Uid,以及一些XAML内置的初始数据类型:x:Boolean、x:String、x:Double、x:Int32。

说一下x:Class,这个属性是用来将XAML模块和其背靠代码(code behind)相关联起来的,就像前面的例子中的Page中指定了x:Class="Application1.BlankPage"

一个XAML标签元素对应着一个对象类型,比如上面例子中Page元素,其类型对应着WinRT中的Windows.UI.Xaml.Controls.Page。而Page上x:Class所指定的对象类型,必须是Page元素的子类型,也就是说Application1.BlankPage必须是Windows.UI.Xaml.Controls.Page中派生出来的。所谓的背靠代码,其实是含有自定义行为的XAML元素的派生类型。而这种派生关系必须在IDL中声明,然后序列化到DLL中,才能被XAML查询到。

另外要提到的是,XAML是按照文件来组织的,每个XAML文件都是由一个顶层的XAML元素,以及若干子孙类型构成,形成包含XAML对象的一颗子倒生树。一般情况下,只有顶层的XAML元素才指定x:Class。但是遗留一个问题,非顶层元素是否能够指定x:class属性呢?

旧链接:XAML背后的代码

XAML的标记扩展

如果XAML只是使用纯的XML的话,功能并不会非常强大,因为XML适合描述信息以及信息的层次,而不适合描述逻辑。所以XAML加上了一些标记扩展(Markup Extension),来增强其功能。

下面是一个参考自Programming Windows, 6th Edition的例子:

<Grid Background="{StaticResource ApplicationPageBackgroundThemeBrush}">
    <TextBlock Text="{StaticResource appName}" FontFamily="Portable User Interface" FontSize="48" HorizontalAlignment="Center" VerticalAlignment="Center" />
    <TextBlock Name="topTextBlock" Text="Top Text" HorizontalAlignment="Center" VerticalAlignment="Top">
        <TextBlock.Foreground>
            Yellow
        </TextBlock.Foreground>
    </TextBlock>
    <TextBlock Text="Left Text" HorizontalAlignment="Left" VerticalAlignment="Center" Foreground="{Binding ElementName=topTextBlock, Path=Foreground}" />
    <TextBlock Text="Right Text" HorizontalAlignment="Right" VerticalAlignment="Center" Foreground="{Binding ElementName=topTextBlock, Path=Foreground}" />
    <TextBlock Text="Bottom Text" HorizontalAlignment="Center" VerticalAlignment="Bottom">
        <TextBlock.Foreground>
            <Binding ElementName="topTextBlock" Path="Foreground" />
        </TextBlock.Foreground>
    </TextBlock>
</Grid>

上面的例子中使用到了两个标记扩展:

  • {StaticResource ……}
  • {Binding ……}

通常标记扩展都是在XML属性中使用,但是也可以以XML元素的方式使用,如上面例子中的:

        <TextBlock.Foreground>
            <Binding ElementName="topTextBlock" Path="Foreground" />
        </TextBlock.Foreground>

XAML的标记扩展其实不多(在XAML Overview: Markup extensions可以查看),出了上面出现的两个以外,还有:

  • {x:Bind}
  • {x:Null}
  • {ThemeResource}
  • {TemplateBinding}
  • {RelativeSource}
  • {CustomResource}

更多语法相关

更多语法相关可以参考XAML syntax guide。引用的这篇文章很长,大概是由于XAML并不是编程语言,所以语法上比较繁琐吧。有很多琐碎的点。比如{需要使用{}{来转义等待。

如何在资源绑定的时候通过多步访问资源的部属,在Property-path syntax中有相应的语法介绍。

XAML里面可以直接指定SVG的path,Move and draw commands syntax对其语法有所描述。

关于如何处理空白字符,在XAML and whitespace中有介绍。值得注意的一点,空白字符默认不保留。如果想要保留空白字符,则需要指定xml:space="preserve"

DependencyObject

前面说了,XAML是声明式,它声明一个XAML文件中有那些元素,并且元素上有哪些属性。它只是一个菜单系统,对菜品进行列举,并声明菜品中包含的食材。所以对于“菜品”这个概念,XAML需要有一个对应的类型才描述,这个类型就是DependencyObject。XAML中所有的元素,都是从DependencyObject派生出来的。

来看看前面的Page元素对应的WinRT对象的继承脉络:

  • Object
  • DependencyObject
  • UIElement
  • FrameworkElement
  • Control
  • UserControl
  • Page

因为WinRT的所有对象都是从IInspectable派生出来的,所以DependencyObject之上还有一个Object,其实对应的就是IInspectable。中间又隔了许多层对象类型,最后才是Page。

那DependencyObject有何特别之处?首先名字就很特别,让人有些摸不着头脑。翻译成中文叫做依赖对象怎么看都不是一个好懂的名字。我的理解是,DependencyObject的名字来源于DependencyProperty,DependencyObject是一堆DependencyProperty的集合。

<TextBlock Text="one" />为例子,TextBlock是一个DependencyObject,而Text就是其DependencyProperty。除了Text,TextBlock还可以有其他的DependencyProperty。

所谓DependencyProperty,理解起来,可以看做互相依赖的部属(这里把Property翻译成部属)。因为DependencyProperty中间可以互相依赖,一个DependencyProperty值的变化可以引起另外一个DependencyProperty值的变化。所以,似乎把DependencyProperty翻译成【相依部属】,然后把DependencyObject翻译成【相依对象】会比较合适一点。

相依对象的一个重要特点,它只能在UI线程上创建。如果对COM的线程模型有所了解的话,UI线程其实是一种特殊的ASTA(application single-threaded apartment)。把相依对象锁死在UI线程其实是一种设计上的考虑。单线程编程比多线程简单,这是其一;对于UI界面,适合采用单线程之上的单事件循环,如果采用多个事件循环,容易乱套。

相依对象的构造函数是Protected,只能在派生类中调用,所以无法直接初始化一个相依对象。相依对象的Dispatcher函数可以让其他线程提交代码到相依对象所在的UI线程来执行,因为相依对象的值只能在UI线程修改。

相依对象所具有的方法,都是用来操作相依部属的。比如GetValue,SetValue,ClearValue等等。

一个例子

有了相依对象,XAML解释器就可以笼统地把所有元素看成相依对象。比如:

<TextBlock x:Name="txtblk" FontSize="48"> Hello </TextBlock>

XAML解释器并不需要知道TextBlock元素地具体类型,XAML只要知道TextBlock是一个相依对象,然后具有FontSize和Content两个相依部属。把FontSize设为48,Content设为Hello就行了。生成地代码可以是:

txtblk.SetValue(TextBlock.FontSizeProperty, 48)

对于XAML解释器来说,上面的代码一定是可以成功,因为给相应对象设置某个相依部属的值,是保证可以成功的操作。如果XAML解释器生成的代码是这样的:

txtblk.FontSize = 48

这个反而存在一些问题,比如TextBlock上没有FontSize这个属性怎么办?让XAML解释器去关心这个问题会让其变得过于静态,就无法实现Attached DependencyProperty,Style以及Template这些特性了。

DepenencyProperties

相依部属其实本身也是一个对象,它的任务是作为一个键,让相依对象能够设置对应的值。可以这样想象,每个相依对象里面保存着的是一个以相依部属为键的字典。

下面是在C Sharp中自定义相依属性的一个例子:

// IsSpinningProperty is the dependency property identifier
// no need for info in the last PropertyMetadata parametere, so we pass null
public static readonly DependencyProperty IsSpinningProperty =
    DependencyProperty.Register(
        "IsSpinning", typeof(Boolean),
        typeof(ExampleClass), null
    );
// The property wrapper, so that callers can use this property through a simple ExampleClassInstance.IsSpinning usage rather than requiring property system APIs
public bool IsSpinning
{
    get { return (bool)GetValue(IsSpinningProperty); }
    set { SetValue(IsSpinningProperty, value); }
}

相依部属是作为静态属性存在的。不属于对象实例,访问这些相依部属对应的值的时候,需要提供包装器函数,就像是上面例子中的IsSpinning部属的定义中所作的设置一样。

注册相依部属的时候,需要提供一些信息:名字,类型,使用者类型,元数据,回调函数等待。

名字,类型,使用者类型这三者是必须要有的,其实也是字符串类型的,在C#/WinRT中使用typeof()来获取,在C++/Winrt中则需要使用winrt::xaml_typename;而元数据主要用来指定默认值;默认值可以从最后一个参数的回调函数中获取。

除了Register以外,还有RegisterAttached方法,可以注册一个附着的相依部属。也就是说可以在相应对象上设置一个相依部属,而这个相依部属的所属者并不是这个相依对象。使用场景可以看一个例子:

<Canvas>
  <Button Canvas.Left="50">Hello</Button>
</Canvas>

上面例子中在Button上设置了Canvas.Left这个相依部属的值。可是Canvas.Left并不属于Button,而是属于Canvas。实例执行的过程中,Canvas会去寻找子对象的Canvas.Left属性,并根据其做出相应的操作。

DepenencyProperties的求值

相依部属的一些好处在Why dependency properties?有所说明。总的来说,相依部属够成的是一个响应性系统,来推导一个部属的值。

相依部属之间可以通过注册回调到IPropertyChangedNotification来获取值的变更通知。

既然是求值系统,就可能从多处获取值的输入,那就想Dependency properties overview描述的那样对值优先级做一些判断:

  1. 动画过程中产生的值
  2. DP所在类的本地值
  3. 模板辖属指定的值
  4. 样式中Setter中指定的值
  5. 默认值

XAML事件处理

在XAML中发生的事件能够向上传导,所以把这类事件叫做Routed Events。Routed Events传导的时候通常包含两个参数,一个是事件触发对象,另一个是事件相关的参数。

RoutedEvent是定义在Windows.UI.Xaml命名空间下的,可以作为参数传入UIElement.AddHandler

比如对于KeyDown或者KeyUp事件,事件相关的参数类型是KeyRoutedEventArgs。其中的Key部属带有具体的按键信息。有一些事件参数中带有Handled部属。将Handled设置为true可以阻止事件继续传导。但是这只只是一种约定,在注册事件的时候添加额外的参数,依然可以捕获Handled为true的事件。

所有的Routed Events相关的参数都从RoutedEventArgs派生出来。RoutedEventArgs只有一个部属,那就是OriginalSource,用来描述事件的源头。

在事件触发过程中,有几个因素会影响事件的传播:

  • 有些XAML元素如果没有background的话,是不处理事件的,比如Grid,参考[Hit testing and input events](Hit testing and input events)
  • 元素的HorizontalAlignment和VerticalAlignment如果不设置的话,默认是stretch状态,占满所有可用的空间,会比肉眼看起占有的空间要大,会吸收所占空间内的所有事件,导致意外。

最后小结一下,Winrt中支持的事件类型并不多,只有:

  • 若干种Pointer开头的用于表示触控、鼠标、手写笔的事件
  • 若干种Manipulation开头的触摸板手势动作
  • 两种键盘事件
  • 若干种抽象事件,比如Tapped、DoubleTapped、RightTapped、Holding等等

在有些控件里面,事件被用来触发Commanding。这种情况下,最好不要干扰其事件处理。

WinRT允许创建自定义事件,但是无法创建自定义的Routed Events。如果在C++/WinRT定义Events,参考Author events in C++/WinRT

(本篇完)

2020-08-10更新

XAML namescopes

XAML中可以通过x:Name或者FrameworkElement.Name给定义的元素命名。既然是命名,那么就有作用范围。就像编程语言中的变量名是有作用域的一样。XAML元素命名的作用域,其实就是InitializeComponent所在对象的成员定义范围,命名的元素也是在InitializeComponent所在对象内作为部属来提供的。

通过XamlReader.Load加载XAML内容的时候,会生成一颗局部对象树,有自己的命名作用域。这棵树默认是和界面的全局对象树没有连接,把生成的局部对象树和界面的全局对象树连接上之后,但是他们的命名作用域依然是分离的。也就是说在全局对象树的节点上调用FindName是无法查找到局部对象树的节点的。只有在局部对象树的节点上调用FindName才行。但是依然可以通过Parent.Child关系或者使用VisualTreeHelper来帮助遍历对象树。

模板有自己的命名作用域,不会与挂接的作用域冲突。下面模板中定义的MyTextBlock不会出现在Page的命名作用域中:

<Page
  xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"  >
  <Page.Resources>
    <ControlTemplate x:Key="MyTemplate">
      ....
      <TextBlock x:Name="MyTextBlock" />
    </ControlTemplate>
  </Page.Resources>
  <StackPanel>
    <SomeControl Template="{StaticResource MyTemplate}" />
    <SomeControl Template="{StaticResource MyTemplate}" />
  </StackPanel>
</Page>

模板中的具名对象,可以通过在应用该模板的元素对象上调用GetTemplateChild来获取。GetTemplateChild是受保护的方法,只能在Control的派生类中使用。

(更新完)