跳到主要内容

3.4 依赖属性与路由事件

学习目标​

本节通过一个小型 UserControl 认识依赖属性和路由事件。完成后,你能为复用控件暴露可绑定属性,在父容器接收控件事件,并解释为什么一般 ViewModel 使用普通属性通知,而控件属性使用 WPF 属性系统。

依赖属性不是依赖注入​

DependencyProperty 由 WPF 属性系统管理值。它支持绑定、样式、默认值、动画、值继承与元数据等功能;“依赖”与构造函数注入服务没有关系。控件上的 TextBox.Text、FrameworkElement.Margin 等属性就是依赖属性。

绑定目标通常需要依赖属性,绑定源则可以是普通 C# 属性。ViewModel 不需要继承 DependencyObject 才能参与绑定;它通常用属性通知,这样更容易测试,也不会引入 UI 线程相关约束。

依赖属性常以两部分出现:一个静态注册标识,以及一个调用 GetValue、SetValue 的 CLR 包装属性。默认值和属性变化回调放在元数据里。不要在包装 setter 中隐藏关键业务逻辑,因为属性系统的某些更新可以绕过这个包装而直接设置依赖属性。

同一个属性还可能受到多个值来源影响,例如默认元数据、样式 setter、本地赋值、绑定和动画。一般本地值优先于普通样式 setter,绑定也会占据本地值的位置;动画等机制有更高优先级。遇到“样式不生效”时,应检查当前实际值来自哪里,而不是重复加样式。直接给已绑定属性赋本地值,还可能替换原绑定。

创建一个复用控件​

沿用 WpfBasics 项目,在项目根目录新增 StatusBadge.xaml 和 StatusBadge.xaml.cs。以下是两个完整文件。控件暴露 Caption,内部用显式祖先绑定读取它,不修改自身 DataContext。

StatusBadge.xaml(完整文件)
<UserControl x:Class="WpfBasics.StatusBadge"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WpfBasics">
<Border BorderBrush="#245AC4" BorderThickness="1"
CornerRadius="6" Padding="12">
<StackPanel Orientation="Horizontal">
<TextBlock VerticalAlignment="Center" Margin="0,0,12,0"
Text="{Binding Caption, RelativeSource={RelativeSource AncestorType={x:Type local:StatusBadge}}}" />
<Button Content="确认" Padding="12,4" Click="OnConfirmClick" />
</StackPanel>
</Border>
</UserControl>
StatusBadge.xaml.cs(完整文件)
using System.Windows;
using System.Windows.Controls;

namespace WpfBasics;

public partial class StatusBadge : UserControl
{
public static readonly DependencyProperty CaptionProperty =
DependencyProperty.Register(
nameof(Caption),
typeof(string),
typeof(StatusBadge),
new PropertyMetadata("待确认"));

public string Caption
{
get => (string)GetValue(CaptionProperty);
set => SetValue(CaptionProperty, value);
}

public static readonly RoutedEvent ConfirmedEvent =
EventManager.RegisterRoutedEvent(
nameof(Confirmed),
RoutingStrategy.Bubble,
typeof(RoutedEventHandler),
typeof(StatusBadge));

public event RoutedEventHandler Confirmed
{
add => AddHandler(ConfirmedEvent, value);
remove => RemoveHandler(ConfirmedEvent, value);
}

public StatusBadge()
{
InitializeComponent();
}

private void OnConfirmClick(object sender, RoutedEventArgs e)
{
RaiseEvent(new RoutedEventArgs(ConfirmedEvent, this));
}
}

注册名称必须与包装属性一致,所有者类型是 StatusBadge。这里用不可变字符串作为默认值;不要把一个可变集合放进元数据默认值,导致所有控件实例共享同一个集合。绑定源仍由外部提供,内部仅定位到当前控件读取公开属性,这也是自定义控件避免破坏外部上下文的常见方式。

在父容器中绑定并接收事件​

用以下两个完整窗口文件替换原文件。窗口上下文使用自身,只为了提供一个简单的初始标题;Caption 后续若由代码修改,也应增加属性通知。父级 Grid 接收从徽章控件冒泡而来的事件。

MainWindow.xaml(完整文件)
<Window x:Class="WpfBasics.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WpfBasics"
Title="属性与事件" Width="600" Height="280">
<Grid Margin="24" local:StatusBadge.Confirmed="OnConfirmed">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<local:StatusBadge Caption="{Binding Caption, Mode=OneWay}"
HorizontalAlignment="Left" />
<TextBlock x:Name="ResultText" Grid.Row="1"
Text="等待确认" Margin="0,16,0,0" />
</Grid>
</Window>
MainWindow.xaml.cs(完整文件)
using System.Windows;

namespace WpfBasics;

public partial class MainWindow : Window
{
public string Caption { get; } = "任务已准备好";

public MainWindow()
{
InitializeComponent();
DataContext = this;
}

private void OnConfirmed(object sender, RoutedEventArgs e)
{
ResultText.Text = $"父级已收到确认;订阅对象:{sender.GetType().Name}";
}
}

虽然 Grid 本身没有声明 CLR 的 Confirmed 事件,XAML 仍可使用所有者限定写法注册对应路由事件处理器。事件从 StatusBadge 发出,沿元素树向父级传播;这个处理器的 sender 是注册处理器的 Grid,而不是内部按钮。

路由和普通 C# 事件的差别​

普通事件由你订阅特定对象。路由事件增加了经过元素树的机制:Bubble 从源向上冒泡,Tunnel 从树上方向源隧道传播,Direct 只在源处处理。许多输入事件有 Preview... 与普通事件配对,但具体路由策略要查看事件定义,不宜仅凭命名推断所有细节。

Source 表示事件报告的源,OriginalSource 保留更早的原始源信息,可能是模板内部元素;控件可以调整报告源。本例主动创建 Confirmed,源就是徽章自身。sender 则是当前处理器订阅所在的对象,三者不能混为一谈。

设置 e.Handled = true 会使通常的后续处理器被跳过,树上的传播机制并非简单地销毁事件。确实需要接收已处理事件时,可以通过 AddHandler 的 handledEventsToo 参数注册。不要随意标记所有输入事件已处理,否则父级键盘或选择行为可能受到影响。

常见错误​

  • 控件的自定义属性不能作为绑定目标:普通 CLR 属性不具备完整的依赖属性能力,按约定注册并用属性系统读写。
  • 控件内部设置 DataContext = this:外部绑定可能因此找不到 ViewModel 属性。使用 RelativeSource 明确访问控件。
  • 把路由事件当作跨模块消息系统:它适合 UI 树上的交互;跨窗口业务协作应选择明确的服务或状态模型。
  • 在包装属性 setter 中执行保存:绑定或样式更新不是业务提交动作,保存逻辑应由事件或命令明确触发。

动手练习与验收​

在窗口中增加第二个徽章,直接设置 Caption="第二个任务",两个控件都由同一个父级处理器接收事件。再在第一个徽章自身增加处理器,设置事件已处理,观察父级普通处理器是否还会响应。

验收标准:两个徽章显示独立标题;父级能收到任意徽章的确认;第一个徽章标记已处理后,父级普通处理器不再响应它,但第二个仍正常。你应能说明依赖属性的所有者、默认值与绑定值,以及事件的源和订阅对象分别是谁。

官方延伸资料​