65.9K
CodeProject 正在变化。 阅读更多。
Home

数据绑定深度解析

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.79/5 (10投票s)

2009年10月11日

CPOL

23分钟阅读

viewsIcon

51935

downloadIcon

674

WPF 和 Silverlight 中数据绑定的精选高级主题。

引言

在过去的两年里,我有幸在各种会议(例如 BASTA、dot.net 科隆、Microsoft BigDays 等)上就 WPF 和 Silverlight 中的数据绑定发表演讲。我还曾在德国的 dot.net 杂志上撰写过两篇文章。这些会议演讲的目标是让新手了解何时以及如何在他们的 WPF 和/或 Silverlight 应用程序中使用数据绑定来编写更少的代码,构建更健壮的应用程序,并开发更易于维护的解决方案。

下周,我将再次参加会议。在维也纳的 DEVcamp 上,我的其中一场演讲的主题仍将是数据绑定。然而,这一次,这个会话不是针对刚开始使用 WPF 和 Silverlight 进行开发的人员;在 DEVcamp 上,我想展示更高级的主题。我的会话将仅包含代码,因此我正在撰写本文。参与者可以重新阅读我将要提出的观点,也许内容对没有机会参加 DEVcamp 的其他读者也很有趣。

接下来几段我将介绍的内容

  1. MVVM:模型-视图-视图模型:当我们使用数据绑定时,这种架构模式为何重要?
  2. 从 WPF 到 Silverlight:WPF 和 Silverlight 在数据绑定方面有哪些区别?
  3. 转换器是王道!为什么转换器对 WPF,尤其是 Silverlight 中的数据绑定如此重要?
  4. XAML 中的脚本编写:如何在 XAML 数据绑定中利用 DLR?
  5. 命令绑定:如何使视图模型层中的方法可绑定?
  6. 异步数据绑定:何时何地以及为何我们需要异步数据绑定,尤其是在 Silverlight 中?
  7. 最后,应大家要求 - RelativeSource 绑定:很多人都为此头疼的问题...

MVVM:模型-视图-视图模型

如前所述,本文假设您对 WPF 和/或 Silverlight 有一定的了解。如果您有,我很确定您听说过 MVVM(或者与 MVVM 密切相关的 MVC 或 MVP;更多关于 MVVM 的信息,例如,请参阅 维基百科)。MVVM 的基本思想是在视图和模型之间引入一个层,作为两者之间的桥梁。为什么我们需要一座桥梁?WPF/Silverlight 数据绑定是否不够强大,无法直接处理模型层中的类?事实证明,它不够强大。在 XAML 中实现的视图层应该描述用户界面结构和行为(这就是我们将 XAML 称为声明性语言的原因;更多关于声明性编程的信息,例如,请参阅 维基百科)。它不应该包含业务逻辑。现在,考虑以下情况

  • 您的模型包含一个名为 IsFemaleboolean 类型属性。
  • 在您的 UI 中,如果一个人是男性,您希望隐藏窗口的特定部分。在 WPF/Silverlight 中,隐藏意味着将相应 UIElement 实例的 Visibility 属性设置为 Collapsed
  • 谁负责将布尔值转换为 CollapsedVisible

您真的不想在模型层中添加 Visibility 类型的属性,对吗?另一方面,XAML 不提供类似 IFIIF 的东西,因此您也无法在那里进行映射。这就是视图模型层发挥作用的地方。它在视图和模型之间搭建了桥梁——在我们的例子中,通过提供一个从 booleanVisibility 的转换器。如果您有 WPF/Silverlight 的经验,我很确定您已经熟悉转换器了。现在,您应该知道您的转换器如何融入 MVVM 模式。稍后我们将看到许多关于转换器的代码示例。

我们在 WPF,尤其是 Silverlight 中使用 MVVM 的另一个原因是将显示逻辑从视图层分离到视图模型层。这样做的原因主要是可测试性。正如您可能知道的,测试视图层相当困难。测试不带用户界面的类库要容易得多。因此,将尽可能多的逻辑移动到视图模型中以使其更易于测试是有意义的。

我想用一个小例子来演示我的意思。我们的目标是实现一个显示某个月份日期的日历

CalendarStep1.png

在我们的示例中,模型层非常简单;它只包含 .NET Framework 的 DateTime 类,因为这就是我们想要处理的:仅仅是日期。所以,让我们首先考虑是否需要在视图模型层中包装 DateTime 类。如上图所示,日历显示了该月份的所有周。在第一周和最后一周,可能有一些日期不属于当前选定的月份。但是,假设我们的规范要求我们也显示它们。在示例的后续步骤中,我们的目标是让它们与那些真正属于选定月份的日期看起来不同。这就是视图模型变得重要的地方。稍后,我们需要每天一个指示器,通过它我们可以判断日期是否应该以不同的方式显示。很明显,我们不能将这个指示器添加到我们的模型中,因为它只对 UI 重要。一个解决方案是在我们的模型(即 DateTime)周围添加一个包装类,并带有一个额外的属性

using System;

namespace ViewModelLibrary.CalendarViewModel
{
 public sealed class CalendarDay
 {
  public CalendarDay(DateTime day, int currentYear, int currentMonth)
  {
   this.Day = day;
   this.IsInCurrentMonth = day.Year == currentYear && day.Month == currentMonth;
  }

  public DateTime Day { get; private set; }

  public bool IsInCurrentMonth { get; private set; }

  public bool IsSunday
  {
   get
   {
    return this.Day.DayOfWeek == DayOfWeek.Sunday;
   }
  }
 }
}

解决我们问题的下一步是为 UI 提供一个可绑定的日期列表。仅仅添加一个类(例如 Calendar)和一个属性(例如 List)是否足够呢?嗯,我们可以这样实现。但是,如果我们这样做,就会给 UI 开发人员带来一个相当棘手的问题。他将不得不找出一种方法将日期列表分成几周——这在 XAML 中是不可能做到的!因此,视图模型层提供一个周列表,然后该列表提供相应周的日期列表,这是一个更好的解决方案。请注意,UI 的结构再次影响了视图模型层的类设计。这在 MVVM 中是相当典型的。

