[转载]让人迷恋的 WPF 数据绑定

绑定是要点所在
绑定是要点所在

更好的绑定
更好的绑定

绑定到复数数据
绑定到复数数据

自定义数据绑定样式
自定义数据绑定样式

跟踪集合更改
跟踪集合更改

我们所处的位置
我们所处的位置

“任何使用 Avalon 的人如果不使用数据绑定,一定会发疯。”

–Mark Boulter 2004 年 6 月 2 日

我热爱我选择的生活方式,因为我花费一大部分时间来进行学习。当我学习新东西时,我从来不会对大脑中突然蹦出的“灵感”感到厌烦。最近我的大脑中就出现过这样一个灵感,它促使我从根本上重新考虑我编写用户界面的方法。下面是一个表明我原来做法的简单示例:

class MyForm : Form {
  Game game1;
  StatusBar statusBar1;
  ...
  void InitializeComponent() {
    ...
    this.game1.ScoreChanged +=
      new EventHandler(this.game1_ScoreChanged);
    ...
  }

  void game1_ScoreChanged(object sender, EventArgs e) {
    this.statusBar1.Text = "Score: " + this.game1.Score;
  }
}

在上述代码中,我拥有一个窗口,其中含有一个类型为 Game 的自定义组件,该组件具有一个 Score 属性,当该属性更改时将引发 ScoreChanged 事件。代码将捕获该事件,使用新的数据格式化一个字符串,然后在状态栏中显示它。这很不错,因为组件不必知道有关谁在侦听其属性更改的任何信息,同时窗口可以对数据执行它喜欢的任何操作。

绑定是要点所在

在 Windows 窗体中,我可以将此向前推进一步,即使用数据绑定将 Score 更改通知直接挂钩到状态栏控件:

class MyForm : Form {
  Game game1;
  StatusBar statusBar1;
  ...
  public MyForm() {
    ...
    statusBar1.DataBindings.Add("Text", game1, "Score");
  }
  ...
}

在该例中,由于 ScoreChanged 事件所使用的命名约定 (Changed),Windows 窗体可以及时注意到 Score 属性的更改并直接设置状态栏控件的 Text 属性。然而,该方案中缺少的是将数据与“Score:”前缀进行合成的机会。要获得该功能,我们必须处理 Binding 对象上的 Format 事件,该事件是通过调用 DataBindings 集合的 Add 方法创建的:

public MyForm() {
  ...
  Binding binding =
    statusBar1.DataBindings.Add("Text", game1, "Score");
  binding.Format +=
    new ConvertEventHandler(binding_Format);
}

void binding_Format(object sender, ConvertEventArgs e) {
  e.Value = "Score: " + e.Value.ToString();
}

此刻,我们已经将 Game 对象的 Score 属性组合为我们所需要的字符串,但是我们将三行代码分布到两个方法中,使其变得有一点儿难以理解。另一方面,我发现 Avalon 的数据绑定语法更为简洁一些,尤其是在使用 XAML 时:

<!-- MyWindow.xaml -->
<Window ... Loaded="MyWindow_Loaded" >
  <FlowPanel>
    <Text TextContent="Score: " />
    <Text TextContent="*Bind(Path=Score)" />
  </FlowPanel>
</Window>

在该 XAML 数据中,请注意我用 XAML 并通过 FlowPanel 声明了一个状态栏(您可以重新阅读我的上一篇文章以复习一下 FlowPanel — 它可将任意数量的不同种类的内容汇聚到一起)。FlowPanel 将两段文本汇聚到一起 — 一个固定的字符串和一个可变的分数 — 就像上述 Windows 窗体示例一样。不同之处在于,我不是编写一段命令式的代码来创建状态栏的完整内容,而是将文本段声明为 UI 本身的一部分。可变文本来自使用 Score 属性的路径 进行的绑定。Avalon 中的绑定是一块 UI 到一块数据的映射。在该例中,即设置我们要定义的 Text 对象的 TextContent 属性。路径是有关如何获取数据的说明。在该例中,即 Score 属性。