这是我们的 Week 类可能的实现方式

using System;
using System.Collections.Generic;

namespace ViewModelLibrary.CalendarViewModel
{
 public sealed class Week
 {
  public Week(DateTime anyDayInWeek, int currentYear, int currentMonth)
  {
   var result = new List<CalendarDay>();
   var currentDate = anyDayInWeek.AddDays(((
                      (int)anyDayInWeek.DayOfWeek) + 6) % 7 * (-1));
   for ( int i=0; i<7; i++ )
   {
    result.Add(new CalendarDay(currentDate, currentYear, currentMonth));
    currentDate = currentDate.AddDays(1);
   }
   this.Days = result;
  }

  public List<CalendarDay> Days { get; private set; }
 }
}

最后但同样重要的是,我们需要 Calendar 类。它是视图层将绑定到的核心视图模型类。与之前开发的 DayWeek 类的重要区别在于,Calendar 不是不可变的(有关不可变对象的更多信息,例如,请参阅 维基百科)。相反,它反映了用户可以在 UI 中执行的操作。在我们的例子中,用户稍后将能够使用两个按钮(下个月,上个月)在日历视图中导航日期。因此,我们的 Calendar 类提供了两个属性

  • CurrentMonth - 当前选中月份的任何一天(通常是第一天)
  • Weeks - 当前选中月份的周列表,需要在 UI 中显示

如果您查看 Calendar 的以下实现,请注意,只要 CurrentMonth 发生变化,Weeks 就会自动更改。UI 通过 INotifyPropertyChanged 收到属性更改的通知(我将不详细介绍此接口,因为我假设您已经听说过它;如果没有,您可以在 MSDN 中找到更多详细信息)。

using System;
using System.Collections.Generic;
using System.ComponentModel;

namespace ViewModelLibrary.CalendarViewModel
{
 public sealed class Calendar : INotifyPropertyChanged
 {
  public Calendar()
  {
   this.PropertyChanged += 
     new PropertyChangedEventHandler(OnCalendarPropertyChanged);
   this.CurrentMonth = new DateTime(2009, 10, 13);
  }

  private void OnCalendarPropertyChanged(object sender, 
               PropertyChangedEventArgs e)
  {
   if (e.PropertyName == "CurrentMonth")
   {
    var result = new List<Week>();
    DateTime currentDate;
    currentDate = (currentDate = this.CurrentMonth.AddDays((-1) * 
     (this.CurrentMonth.Day - 1)) - this.CurrentMonth.TimeOfDay).AddDays(
     (((int)currentDate.DayOfWeek) + 6) % 7 * (-1));

    while (currentDate.Month == this.CurrentMonth.Month ||
     currentDate.AddDays(6).Month == this.CurrentMonth.Month)
    {
     result.Add(new Week(currentDate, this.CurrentMonth.Year, 
                         this.CurrentMonth.Month));
     currentDate = currentDate.AddDays(7);
    }

    this.Weeks = result;
   }
  }

  private DateTime currentMonth;
  public DateTime CurrentMonth
  {
   get { return this.currentMonth; }
   set
   {
    if (this.currentMonth != value)
    {
     this.currentMonth = value;
     this.OnPropertyChanged("CurrentMonth");
    }
   }
  }

  private IList<Week> weeks;
  public IList<Week> Weeks
  {
   get { return this.weeks; }
   private set
   {
    this.weeks = value;
    this.OnPropertyChanged("Weeks");
   }
  }

  #region INotifyPropertyChanged
  private void OnPropertyChanged(string propName)
  {
   if (this.PropertyChanged != null)
   {
    this.PropertyChanged(this, new PropertyChangedEventArgs(propName));
   }
  }
  public event PropertyChangedEventHandler PropertyChanged;
  #endregion
 }
}

ViewModel.png

现在我们的视图模型层已准备好使用。在我们继续讨论视图层之前,请注意,我们的视图模型层不依赖于 WPF 或 Silverlight。它普遍可用!此外,它非常易于测试。您可以编写一个简单的单元测试,通过仅测试 Calendar 类的方法来检查应用程序是否正确响应当前选定月份的更改。无需复杂的 UI 自动化魔法!

视图层使用数据绑定通过视图模型层将不同的 UI 元素与模型类连接起来。在我向您展示 XAML 代码之前,请允许我提请您注意以下几个重要点

  • 这里我没有使用样式来格式化示例 UI。这样做只是为了简单起见;在实际应用中,您应该将 UI 样式与其结构分开(有关详细信息,请参阅 _app.xaml_、主题等)。
  • 请注意,在 Binding 表达式中,您可以在一定程度上对数字、日期和时间值进行字符串格式化,而无需编写自己的转换器。我的示例展示了如何使用 BindingStringFormat 属性来完成此操作:<TextBlock [...] Text="{Binding Path=CurrentMonth, StringFormat={}{0:MMMM yyyy}}"/>
<Window x:Class="DataBindingUI.Window1"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:vm="clr-namespace:ViewModelLibrary.CalendarViewModel;assembly=ViewModelLibrary"
    Title="Data Binding Sample" 
 MinHeight="300" MinWidth="300" Background="Black" SizeToContent="WidthAndHeight">
 <Window.Resources>
  <DataTemplate DataType="{x:Type vm:Week}">
   <ItemsControl ItemsSource="{Binding Path=Days}">
    <ItemsControl.ItemsPanel>
     <ItemsPanelTemplate>
      <StackPanel Orientation="Horizontal" />
     </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
   </ItemsControl>
  </DataTemplate>
  
  <DataTemplate DataType="{x:Type vm:CalendarDay}">
   <Border Background="DarkGray" Margin="5" CornerRadius="5" BorderThickness="2">
    <TextBlock Text="{Binding Path=Day, StringFormat={}{0:dd}}" Margin="10" />
   </Border>
  </DataTemplate>
 </Window.Resources>
 
 <Grid Margin="5">
  <Grid.ColumnDefinitions>
   <ColumnDefinition Width="Auto" />
   <ColumnDefinition Width="*" />
   <ColumnDefinition Width="Auto" />
  </Grid.ColumnDefinitions>
  <Grid.RowDefinitions>
   <RowDefinition Height="Auto" />
   <RowDefinition Height="*" />
  </Grid.RowDefinitions>
  
  <Button Content="Prev." />
  
  <TextBlock Grid.Column="1"
       Text="{Binding Path=CurrentMonth, StringFormat={}{0:MMMM yyyy}}" 
       Foreground="White" HorizontalAlignment="Center" />
  
  <Button Grid.Column="2" Content="Next" />
  <Border Grid.Row="1" Grid.ColumnSpan="3" Margin="0,5,0,0"
    BorderBrush="LightGray" CornerRadius="5" BorderThickness="2"
    Background="White">
   <ItemsControl ItemsSource="{Binding Path=Weeks}" />
  </Border>
 </Grid>
</Window>

还缺少最后一部分:XAML 的代码隐藏文件。如果您使用 MVVM,通常您的代码隐藏文件几乎是空的。您在我们的示例中也看到了这一点

using System.Windows;
using ViewModelLibrary.CalendarViewModel;

namespace DataBindingUI
{
 public partial class Window1 : Window
 {
  private Calendar viewModel;

  public Window1()
  {
   InitializeComponent();
   this.DataContext = this.viewModel = new Calendar();
  }
 }
}

从 WPF 到 Silverlight - 转换器是王道!

WPF 和 Silverlight 相似,这一点毋庸置疑。但是,如果您尝试为应用程序的 WPF 和 Silverlight 版本使用单一代码库,您将遇到许多障碍。很快,您会意识到 WPF 和 Silverlight 之间许多细微的差异会让您抓狂;数据绑定也不例外。让我们尝试将上面的现有示例迁移到 Silverlight。第一步是视图模型。在大多数情况下,如果您遵循一个简单的准则,您可以在 WPF 之间共享您的视图模型实现:每当您访问网络(例如,调用 Web 服务)时,请将您的调用设置为异步!原因是 Silverlight 只能异步访问网络。

但是,让我们回到我们的案例。到目前为止,我们还没有使用任何 Web 服务。因此,我们可以不加任何更改地使用我们的视图模型。但是,我们不能将我们为 Silverlight 编写的 XAML 文件用于以下原因

  1. Silverlight 不支持为 DataTemplate 分配 DataType
  2. Silverlight 的 Binding 类不知道 StringFormat

第一个问题很容易解决。我们必须从模板中删除 DataType 属性,并使用其键引用模板(我将不在此处详细介绍,因为它与数据绑定关系不大)。

第二个问题更棘手。它是 Silverlight 中数据绑定的一个典型症状:您需要编写许多许多转换器。Silverlight 中的数据绑定机制远不如 WPF 中的强大。您必须通过使用转换器来自己解决这些弱点。我们案例中的转换器——目前为止——相当简单(请注意,这里的实现非常粗糙,没有错误处理等)

using System;
using System.Globalization;
using System.Windows.Data;

namespace ViewModelLibrary.CalendarViewModel
{
 public class DateTimeToStringConverter : IValueConverter
 {
  public object Convert(object value, Type targetType, 
                        object parameter, CultureInfo culture)
  {
   return ((DateTime)value).ToString((string)parameter, culture);
  }

  public object ConvertBack(object value, Type targetType, 
                object parameter, CultureInfo culture)
  {
   throw new System.NotImplementedException();
  }
 }
}

有了这个视图模型层中的转换器,我们就可以为我们的日历的 Silverlight 版本编写 XAML 了

<UserControl x:Class="DataBindingSilverlightUI.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" 
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
 xmlns:vm="clr-namespace:ViewModelLibrary.CalendarViewModel"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
    mc:Ignorable="d" d:DesignWidth="640" d:DesignHeight="480">
 <UserControl.Resources>
  <vm:DateTimeToStringConverter x:Key="DateTimeToStringConverter" />
  <DataTemplate x:Key="DayTemplate">
   <Border Background="DarkGray" Margin="5" CornerRadius="5" BorderThickness="2">
    <TextBlock Text="{Binding Path=Day, Converter={StaticResource 
                        DateTimeToStringConverter}, ConverterParameter=dd}" 
               Margin="10" />
   </Border>
  </DataTemplate>
  <DataTemplate x:Key="WeekTemplate">
   <ItemsControl ItemsSource="{Binding Path=Days}" 
           ItemTemplate="{StaticResource DayTemplate}">
    <ItemsControl.ItemsPanel>
     <ItemsPanelTemplate>
      <StackPanel Orientation="Horizontal" />
     </ItemsPanelTemplate>
    </ItemsControl.ItemsPanel>
   </ItemsControl>
  </DataTemplate>
 </UserControl.Resources>
 <Grid Background="Black">
  <Grid Margin="5">
   <Grid.ColumnDefinitions>
    <ColumnDefinition Width="Auto" />
    <ColumnDefinition Width="*" />
    <ColumnDefinition Width="Auto" />
   </Grid.ColumnDefinitions>
   <Grid.RowDefinitions>
    <RowDefinition Height="Auto" />
    <RowDefinition Height="*" />
   </Grid.RowDefinitions>
   <Button Content="Prev." />
   <TextBlock Grid.Column="1"
       Text="{Binding Path=CurrentMonth, Converter={StaticResource 
             DateTimeToStringConverter}, ConverterParameter='MMMM yyyy'}" 
       Foreground="White" HorizontalAlignment="Center" />
   <Button Grid.Column="2" Content="Next" />
   <Border Grid.Row="1" Grid.ColumnSpan="3" Margin="0,5,0,0"
    BorderBrush="LightGray" CornerRadius="5" BorderThickness="2"
    Background="White">
    <ItemsControl ItemsSource="{Binding Path=Weeks}"
        ItemTemplate="{StaticResource WeekTemplate}"/>
   </Border>
  </Grid>
 </Grid>
</UserControl>

CalendarStep2.png