如果该 *Bind 语法在您看起来很奇怪,您应该知道 XAML 在设计时考虑了手动创作,因此所生成的语法有助于节省击键操作(这是 XML 大体上不具有的特点)。如果您愿意使用更加繁琐的方法,可以使用 XAML 的复合属性语法 来创建 Bind 对象。通过复合属性语法,可以使用点分名称将属性设置为元素,从而将父元素名和属性名组合为它自己的元素,如下所示:

<Window ... Loaded="MyWindow_Loaded" >
  ...
  <Text TextContent="Score: " />
  <Text>
    <Text.TextContent>      <!-- compound property syntax -->
      <Bind Path="Score" /> <!-- expanded Bind syntax -->
    </Text.TextContent>
  </Text>
</Window>

因此,我们将对象的 Score 属性绑定到 Text 控件的 TextContent 属性,但是具有 Score 属性的对象来自何处?该对象通过 DataContext 属性进行设置:

partial class MyWindow : Window {
  void MyWindow_Loaded(object sender, EventArgs e) {
    Game game = ((SolApp)SolApp.Current).Game;
    this.DataContext = game;
  }
  ...
}

数据绑定沿控件层次结构向上进行,因此当 Text 控件将其 TextContent 属性数据绑定到 Score 属性时,Avalon 会向上进行挖掘以查找有效的数据上下文。如果我希望缩小数据上下文的范围,我可以设置该特定 Text 控件的 DataContext 属性。我选择了窗口的数据上下文,以防我可能希望让其他控件绑定到 Game 对象的其他属性(就像计时游戏的 Time 属性一样)。

使得我的大脑中迸发这一灵感的事情是,在我原来的思维方式中,我具有三个部分:数据、UI 以及二者之间的映射代码。在新的方法中,我只有数据和 UI,而不必编写任何映射代码。与原来不同的是,UI 本身能够决定它要显示数据的哪些部分以及如何显示。这将具有非常重要的意义。

返回页首返回页首

更好的绑定

绑定到单个对象上的单个属性是很有趣的,但是让我们尝试某种稍微复杂一点儿的做法。例如,设想有一个类,它具有两个公共的读写属性:

public class Person {
  string name;
  public string Name {
    get { return this.name; }
    set { this.name = value; }
  }

  int age;
  public int Age {
    get { return this.Age; }
    set { this.Age = value; }
  }

  public Person(string name, int age) {
    this.name = name;
    this.age = age;
  }
}

注意,如果我们采取捷径,使 NameAge 成为公共字段以便简化代码,则 Avalon 不会绑定到它们。Avalon 只会绑定到公共属性。绑定到 Person 对象的一个实例时,将如下所示:

<!-- Window1.xaml -->
<Window ... >
    <GridPanel Columns="2">
      <Text>Name</Text>
      <TextBox Text="*Bind(Path=Name)"/>
      <Text>Age</Text>
      <TextBox Text="*Bind(Path=Age)"/>
      <Border />
      <Button ID="showButton">Show</Button>
      <Border />
      <Button ID="birthdayButton">Birthday</Button>
    </GridPanel>
</Window>

// Window1.xaml.cs
...
partial class Window1 : Window {
  Person person = new Person("John", 10);

  void Window1_Loaded(object sender, EventArgs e) {
    this.DataContext = this.person;
    showButton.Click += showButton_Click;
    birthdayButton.Click += birthdayButton_Click;
  }

  void showButton_Click(object sender, ClickEventArgs e) {
    MessageBox.Show(
      string.Format(
        "Person= {0}, age {1}",
        this.person.Name, this.person.Age));
  }

  void birthdayButton_Click(object sender, ClickEventArgs e) {
    ++this.person.Age;
  }
}

运行该应用程序并按 Show 按钮时,将产生意料之中的图 1。

alt

1. 显示被数据绑定的 Person 对象

同样,因为我们不仅从对象中读取数据,而且还允许写入。更改年龄文本框,并按 Show 按钮以显示我们已经将 TextBox 控件绑定到的 Person 对象的当前状态时,将展现图 2。

alt

2. 显示更新后的 Person 对象

图 2 显示我们正在两个方向进行绑定。即,从数据到文本框。而随着文本框的变化,发生的更改将被复制回基础对象。