我们不能先编写 Silverlight 版本的 XAML,然后将此代码用于 WPF 吗?在这个简单的例子中我们可以。在实践中,你会发现很多很多情况下这是不可能的。我们团队的经验是,您最终会编写两次 XAML,一次用于 Silverlight,一次用于 WPF。但是,请记住,共享一个视图模型层非常容易!

我想通过进一步扩展我们的示例来演示在 Silverlight 中使用更复杂的转换器。正如之前所承诺的,我们将以不同的方式显示那些不在选中月份中的日期

CalendarStep3.png

在 WPF 中,这真的小菜一碟。只需向现有数据模板添加一个数据触发器,一切就完成了

<DataTemplate DataType="{x:Type vm:CalendarDay}">
   <Border Name="DayBorder" Background="DarkGray" 
       Margin="5" CornerRadius="5" 
       BorderThickness="2">
    <TextBlock Name="DayText" 
       Text="{Binding Path=Day, StringFormat={}{0:dd}}" 
       Margin="10" />
   </Border>
   <DataTemplate.Triggers>
    <DataTrigger Binding="{Binding Path=IsInCurrentMonth}" Value="False">
     <Setter Property="Background" 
        Value="WhiteSmoke" TargetName="DayBorder" />
     <Setter Property="Foreground" 
        Value="LightGray" TargetName="DayText" />
    </DataTrigger>
   </DataTemplate.Triggers>
</DataTemplate>

Silverlight 的问题在于它完全不知道 DataTrigger。解决方案?没错,我们需要一个转换器。我在这里想强调的一点是,您应该真正考虑编写通用可用的转换器。在这里可以编写一个“BooleanToBorderBackground”和一个“BooleanToTextForeground”转换器。问题是它们只能在特定情况下使用。一个更通用的解决方案是实现一种“IIF”操作的转换器。我们向它传递一个布尔值以及一个真值和一个假值。

转换器的问题在于它们不接受一个以上的输入参数(Binding.ConverterParameter)。一种可能的解决方法是使用特殊字符(例如“;”)分隔参数值,如以下代码示例所示

using System;
using System.Globalization;
using System.Windows.Data;

namespace ViewModelLibrary.CalendarViewModel
{
 public class IifConverter : IValueConverter
 {
  public object Convert(object value, Type targetType, 
                object parameter, CultureInfo culture)
  {
   var possibleReturnValues = parameter.ToString().Split(';');
   if (possibleReturnValues.Length != 2)
   {
    throw new ArgumentException();
   }
   return ((bool)value) ? possibleReturnValues[0] : possibleReturnValues[1];
  }

  public object ConvertBack(object value, Type targetType, 
                object parameter, CultureInfo culture)
  {
   throw new System.NotImplementedException();
  }
 }
}

现在,这个转换器可以在 Silverlight 中使用,以达到我们使用 DataTrigger 在 WPF 中实现的效果

<DataTemplate x:Key="DayTemplate">
   <Border Name="DayBorder" BorderThickness="1" 
       BorderBrush="{Binding Path=IsInMonth, Converter={StaticResource 
                    IIfResourceConverter}, 
                    ConverterParameter=TimeframeSelectorSelectedMonthDayBorder;
                    TimeframeSelectorDayBorder}" CornerRadius="3" 
       Background="{Binding Path=IsInMonth, Converter={StaticResource IIfResourceConverter}, 
                   ConverterParameter=TimeframeSelectorSelectedMonthDayBackground;
                   TimeframeSelectorDayBackground}" 
       Width="20" Height="20" Margin="1">
    <TextBlock Name="DayTextBlock" 
       Text="{Binding Path=BeginTime.Day}" FontSize="9" 
       Foreground="{Binding Path=IsInMonth, Converter={StaticResource 
                   IIfResourceConverter}, 
                   ConverterParameter=TimeframeSelectorSelectedMonthDayForeground;
                   TimeframeSelectorDayForeground}" 
       HorizontalAlignment="Center" 
       VerticalAlignment="Center" />
   </Border>
</DataTemplate>

分割参数值并不是一个非常优雅的解决方案,是吗?如果需要传递更复杂的对象,比如图像怎么办?一个更好的解决方案(尽管在 XAML 中编写起来更长)是实现一个帮助类,用于将多个参数传输到转换器中。在我们的案例中,我们需要一个帮助类,它可以在我们的 IifConverter 的真情况和假情况中接受一个对象

namespace ViewModelLibrary.CalendarViewModel
{
 public sealed class IifConverterArgs
 {
  public object TrueObject { get; set; }
  public object FalseObject { get; set; }
 }
}

当然,我们也必须稍微改变一下转换器

using System;
using System.Globalization;
using System.Windows.Data;

namespace ViewModelLibrary.CalendarViewModel
{
 public class IifConverterEx : IValueConverter
 {
  public object Convert(object value, Type targetType, 
                object parameter, CultureInfo culture)
  {
   var iifParameterArgs = parameter as IifConverterArgs;
   if ( iifParameterArgs==null )
   {
    throw new ArgumentException();
   }
   return ((bool)value) ? iifParameterArgs.TrueObject : 
            iifParameterArgs.FalseObject;
  }

  public object ConvertBack(object value, Type targetType, 
                object parameter, CultureInfo culture)
  {
   throw new System.NotImplementedException();
  }
 }
}

有了这个,我们可以以更健壮的方式直接在 XAML 中将多个参数传递给转换器(我保留了 IifConverter 的旧版本用于 TextBlock 的前景色,以便您可以比较两个版本)

<DataTemplate x:Key="DayTemplate">
   <Border Margin="5" CornerRadius="5" BorderThickness="2">
    <Border.Background>
     <Binding Path="IsInCurrentMonth"
        Converter="{StaticResource IifConverterEx}">
      <Binding.ConverterParameter>
       <vm:IifConverterArgs TrueObject="DarkGray" FalseObject="WhiteSmoke" />
      </Binding.ConverterParameter>
     </Binding>
    </Border.Background>
    <TextBlock Text="{Binding Path=Day, Converter={StaticResource 
                        DateTimeToStringConverter}, ConverterParameter=dd}" 
         Foreground="{Binding Path=IsInCurrentMonth, 
                     Converter={StaticResource IifConverter}, 
                     ConverterParameter=Black;LightGray}"
         Margin="10" />
   </Border>
</DataTemplate>

如您所见,转换器是数据绑定,尤其是在 Silverlight 中,重要且强大的工具。如果您开始使用它们,您很快就会发现自己处于想要嵌套转换器的情况(即,第一个转换器的结果应该作为第二个转换器的输入参数)。不幸的是,这在 WPF 和 Silverlight 中都不可能。您必须构建一个内部使用其他转换器的第三个转换器。

DLR - 编写最后一个转换器

如果您来自 ASP.NET 或 HTML,那么您会开始怀念 JavaScript,不是吗?MVVM 是一个很好的模式,遵守它会带来很多好处。但是,有些情况下您只需要做一些小小的转换,您真的不想再为此编写一个转换器。好吧,如果您不告诉任何人,我可以向您展示如何再次在 XAML 中启用脚本。解决方案叫做 DLR,动态语言运行时。

如果你还没了解 DLR,那你应该去了解一下!在我看来,这是 .NET 应用程序开发的下一个大事件。特别是随着 C# 4 中所有动态特性的出现,动态语言将变得越来越流行、强大和实用。将其集成到 WPF 或 Silverlight 的数据绑定机制中出奇的简单。您需要执行四个步骤

  1. 引用必要的 DLL。
  2. 在您的 _app.config_ 文件中配置 DLR(对于 WPF 版本;Silverlight 有点不同,但我不会在本文中介绍这些差异)。
  3. 初始化脚本环境。
  4. 根据需要执行表达式(例如,在您的转换器中)。

在我们继续之前,让我澄清两点

  • 即使您在 WPF/Silverlight 数据绑定库中包含脚本,您也**绝不能**在视图层中编写业务逻辑!此工具仅用于简单的面向 UI 的任务!
  • 请注意,脚本不是地球上最快的东西。用 C# 编写的编译转换器总是比脚本快得多!

步骤 1 - 引用 DLL

下载必要的 DLL(请参阅 CodePlex 上的 DLR 主页CodePlex 上的 IronPython)并在您的应用程序中引用它们。

步骤 2 - 添加 app.config 文件

DLR 和 IronPython 需要在您的 _app.config_ 文件中配置。它可能看起来像这样

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
 <configSections>
  <section name="microsoft.scripting"
     type="Microsoft.Scripting.Hosting.Configuration.Section, 
           Microsoft.Scripting, Version=0.9.0.0, Culture=neutral, 
           PublicKeyToken=31bf3856ad364e35" />
 </configSections>
 <microsoft.scripting>
  <languages>
   <language names="IronPython;Python;py" 
       extensions=".py" displayName="IronPython v2.0"
       type="IronPython.Runtime.PythonContext, IronPython, Version=2.0.0.0, 
             Culture=neutral, PublicKeyToken=31bf3856ad364e35" />
  </languages>
 </microsoft.scripting>
</configuration>

步骤 3 和 4 - 初始化脚本环境并执行脚本

下一步是初始化脚本环境。请注意,此步骤可能需要几分钟。因此,您不应该在每次调用转换器时都执行它。在下面的示例中,初始化代码只执行一次。

现在,您可以在任何需要的地方使用脚本了。在这里,我实现了一个转换器,它能够在绑定场景中执行 IronPython 表达式。请注意,转换器将数据绑定的源作为变量 Current 传递给表达式。这意味着脚本可以访问数据绑定的模型/视图模型对象。

using System.Reflection;
using System.Text;
using System.Windows.Data;
using Microsoft.Scripting;
using Microsoft.Scripting.Hosting;

namespace ViewModelLibrary.CalendarViewModel
{
 public sealed class ScriptConverter : IValueConverter
 {
  private static ScriptRuntime ScriptRuntime;
  private static ScriptScope ScriptScope;
  private static ScriptEngine ScriptEngine;

  static ScriptConverter()
  {
   ScriptConverter.InitializeScriptingEnvironment();
  }

  public object Convert(object value, System.Type targetType, 
         object parameter, System.Globalization.CultureInfo culture)
  {
   ScriptConverter.ScriptScope.SetVariable("Current", value);
   return ScriptConverter.ScriptEngine.CreateScriptSourceFromString(
    parameter.ToString(), SourceCodeKind.Expression).Execute(
     ScriptConverter.ScriptScope);
  }


  public object ConvertBack(object value, System.Type targetType, 
         object parameter, System.Globalization.CultureInfo culture)
  {
   throw new System.NotImplementedException();
  }

  private static void InitializeScriptingEnvironment()
  {
   ScriptConverter.ScriptRuntime = ScriptRuntime.CreateFromConfiguration();
   ScriptConverter.ScriptRuntime.LoadAssembly(Assembly.GetExecutingAssembly());
   ScriptConverter.ScriptScope = ScriptConverter.ScriptRuntime.CreateScope();
   ScriptConverter.ScriptEngine = ScriptConverter.ScriptRuntime.GetEngine("py");
   ScriptConverter.ScriptEngine.Runtime.LoadAssembly(typeof(DateTime).Assembly);

   var script = new StringBuilder();
   script.AppendLine("from System import DateTime, DayOfWeek");
   ScriptConverter.ScriptEngine.CreateScriptSourceFromString(script.ToString(), 
                   SourceCodeKind.Statements).Execute(ScriptConverter.ScriptScope);
  }
 }
}

现在一切都准备就绪了。举个例子,我们将使用脚本转换器来用红色边框显示所有星期日。所需的 IronPython 表达式如下所示:"Red" if ( Current.Day.DayOfWeek == DayOfWeek.Sunday ) else "Transparent"。有了脚本转换器,您可以直接将此表达式添加到您的 XAML 代码中。在实践中,您不会将其硬编码在那里——相反,它可能来自用户自定义、配置文件或类似的东西。

CalendarStep4.png