然而,就 Person 类的当前实现而言,尽管我们的 Birthday 按钮实现更改了基础对象,但它将不会导致 UI 更新。换句话说,连续按 BirthdayShow 按钮将导致如图 3 所示的差异。

alt

3. 显示未正确启用双向数据绑定的已更新 Person 对象

问题在于,尽管 Avalon 数据绑定引擎可以监控 UI 更改并更新基础对象数据,但该对象本身在其数据被直接更改时并不会引发任何事件。那么,我们该怎么办呢?要使 Avalon 跟踪 Person 类实例上发生的更改,它需要实现 IPropertyChange 接口:

namespace System.ComponentModel {
  public interface IPropertyChange {
    public event PropertyChangedEventHandler PropertyChanged;
  }
}

Updating our Person to support IPropertyChange looks like this:

class Person : IPropertyChange {
  public event PropertyChangedEventHandler PropertyChanged;
  void FirePropertyChanged(string propertyName) {
    if( this.PropertyChanged != null ) {
      PropertyChanged(this,
        new PropertyChangedEventArgs(propertyName));
    }
  }

  string name;
  public string Name {
    get { return this.name; }
    set {
      this.name = value;
      FirePropertyChanged("Name");
    }
  }

  int age;
  public int Age {
    get { return this.age; }
    set {
      this.age = value;
      FirePropertyChanged("Age");
    }
  }
  ...
}

当 Avalon 绑定到 Person 对象时,它将预订 PropertyChanged 事件,以便它能够在属性更改时更新绑定到这些属性的任何控件。在我们的 Person 类中,我们在任何属性更改时引发了该事件,以确保指定发生更改的属性的名称。通过这种方式,无论 UI 更改还是对象更改,这两者都能保持同步,而我们无须在两者之间编写代码以使事情恢复正常。

如果您熟悉支持 Windows 窗体数据绑定的 Changed 事件,则可以使用 Avalon 的 IPropertyChange 接口来取代该约定。因为所有属性更改通知都通过单个事件引发,所以 Avalon 的机制可能更为有效。然而,在当前版本中,Avalon 不能识别 Windows 窗体 Changed 事件,因此,已经实现这些事件的对象必须添加对 Avalon 的新方法的支持,该新方法提供了属性更改通知。

返回页首返回页首

绑定到复数数据

迄今为止,我已经向您说明了两个绑定到单个对象的示例。因为数据绑定与 XAML 之间存在紧密的集成,所以这种风格的绑定是自然和灵活的做法。但是,更为传统的绑定手段是绑定到一系列项目:

<!-- Window1.xaml -->
<Window ... >
    <GridPanel Columns="2">
      <Text>Persons</Text>
      <ListBox ItemsSource="*Bind()" />
      ...
    </GridPanel>
</Window>

// Window1.xaml.cs
...
public partial class Window1 : Window {
  ArrayList persons = new ArrayList();

  void Window1_Loaded(object sender, EventArgs e) {
    persons.Add(new Person("John", 10));
    persons.Add(new Person("Tom", 8));
    this.DataContext = this.persons;
    ...
  }
  ...
}

在该例中,我们已经将数据上下文设置为 Person 对象的数组列表。为了将 ListBox 控件绑定到该数据,对于 ItemsSource 属性我们只是使用 *Bind(),而未指定 Path,因为我们希望在各个项目中表示整个对象。默认情况下,将显示每个 Person 对象,如图 4 所示。

alt

4. 以令人不愉快的方式显示一系列 Person 对象

如果您熟悉 Windows 窗体数据绑定,您将会认识到显示的是每个对象的类型,而不是有意义的值。默认情况下,将调用 Person 类的 ToString 方法来获取每个对象的字符串表示,从而产生返回类型名的 Object 基类方法实现。

Windows 窗体提供了多种方法来解决该问题,范围涉及选择单个显示属性到覆盖 Person 类的 ToString 方法。Avalon 数据绑定倾向于另一种技术,即使用样式 来决定应该如何显示 Person 对象。

返回页首返回页首

自定义数据绑定样式

要定义 Avalon 中列表框项目的名称,我们不使用所有者绘制或自定义绘制,而是使用成分。列表框中的每个项目都是一个或多个 UI 元素的成分,并且根据各个项目的数据绑定值按需产生。进入各个项目的元素列表由 Avalon 样式 提供。您可以将样式视为充当元素及其属性的初始描述的模板或复印。