<DataTemplate DataType="{x:Type vm:CalendarDay}">
   <Border Name="DayBorder" Background="DarkGray" 
       Margin="5" CornerRadius="5" BorderThickness="2">
    <Border.BorderBrush>
     <Binding Converter="{StaticResource ScriptConverter}"
        ConverterParameter='"Red" if ( Current.Day.DayOfWeek == 
                            DayOfWeek.Sunday ) else "Transparent"' />
    </Border.BorderBrush>
    <TextBlock Name="DayText" Text="{Binding Path=Day, StringFormat={}{0:dd}}" Margin="10" />
   </Border>
   <DataTemplate.Triggers>
    <DataTrigger Binding="{Binding Path=IsInCurrentMonth}" Value="False">
     <Setter Property="Background" Value="WhiteSmoke" TargetName="DayBorder" />
     <Setter Property="Foreground" Value="LightGray" TargetName="DayText" />
    </DataTrigger>
   </DataTemplate.Triggers>
</DataTemplate>

从数据绑定到命令绑定

我们的日历示例还缺少一些东西:用于导航到下一个月或上一个月的按钮目前没有任何功能。我认识的大多数开发人员都会将窗口代码隐藏文件中的函数分配给按钮的 Click 事件

<Button Content="Prev." [...] Click="Button_Click" />
private void Button_Click(object sender, RoutedEventArgs e)
{
 [...]
}

虽然这个解决方案可行,但它并不是结合 MVVM 模式解决潜在问题的首选方法。视图模型类已经提供了数据绑定所需的一切。鉴于此,我们可以删除窗口代码隐藏文件中几乎所有的代码。如果能够将按钮直接“绑定”到视图模型层提供的方法,那不是很好吗?

事实证明,这可以通过使用命令绑定来实现。UI 控件,如 ButtonMenuItem 等(从技术上讲,所有实现 ICommandSource 的类),都提供一个 Command 属性。Command 的类型是 ICommand。如果您仔细查看,例如,Button,您会发现 Command 属性是一个依赖属性。万岁,当我们使用数据绑定时,一切都在那里!

要能够将命令绑定到视图模型类,该类必须提供 ICommand 类型的属性。在我们的案例中,我们需要两个命令:导航到下个月,导航到上个月。一个可能的解决方案是实现两个实现 ICommand 的类,一个用于“下个月”,一个用于“上个月”。如果命令包含或多或少复杂的业务逻辑,这绝对是正确的方法。在我们的案例中,逻辑非常非常简单。在实践中,您会发现命令逻辑通常非常简单(只是更改属性,调用方法等)。因此,建议编写一个可用于多种情况的 ICommand 通用实现。

您可以在网上找到此类通用命令类的不同模板。这里有一个适合我们示例需求的。请注意,当关联的实现 INotifyPropertyChanged 的对象中的属性发生更改时,会触发 ICommandCanExecuteChanged 事件。此处显示的实现适用于 WPF 和 Silverlight。

using System;
using System.ComponentModel;
using System.Windows.Input;

namespace ViewModelLibrary.CalendarViewModel
{
 public class DelegateCommand<T> : ICommand
 {
  private readonly Func<T, bool> canExecuteMethod = null;
  private readonly Action<T> executeMethod = null;

  public DelegateCommand(INotifyPropertyChanged parentObject, Action<T> executeMethod)
       : this(parentObject, null, executeMethod)
  {
  }

  public DelegateCommand(INotifyPropertyChanged parentObject, 
         Func<T, bool> canExecuteMethod, Action<T> executeMethod)
  {
   this.canExecuteMethod = canExecuteMethod;
   this.executeMethod = executeMethod;
   parentObject.PropertyChanged += delegate(object sender, 
                PropertyChangedEventArgs args)
    {
     if (this.CanExecuteChanged != null)
     {
      this.CanExecuteChanged(this, args);
     }
    };
  }

  public bool CanExecute(object parameter)
  {
   if (this.canExecuteMethod == null)
   {
    return true;
   }
   else
   {
    return this.canExecuteMethod((T)parameter);
   }
  }

  public void Execute(object parameter)
  {
   this.executeMethod((T)parameter);
  }

  public event EventHandler CanExecuteChanged;
 }
}

有了这个辅助类,在视图模型类中提供两个用于月份导航的命令属性就很容易了。为了演示目的,只能选择 2009 年和 2010 年的月份

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Windows.Input;

namespace ViewModelLibrary.CalendarViewModel
{
 public sealed class Calendar : INotifyPropertyChanged
 {
  public Calendar()
  {
   this.PropertyChanged += 
     new PropertyChangedEventHandler(OnCalendarPropertyChanged);
   this.CurrentMonth = new DateTime(2009, 10, 13);

   this.nextMonthCommand = new DelegateCommand<Calendar>(
    this,
    c => c.CurrentMonth.AddMonths(1).Year < 2010,
    delegate(Calendar c) { c.CurrentMonth = c.CurrentMonth.AddMonths(1); });

   this.prevMonthCommand = new DelegateCommand<Calendar>(
    this,
    c => c.CurrentMonth.AddMonths(-1).Year >= 2009,
    delegate(Calendar c) { c.CurrentMonth = c.CurrentMonth.AddMonths(-1); });
  }

  [...]

  private ICommand nextMonthCommand;
  public ICommand NextMonthCommand
  {
   get
   {
    return this.nextMonthCommand;
   }
  }

  private ICommand prevMonthCommand;
  public ICommand PrevMonthCommand
  {
   get
   {
    return this.prevMonthCommand;
   }
  }
  [...]

 }
}

最后一步是 XAML 文件中的命令绑定。除了绑定结果是 ICommand 之外,与数据绑定并没有真正的区别。请注意,只有 WPF 提供此处所示的命令支持,Silverlight 不开箱即用支持 Command 属性(网络上有一些 Silverlight 的 ICommand 实现,只需在 Bing/Google 上搜索“ICommand Silverlight”之类的词)。

<Button Content="Prev." 
     CommandParameter="{Binding}" 
     Command="{Binding Path=PrevMonthCommand}" />
  [...]  
<Button Grid.Column="2" Content="Next" 
  CommandParameter="{Binding}" 
  Command="{Binding Path=NextMonthCommand}" />

重要提示:始终先指定命令的参数,然后指定命令。否则,命令参数可能会接收到 NULL

异步数据绑定

到目前为止,您已经了解了如何将数据绑定与 MVVM 模式结合使用以获得精简的代码隐藏文件。我们已将数据和命令从 UI 层绑定到视图模型层。这在“hello world”应用程序中运行良好。在实践中,一旦涉及到异步这个棘手的问题,事情往往会变得更加复杂。

我们需要异步执行操作的两个最常见场景是

  1. 在后台执行长时间运行的操作,同时保持 UI 响应。
  2. 由于某些技术限制,被迫异步执行操作。

您能猜到我上面第 2 点提到的“技术限制”是谁造成的吗?没错,又是 Silverlight。Silverlight 无法同步访问网络。这意味着每当您想要执行任何涉及网络的操作时,都必须进行异步调用。这包括 Web 服务或 REST 调用。Silverlight 的所有数据都通过网络获取,因此 Silverlight 中的视图模型类充满了异步操作——不幸的是。

WPF 让事情变得容易得多。一方面,您可以通过使用属性 Binding.IsAsync 将数据绑定标记为异步。这涵盖了我们上面列表中提到的第 1 点。不幸的是,Silverlight 不提供此属性。但是,微软建议无论如何都不要使用它(请参阅 IsAsync 的 MSDN 文档)。因此,我不会在此处详细介绍。WPF 也不强制您在需要访问网络时使用异步调用。例如,您可以直接在 UI 绑定的属性中发出 Web 服务调用。如果您打算这样做,您真的应该考虑这是否是一个好主意。Web 服务调用可能会花费一些时间,因此即使 WPF 允许您以同步方式工作,您也应该考虑异步编程。

为了演示如何在 WPF 和 Silverlight 中异步进行数据绑定,我们将扩展我们的日历示例。我们的目标是通过 Flickr 的 REST Web 服务异步加载 Flickr 照片列表。照片列表应该只包含在选定月份拍摄的照片。因此,每当当前选定的月份发生变化时,列表都必须刷新。

CalendarStep5.png

让我们从扩展我们的视图模型层开始。我们需要做两件事

  1. 添加一个 IList 类型的属性,我们将在其中提供 Flickr 照片列表。视图层可以将例如 ItemsControl 控件绑定到此列表。
  2. 每当当前选定的月份发生变化时,就异步从 Flickr 检索照片列表。

第一步很简单。这是代码

private IList<Uri> flickrUriList;
public IList<Uri> FlickrUriList
{
   get { return this.flickrUriList; }
   set
   {
    this.flickrUriList = value;
    this.OnPropertyChanged("FlickrUriList");
   }
}

第二点比较难。视图模型层必须适用于 Silverlight 和 WPF。因此,我们被迫异步工作。获取 Flickr 照片是简单的一部分。该网站提供了一组基于 REST 的 API。在这种情况下,我只是使用 .NET 的 WebRequest 类来查询 Flickr 的 flickr.photos.search API 调用。在您查看下面的代码之前,请注意以下重要事项

  1. 我们必须使用 WebRequestBeginGetResponse 方法而不是 GetResponse
  2. 在异步回调方法中,我们不能直接使用 OnPropertyChanged 方法,因为它使用了只能从 UI 线程使用的资源。因此,我们必须通过 WPF/Silverlight Dispatcher 对象间接调用 OnPropertyChanged(请注意,在 WPF 中获取 dispatcher 很简单,但在 Silverlight 中很棘手;在 Silverlight 中,您必须在 App 类中记住 dispatcher)。

这是 OnCalendarPropertyChanged 方法的完整实现

private void OnCalendarPropertyChanged(object sender, PropertyChangedEventArgs e)
{
   if (e.PropertyName == "CurrentMonth")
   {
    RefreshWeekList();
    RefreshFlickrPhotoList();
   }
}

private void RefreshFlickrPhotoList()
{
   this.FlickrUriList = null;
   var request = WebRequest.Create(string.Format("http://api.flickr.com/services/" + 
    "rest/?method=flickr.photos.search&api_key=<insert_your_flickr_api_key_here>" + 
    "&min_taken_date={0}&max_taken_date={1}",
    this.CurrentMonth.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
    this.CurrentMonth.AddMonths(1).AddDays(-1).ToString("yyyy-MM-dd", 
                      CultureInfo.InvariantCulture)));
   request.BeginGetResponse(delegate(IAsyncResult asyncResult)
   {
    using (var response = request.EndGetResponse(asyncResult))
    {
     using (var stream = response.GetResponseStream())
     {
      using (var reader = new StreamReader(stream))
      {
       var flickrResult = XDocument.Load(reader);
       this.flickrUriList =
        (from r in flickrResult.Descendants("photo")
         select new Uri(string.Format(CultureInfo.InvariantCulture,
         "http://farm{0}.static.flickr.com/{1}/{2}_{3}.jpg",
         r.Attribute("farm").Value,
         r.Attribute("server").Value,
         r.Attribute("id").Value,
         r.Attribute("secret").Value), UriKind.Absolute)).ToList();
#if ( SILVERLIGHT )
       App.MainPageDispatcher.BeginInvoke(
        new Action(() => this.OnPropertyChanged("FlickrUriList")), null);
#else
       Application.Current.Dispatcher.BeginInvoke(
        new Action(() => this.OnPropertyChanged("FlickrUriList")), null);
#endif
      }
     }
    }
   }, null);
}
private void RefreshWeekList()
{
   var result = new List<Week>();
   DateTime currentDate;
   currentDate = (currentDate = this.CurrentMonth.AddDays((-1) * 
    (this.CurrentMonth.Day - 1)) - this.CurrentMonth.TimeOfDay).AddDays(
    (((int)currentDate.DayOfWeek) + 6) % 7 * (-1));
   while (currentDate.Month == this.CurrentMonth.Month ||
    currentDate.AddDays(6).Month == this.CurrentMonth.Month)
   {
     result.Add(new Week(currentDate, this.CurrentMonth.Year, 
                         this.CurrentMonth.Month));
     currentDate = currentDate.AddDays(7);
   }
   this.Weeks = result;
}