作为您可能希望对样式进行的处理的简单示例,请设想将每个按钮的文本设置为粗体。一种完成该任务的方法是设置每个 Button 元素的 FontWeight 属性:

<Window ... > 
  <Button FontWeight="Bold" ID="showButton">Show</Button>
  ...
  <Button FontWeight="Bold" ID="birthdayButton">Birthday</Button>
  ...
</Window>

当然,该方法的问题与所有复制-粘贴软件构建手段相同:可维护性。将每个按钮设置为粗体的一种更加健壮的方法是定义按钮的样式,例如:

<Window  
    xmlns="http://schemas.microsoft.com/2003/xaml"
    xmlns:def="Definition"
    def:Class="PersonBinding.Window1"
    def:CodeBehind="Window1.xaml.cs" 
    Text="PersonBinding"
    Loaded="Window1_Loaded"
    >
  <Window.Resources>
    <Style>
      <Button FontWeight="Bold" />
    </Style>
  </Window.Resources>
  ...
  <!-- this button will be bold -->
  <Button ID="showButton">Show</Button>
  ...
  <!-- this button will also be bold -->
  <Button ID="birthdayButton">Birthday</Button>
  ...
</Window>

这里,我们已经在按钮的包含窗口的 Resources 区域内部定义了一种样式。像数据上下文一样,样式也是按层次组织的,因此在创建一个按钮时,将遍历其父样式(以及更高层的样式)来查找要应用的样式。样式本身将充当模板,设置在该窗口中创建的所有按钮对象的 FontWeight 属性。

如果要进一步采用样式,可以向其赋予名称,并使用 def:Name 属性选择性地应用它们,如下所示:

<Window ... >
  <Window.Resources>
    <Style def:Name="BoldButton">
      <Button FontWeight="Bold" />
    </Style>
  </Window.Resources>
  ...
  <!-- this button will be bold -->
  <Button ID="showButton" Style="{BoldButton}">Show</Button>
  ...
  <!-- this button will not be bold -->
  <Button ID="birthdayButton">Birthday</Button>
  ...
</Window>

在该例中,按钮样式是相同的,但它被赋予了一个名称,该名称被应用于(使用特殊的大括号语法)我们希望将其变为粗体的按钮的 Style 属性。这只是 Avalon 样式的冰山一角(有关详细信息,请参见 Longhorn SDK),但对于要构建列表框样式的我们来说已经足够了:

<Window ... >
  <Window.Resources>
    <Style def:Name="PersonStyle">
      <Style.VisualTree>
        <FlowPanel>
          <Text TextContent="*Bind(Path=Name)" />
          <Text TextContent=":" />
          <Text TextContent="*Bind(Path=Age)" />
          <Text TextContent=" years old" />
        </FlowPanel>
      </Style.VisualTree>
    </Style>
  </Window.Resources>
  <GridPanel Columns="2">
    <Text>Persons</Text>
    <ListBox ItemStyle="{PersonStyle}" ItemsSource="*Bind()" />
    ...
  </GridPanel>
</Window>

注意 PersonStyle 样式,它由一组文本控件组成,其中一些控件带有使用常量字符串设置的文本内容,而另一些控件则使用数据绑定。在当前版本的 Longhorn 中,应用列表框项目样式的最简单方法是命名该样式并将其作为 ListBox 控件的 ItemStyle 属性进行应用。不必影响基础 Person 类,我们便可自定义 Person 对象的视图,如图 5 中所示。

alt

5. 以令人略感愉快的方式显示一系列 Person 对象

既然我们可以看到列表框中的项目,很明显文本框反映了当前的列表框选择。这由在列表框和文本框之间共享的视图进行管理。除了当前状态以外,该视图还管理筛选和排序。在本文章系列的下一篇文章中,将对此进行更为深入的讨论。

返回页首返回页首

跟踪集合更改

现在要讨论的另外一件事情是管理集合本身的更改。例如,如果我要在小示例应用程序中创建一个 Add 按钮,我可能选择按以下方式来实现它:

void addPersonButton_Click(object sender, ClickEventArgs e) {
  this.persons.Add(new Person("Chris", 34));
}

这里的问题是我们的数据绑定控件根本不会意识到这一更改。就像数据绑定对象 需要实现 IPropertyChange 接口一样,数据绑定列表 需要实现 ICollectionChange 接口:

namespace System.ComponentModel {
  public interface ICollectionChange {
    public event CollectionChangeEventHandler CollectionChanged;
  }
}

ICollectionChange 接口用于通知数据绑定控件已经在绑定列表中添加或删除了项目。尽管常见的做法是在自定义类型中实现 IPropertyChange,以支持在类型属性上进行双向数据绑定,但除非您要实现自己的集合类,否则您不必实现 ICollectionChange 接口。相反,您很可能依赖于 .NET 框架类库中的一个集合类来为您实现 ICollectionChange。遗憾的是,目前只有极少数类实现了 ICollectionChange,而我们要用来存放 Person 对象的类 (ArrayList) 不属于这些类。幸亏 Avalon 提供了 ArrayListDataCollection 类专门用于此目的:

namespace System.Windows.Data {
  public class ArrayListDataCollection :
    ArrayList, ICollectionChange, ... {...}
}

因为 ArrayListDataCollection 类派生于 ArrayList 并且实现了 ICollectionChange 接口,所以每当需要支持数据绑定的 ArrayList 时,都可以使用它。

partial class Window1 : Window {
  ArrayListDataCollection persons = new ArrayListDataCollection();

  void Window1_Loaded(object sender, EventArgs e) {
    persons.Add(new Person("John", 10));
    persons.Add(new Person("Tom", 8));
    this.DataContext = this.persons;
    ...
  }
  ...
}

现在,当从 persons 列表中删除一个项目时,相应的更改将反映在数据绑定控件中。

令人鼓舞的是,尽管 Avalon 数据绑定不像 Windows 窗体那样支持 Changed 约定,但与 Windows 窗体数据绑定接口 ICollectionChange 等效的 IBindingList 接口受到 Avalon 的支持。还有一个额外的好处 — 因为 IBindingList 提供了 ICollectionChangeIPropertyChange 的功能,所以任何目前接通 Windows 窗体数据绑定的数据源对于 Avalon 数据绑定也将完全有效(这包括 ADO.NET 中的 DataTable 对象等)。

返回页首返回页首

我们所处的位置

我从讨论游戏和分数以及它们如何将我的思想导向 Avalon 中的数据绑定开始。我们一开始讨论了绑定到对象的基础知识,以及如何使用 IPropertyChange 在对象和文本框控件之间实现双向更改通知。我向您介绍了简洁的、扩展的绑定语法,并且随后继续讨论了如何绑定到数据列表,如何设置列表项的样式以及如何使用 ICollectionChange 跟踪双向列表更改。

正如文中所表明的那样,对于 Avalon 中的数据绑定有大量相关内容。在下一期中,我将讨论其他数据绑定主题(如用于高级数据样式设置的转换器和样式选择器、自定义视图和筛选器),并且插入一些拖放操作来推进我的 solitaire 实现,如图 6 所示。

alt

6. 我的 Solitaire 应用程序被更新为使用数据绑定

图 6 中显示的所有数据都是使用数据绑定实现的,包括全部七堆纸牌和分数。而且正如 Mark 所说的,如果我用其他任何方式实现它,那我一定会发疯。

致谢

首先必须感谢 Namita Gupta 和 David Jenni — Avalon 数据绑定的项目经理和首席开发人员。Namita 不仅在 PDC 极为成功的发表了有关 Avalon 数据绑定的讲话,而且她和 David 还非常出色地回答了我的数据绑定问题,并帮助我处理了当前版本中存在的问题。

还要感谢 Lutz Roeder 提供了在 Longhorn 上运行的 Reflector 版本。它是一个非常宝贵的工具,用于填充尚未记录的详细信息。坦白地说,如果没有这一工具,我不知道现在从事 Longhorn 开发的人们将如何生存。谢谢你,Lutz!

参考资料

Avalon Data Binding in the Longhorn SDK

posted on 2011-01-14 13:11  guoxuefeng  阅读(720)  评论(0编辑  收藏  举报