鉴于此,在 XAML 中添加 Flickr 照片列表并非特殊,只是一个简单的数据绑定。请注意,以下代码示例显示了 XAML 的 WPF 版本。视图模型适用于 Silverlight 和 WPF。XAML 必须为 Silverlight 稍作更改,因为 WrapPanel 面板不属于 Silverlight 3.0,而是 Silverlight 工具包的一部分。您可以在本文的源代码下载中查看 Silverlight 实现。

<Grid>
  <Grid.ColumnDefinitions>
   <ColumnDefinition Width="Auto" />
   <ColumnDefinition Width="*" />
  </Grid.ColumnDefinitions>
  <Grid Margin="5">
    [...]
  </Grid>
  <ItemsControl Grid.Column="1" ItemsSource="{Binding Path=FlickrUriList}">
   <ItemsControl.ItemsPanel>
    <ItemsPanelTemplate>
     <WrapPanel />
    </ItemsPanelTemplate>
   </ItemsControl.ItemsPanel>
   <ItemsControl.ItemTemplate>
    <DataTemplate>
     <Image Source="{Binding Path=AbsoluteUri}" Width="75" Margin="5" />
    </DataTemplate>
   </ItemsControl.ItemTemplate>
  </ItemsControl>
</Grid>

另一种选择是将 FlickrUriList 属性的类型设置为 ObservableCollection。在这种情况下,视图模型方法不会在每次选定月份更改时都创建一个全新的列表。相反,它会从可观察集合中添加和删除项目。数据绑定会自动识别可观察集合中的更改;因此,最终结果将是相同的。

RelativeSource 绑定

您想知道数据绑定还能为您做什么吗?很多!我想用一个人们反复问我的问题来结束本文:为什么存在 RelativeSource 绑定,以及它们是如何工作的?

请记住,您可以通过多种方式指定绑定的源

  1. 使用 ElementName 通过名称引用 XAML 元素。
  2. 使用 Source 引用现有对象(例如,页面资源中的对象)。
  3. 最后但同样重要的是,还有 RelativeSource

RelativeSource 使您能够相对于目标对象(即定义绑定的对象)查找源。查找源的规则通常使用 RelativeSource 标记扩展来定义

  1. Silverlight 和 WPF
    1. RelativeSource.Mode = TemplatedParent:如果您正在编写模板的 XAML(例如,自定义控件的模板)并且想要绑定到控件类的属性,请使用此模式。
    2. RelativeSource.Mode = Self:通常,绑定的 Path 是相对于对象的数据上下文的。但是,如果指定 RelativeSource.Mode = Self,则路径是相对于定义绑定的对象的。
  2. 仅限 WPF
    1. RelativeSource.Mode = PreviousData:在列表中使用此模式可绑定到列表中的前一个项目。
    2. RelativeSource.Mode = FindAncestor:通过此模式,您可以沿着 XAML 节点树向上查找源对象。

我想用一个简短的例子来演示 FindAncestor。还记得我们的日历示例吗?将日历控件封装到一个可重用的用户控件中可能是一个好主意。这样做非常容易。您只需向项目中添加一个新的用户控件,并将日历的网格复制粘贴到用户控件中,就完成了

<UserControl x:Class="DataBindingUI.CalendarControl"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
 <Grid Margin="5">
  <Grid.ColumnDefinitions>
   <ColumnDefinition Width="Auto" />
   <ColumnDefinition Width="*" />
   <ColumnDefinition Width="Auto" />
   <ColumnDefinition Width="Auto" />
  </Grid.ColumnDefinitions>
  <Grid.RowDefinitions>
   <RowDefinition Height="Auto" />
   <RowDefinition Height="*" />
  </Grid.RowDefinitions>

  <Button Content="Prev." CommandParameter="{Binding}" 
               Command="{Binding Path=PrevMonthCommand}" />

  <TextBlock Grid.Column="1"
       Text="{Binding Path=CurrentMonth, StringFormat={}{0:MMMM yyyy}}" 
       Foreground="White" HorizontalAlignment="Center" />

  <Button Grid.Column="3" Content="Next" 
    CommandParameter="{Binding}" 
    Command="{Binding Path=NextMonthCommand}" />

  <Border Grid.Row="1" Grid.ColumnSpan="4" Margin="0,5,0,0"
    BorderBrush="LightGray" CornerRadius="5" BorderThickness="2"
    Background="White">
   <ItemsControl ItemsSource="{Binding Path=Weeks}" />
  </Border>
 </Grid>
</UserControl>

请注意,您无需更改任何与现有数据绑定相关的内容。用户控件嵌入在主窗口中,并从那里继承数据上下文(即我们的视图模型层)。

那么,我们为什么需要这里的一些特殊之处呢?要理解为什么存在相对源绑定,我们必须查看使用新用户控件的 XAML 代码

<local:CalendarControl />

想象一下您是用户控件的用户,并且在特定情况下您希望其背景为红色。问题是用户控件的背景被硬编码到其 XAML 中

<Border [...] Background="White">

在这种情况下,**不**建议在视图模型中添加 Background 属性!您可以使用 UserControl 的现有 Background 属性代替

<local:CalendarControl Background="Red" />

用户控件中的数据绑定表达式不能仅仅是 {Binding Path=Background},因为 WPF 会在视图模型中查找名为 Background 的属性。正是针对这些情况,微软构建了相对源绑定。绑定必须通过沿着 XAML 节点树向上查找绑定源,直到找到类型为 UserControl 的节点。然后,它必须获取 Background 属性

<Border [...]
    Background="{Binding RelativeSource={RelativeSource 
                Mode=FindAncestor, AncestorType={x:Type UserControl}}}">

结论

数据绑定是 WPF 和 Silverlight 中强大的工具。按照 MVVM 模式构建您的应用程序,它将为您节省大量时间,您将开发出更具可测试性的软件,并从此过上幸福的生活 ;-)

© . All rights reserved.