WPF 学习笔记(二):依赖属性——WPF 的属性系统

写在前面

版本说明:基于 .NET 8 LTS。本文涉及的反编译代码基于 .NET 8 实际版本(可能因版本略有差异)。

依赖属性(DependencyProperty,简称 DP)是 WPF 的"地基"。绑定、样式、动画、模板、属性继承——所有 WPF 引以为傲的特性都建立在它之上。如果你写 WPF 但不理解 DP,就像写 C# 但不理解引用类型——能用,但出 bug 时一脸懵。

本文要回答的核心问题:

为什么 WPF 要重新发明一遍"属性"?它和普通 CLR 属性到底差在哪?


一、CLR 属性的局限

先理解问题,再看解决方案。

1.1 一个普通 CLR 属性

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class Person
{
    private string _name;
    public string Name
    {
        get => _name;
        set
        {
            if (_name != value)
            {
                _name = value;
                OnPropertyChanged(nameof(Name));
            }
        }
    }
}
1
2
看起来挺完美,为什么 WPF 还要重新搞?
  → 因为 UI 框架对"属性"有更复杂的需求

1.2 UI 框架的四大需求

需求 1:默认值

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Button 默认 Background = SystemColors.ControlBrush
但 Button 类有几百个属性,全部用字段存默认值?
  → 一个 Button 实例 = 几百字段的内存开销
  → 一万个 Button = 几百万字段,全部都是默认值

CLR 属性的方案:
  private static readonly Brush DefaultBackground = ...;
  private Brush _background = DefaultBackground;
  → 每个实例还是有一个字段引用
  → 引用很便宜,但 WPF 控件属性多到不行,还是不能接受

需求 2:属性继承

1
2
3
4
5
6
Window 字号 16 → 子元素默认继承 16
Window.Foreground = Red → 子元素默认也是 Red

CLR 属性做不到:
  → 父级改了,子级怎么知道?
  → 你要么写循环遍历子元素(崩溃),要么用事件机制(繁琐)

需求 3:变更通知

1
2
3
4
5
6
7
Button.Width 改了 → 父容器要重新布局
Button.Background 改了 → 要重绘
Button.IsEnabled=false → 子元素也要禁用

CLR 属性:
  需要为每个属性手动写 INotifyPropertyChanged
  → 一堆样板代码

需求 4:样式覆盖

1
2
3
4
5
6
Button 默认 Background = Control
Style 改成 Red
本地又设置了 Background = Blue
最后值是?

CLR 属性只有一个值,没法表达"多层优先级"

1.3 WPF 的答案:依赖属性

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
DependencyProperty 的核心思想:
  属性值不存"字段",存"按需的稀疏字典"
  优先级、默认值、继承、通知,全部由属性系统统一管理

类比后端:
  CLR 属性 = 直接字段存储
  DP      = Redis 式的"分层数据源 + 优先级合并 + 通知"

  多个值源(本地/样式/默认/继承/动画/...)→ 取优先级最高的
 任何一个值源变化 → 自动重新计算并通知

二、DependencyProperty.Register

2.1 第一个 DP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class CircularProgress : Control
{
    // 1. 注册依赖属性
    public static readonly DependencyProperty ProgressProperty =
        DependencyProperty.Register(
            name: nameof(Progress),           // 属性名
            propertyType: typeof(double),     // 类型
            ownerType: typeof(CircularProgress), // 所属类型
            typeMetadata: new PropertyMetadata(
                defaultValue: 0.0,             // 默认值
                propertyChangedCallback: OnProgressChanged  // 变更回调
            ),
            validateValueCallback: IsValidProgress  // 校验回调
        );

    // 2. CLR 属性包装器(用 GetValue/SetValue)
    public double Progress
    {
        get => (double)GetValue(ProgressProperty);
        set => SetValue(ProgressProperty, value);
    }

    // 3. 校验:必须是 0~100
    private static bool IsValidProgress(object value)
        => value is double d && d >= 0 && d <= 100;

    // 4. 变更回调(静态!实例通过 sender 拿到)
    private static void OnProgressChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var control = (CircularProgress)d;
        var newProgress = (double)e.NewValue;
        control.InvalidateVisual();   // 触发重绘
    }
}

2.2 Register 干了什么

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
DependencyProperty.Register 内部:

1. 创建一个 DependencyProperty 实例(DP 是不可变对象)
2. 把它注册到全局哈希表 PropertyFromName
   key = (OwnerType, Name) 元组
   value = DependencyProperty 实例
3. 返回这个实例(通常存为 static readonly 字段)

属性标识:
  每个 DP 有唯一的 GlobalIndex(int)
  DP 的"身份"由 (OwnerType + Name) 或 GlobalIndex 唯一确定
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// DependencyProperty 内部结构(简化)
public class DependencyProperty
{
    public string Name { get; }                    // "Progress"
    public Type PropertyType { get; }              // typeof(double)
    public Type OwnerType { get; }                 // typeof(CircularProgress)
    public int GlobalIndex { get; }                // 全局序号(int)
    public PropertyMetadata DefaultMetadata { get; } // 默认元数据
    public ValidateValueCallback ValidateValueCallback { get; }
}

2.3 全局哈希表

1
2
3
4
5
6
PropertyFromName(DP 的全局注册表):
  key: (OwnerType, Name)
  value: DependencyProperty

注册一次永久存在,所有实例共享这个 DP 实例
→ 这是"稀疏存储"的前提:DP 本身不存值,只存"元数据"

三、稀疏存储:DependencyObject 内部

属性值存哪里?答案:在 DependencyObject 实例上的"稀疏数组"。

3.1 EffectiveValueEntry

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// DependencyObject 内部(简化)
public abstract class DependencyObject : DispatcherObject
{
    // 数组:只存"被设置过"的属性
    private EffectiveValueEntry[] _effectiveValues;

    public object GetValue(DependencyProperty dp)
    {
        // 1. 在 _effectiveValues 中按 GlobalIndex 查
        var entry = FindEntry(dp.GlobalIndex);
        if (entry != null) return entry.Value;

        // 2. 没设置过 → 走默认值/继承/其他源
        return GetValueFromSources(dp);
    }

    public void SetValue(DependencyProperty dp, object value)
    {
        // 1. 校验
        if (!dp.ValidateValueCallback(value))
            throw new ArgumentException();

        // 2. 强制(Coerce)
        value = CoerceValue(dp, value);

        // 3. 写入 _effectiveValues
        SetEntry(dp.GlobalIndex, value);

        // 4. 触发 PropertyChanged 回调
        RaisePropertyChanged(dp, oldValue, value);
    }
}

3.2 稀疏存储的含义

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
有效值数组的设计:
  - 控件有几百个属性,但实例上通常只设置几十个
  - 其他属性用默认值(不进数组)
  - 数组只占几十个槽位,而不是几百个字段

类比:
  一个 .NET 对象的实例字段 = 强制存储(每个字段都占位)
  DP 的 EffectiveValueEntry[] = 稀疏存储(只有非默认值才占位)

效果:
  - 1000 个 Button 实例 × 每个 Button 有 200 个 DP
  - 假设每个实例平均只设置 20 个非默认值
  - 内存 = 1000 × 20 entries ≈ 1000 × 20 × 32 字节 ≈ 640 KB
  - 如果用字段:1000 × 200 × 8 字节 ≈ 1.6 MB

3.3 DependencyPropertyKey(只读 DP)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class MyControl : Control
{
    // 只读 DP 需要用 RegisterReadOnly
    private static readonly DependencyPropertyKey IsMouseOverInternalPropertyKey =
        DependencyProperty.RegisterReadOnly(
            nameof(IsMouseOverInternal),
            typeof(bool),
            typeof(MyControl),
            new PropertyMetadata(false));

    // 公开的 DP 用 Key.DependencyProperty 暴露(不能 SetValue)
    public static readonly DependencyProperty IsMouseOverInternalProperty =
        IsMouseOverInternalPropertyKey.DependencyProperty;

    public bool IsMouseOverInternal
    {
        get => (bool)GetValue(IsMouseOverInternalProperty);
        // 注意:没有 set,外部不能赋值
    }

    // 内部可以用 Key 设置
    private void UpdateMouseOver(bool value)
    {
        SetValue(IsMouseOverInternalPropertyKey, value);
    }
}
1
2
3
4
5
6
7
为什么需要 DependencyPropertyKey?
  - 普通 DP 任何代码都能 SetValue(不安全)
  - 只读 DP 想让外部只读,但内部能改
  - 用 Key 作为 SetValue 的"令牌",没 Key 就不能改
  - Key 是 private,外部访问不到 → 安全

常见只读 DP:IsMouseOver、IsKeyboardFocusWithin、HasItems

四、属性值优先级(11 级)

这是 DP 最容易被忽略、但实战最常踩坑的部分。一个属性的"最终值"由多个来源决定,按优先级取最高。

4.1 11 级优先级(从高到低)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
1. Coercion (CoerceValueCallback)            ← 强制值(永远最高)
2. Active Animations                         ← 动画(占用的属性)
3. Local Value                               ← 本地值(XAML 直接写/SetValue)
4. TemplatedParent Template                  ← 模板内
5. Style Triggers                            ← 样式触发器
6. Template Triggers                         ← 模板触发器
7. Style Setters                              ← 样式 Setter
8. Theme Style Triggers                       ← 主题样式触发器
9. Theme Style Setters                        ← 主题样式 Setter
10. Inherited                                 ← 继承(来自父级)
11. Default                                   ← 默认值(永远最低)

4.2 优先级实战例子

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!-- 默认值(第 11 级) -->
<Button Content="OK"/>
<!-- Background = ButtonChrome 的默认色 -->

<!-- 样式 Setter(第 7 级) -->
<Button Content="OK">
    <Button.Style>
        <Style TargetType="Button">
            <Setter Property="Background" Value="Yellow"/>
        </Style>
    </Button.Style>
</Button>
<!-- Background = Yellow -->

<!-- 本地值(第 3 级,覆盖 Setter) -->
<Button Content="OK" Background="Red">
    <Button.Style>...</Button.Style>
</Button>
<!-- Background = Red -->

<!-- 动画(第 2 级,覆盖本地) -->
<Button Content="OK" Background="Red">
    <Button.Triggers>
        <EventTrigger RoutedEvent="Button.Loaded">
            <BeginStoryboard>
                <Storyboard>
                    <ColorAnimation Storyboard.TargetProperty="(Button.Background).(SolidColorBrush.Color)"
                                    To="Green" Duration="0:0:1"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
    </Button.Triggers>
</Button>
<!-- 动画期间 Background = Green -->

4.3 调试技巧

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 查看一个 DP 的当前值来源
var source = button.GetLocalValueEnumerator();
while (source.MoveNext())
{
    var dp = source.Current.Property;
    var value = source.Current.Value;
    Console.WriteLine($"{dp.Name} = {value}");
}

// 看完整 effective value(含默认)
var current = button.GetValue(Button.BackgroundProperty);

五、PropertyMetadata 回调链

PropertyMetadata 提供三个回调,按固定顺序执行。

5.1 三个回调

1
2
3
4
5
6
7
8
9
DependencyProperty.Register(
    "Progress",
    typeof(double),
    typeof(CircularProgress),
    new PropertyMetadata(
        defaultValue: 0.0,
        propertyChangedCallback: OnProgressChanged,   // ③ 变更后
        coerceValueCallback: CoerceProgress),         // ② 强制
    validateValueCallback: IsValidProgress);          // ① 校验

5.2 执行顺序

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
SetValue(value) 调用时:

  ① ValidateValueCallback(value)
     → 返回 false 抛 ArgumentException
     → 用于"硬约束"(必须是 0~100、不能为 null)
     → 静态、无状态(不依赖实例)

  ② CoerceValueCallback(instance, value)
     → 返回"调整后的值"
     → 用于"软约束"(Progress 不能超过 MaxProgress)
     → 实例方法(可以访问其他属性)

  ③ 写入 EffectiveValueEntry

  ④ PropertyChangedCallback(instance, args)
     → OldValue → NewValue 通知
     → 触发重绘、布局、级联属性变更
     → 静态签名(但 d 参数是实例)

  ⑤ 触发绑定、动画、监听器

5.3 回调应用场景

回调何时调用典型用途
ValidateValueCallbackSetValue 前类型/范围校验(必须 0~100)
CoerceValueCallbackValidate 后、写入前关联约束(不超过 MaxProgress)
PropertyChangedCallback写入后触发副作用(重绘、级联、通知)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 经典案例:MinWidth ≤ Width ≤ MaxWidth
public double Width
{
    get => (double)GetValue(WidthProperty);
    set => SetValue(WidthProperty, value);
}

private static object CoerceWidth(DependencyObject d, object baseValue)
{
    var control = (MyControl)d;
    var width = (double)baseValue;

    if (width < control.MinWidth) return control.MinWidth;
    if (width > control.MaxWidth) return control.MaxWidth;
    return width;
}
1
2
3
4
5
6
7
8
为什么 Coerce 在 PropertyChanged 之前?
  → Coerce 是"调整",不是"拒绝"
  → 你给 Width=-10,它返回 MinWidth,然后正常处理
  → PropertyChanged 拿到的就是调整后的值

为什么 Validate 在 Coerce 之前?
  → Validate 是静态硬约束(拒绝非法值)
  → Coerce 是动态软约束(根据当前状态调整)

5.4 PropertyChangedCallback 是静态的

1
2
3
4
5
6
7
8
9
// ❌ 错误想法:实例方法
private void OnProgressChanged(DependencyObject d, ...) { ... }

// ✅ 实际签名:static
private static void OnProgressChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
    var control = (CircularProgress)d;   // 通过 d 拿到实例
    ...
}
1
2
3
4
5
6
7
8
为什么是 static?
  → DP 注册是类型级别的(static field)
  → 回调要被多个实例共享
  → 如果是实例方法,每次 Register 都要绑定 this(不合理)
  → 通过 d 参数把实例传进来(标准模式)

后端类比:
  类似 ASP.NET Core 的中间件——签名是静态的,靠 context 拿请求实例

六、附加属性(Attached Property)

附加属性是一种特殊的 DP,“属于类型 A,但能设置在类型 B 的实例上”。

6.1 经典例子:Grid.Row

1
2
3
4
5
6
7
8
9
<Grid>
    <Grid.RowDefinitions>
        <RowDefinition/>
        <RowDefinition/>
    </Grid.RowDefinitions>

    <Button Grid.Row="0" Content="Row 0"/>
    <Button Grid.Row="1" Content="Row 1"/>
</Grid>
1
2
3
4
5
6
7
8
9
观察:
  - Button 没有 Row 属性
  - 但 Grid.Row="0" 写在 Button 上
  - 这是"附加属性"

含义:
  Grid 类型定义了 RowProperty
  但 RowProperty 的值存在 Button 实例上
  Grid 读取 Button 的 Row 时,从 Button 实例上拿

6.2 注册附加属性

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class Grid : Panel
{
    public static readonly DependencyProperty RowProperty =
        DependencyProperty.RegisterAttached(
            "Row",                          // 名称
            typeof(int),                    // 类型
            typeof(Grid),                   // 所属类型
            new PropertyMetadata(0));       // 默认值

    // Get/Set 访问器(XAML 解析器约定)
    public static int GetRow(DependencyObject element)
        => (int)element.GetValue(RowProperty);

    public static void SetRow(DependencyObject element, int value)
        => element.SetValue(RowProperty, value);
}
1
2
3
4
5
6
RegisterAttached vs Register:
  - Register:DP 属于自己的类型,自己的实例用
  - RegisterAttached:DP 属于自己的类型,但其他类型实例上能设置

底层存储一样:都存在 DependencyObject 的 _effectiveValues 里
区别只在 API 表面(Get/Set 是静态方法)

6.3 附加属性的应用

1
2
3
4
5
6
7
8
9
布局:Grid.Row/Column、Canvas.Left/Top、DockPanel.Dock
布局相关:KeyboardNavigation.TabNavigation、ToolTipService.ToolTip
行为:SpellCheck.IsEnabled、ContextMenuService.ContextMenu

设计哲学:
  "把行为附加到任意元素上"
  → Button 本身不需要知道"我属于第几行"
  → 但布局系统(Grid)需要知道
  → 用附加属性让"无关类型"也能携带信息

6.4 自定义附加属性示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 给任意 FrameworkElement 加"水印"附加属性
public static class WatermarkService
{
    public static readonly DependencyProperty WatermarkProperty =
        DependencyProperty.RegisterAttached(
            "Watermark",
            typeof(string),
            typeof(WatermarkService),
            new PropertyMetadata(null, OnWatermarkChanged));

    public static string GetWatermark(DependencyObject obj)
        => (string)obj.GetValue(WatermarkProperty);

    public static void SetWatermark(DependencyObject obj, string value)
        => obj.SetValue(WatermarkProperty, value);

    private static void OnWatermarkChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        // 给 TextBox 加水印逻辑
        if (d is TextBox tb)
        {
            // ... 实现水印
        }
    }
}
1
<TextBox local:WatermarkService.Watermark="请输入用户名"/>

七、属性继承(Property Inheritance)

某些 DP 会"从父级继承"。

7.1 哪些属性会继承

1
2
3
4
5
6
7
8
9
默认会继承的属性:
  - FontSize、FontFamily、FontWeight
  - Foreground、Background(部分)
  - CultureInfo
  - DataContext(最重要!)

继承的语义:
  子元素没设置这个属性 → 用父元素的值
  子元素设置了 → 用自己的值

7.2 注册时指定继承

1
2
3
4
5
6
7
8
public static readonly DependencyProperty MyFontSizeProperty =
    DependencyProperty.RegisterAttached(
        "MyFontSize",
        typeof(double),
        typeof(MyControl),
        new FrameworkPropertyMetadata(
            defaultValue: 12.0,
            flags: FrameworkPropertyMetadataOptions.Inherits));  // 标记可继承
1
2
3
4
5
6
7
FrameworkPropertyMetadataOptions:
  - Inherits:可继承
  - AffectsMeasure、AffectsArrange、AffectsRender:影响布局/绘制
  - BindsTwoWayByDefault:默认双向绑定
  - ...

  这就是为什么改 Window.FontSize,所有子控件字号也变

7.3 DataContext 继承

1
2
3
4
5
6
<Window DataContext="{Binding MainViewModel}">
    <StackPanel>                       <!-- 继承 DataContext -->
        <TextBox Text="{Binding Name}"/>  <!-- 用 Window 的 DataContext -->
        <Button Content="{Binding Title}"/>
    </StackPanel>
</Window>
1
2
3
4
5
关键事实:
  - DataContext 是 DP 且默认可继承
  - 子元素没设置 → 用父级
  - 绑定默认从 DataContext 取值
  - 这是 WPF 数据绑定的根基(详见第 5 篇)

八、DP 与绑定的关系

DP 是绑定的"目标"载体。

8.1 双向通路

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
DP 作为绑定目标:
  <TextBox Text="{Binding Name}"/>
  → TextBox.Text 是 DP
  → 绑定系统监听 DP 的 PropertyChanged
  → 数据源变了,TextBox 自动更新

INotifyPropertyChanged 作为绑定源:
  ViewModel.Name 改变 → 触发 INPC
  → 绑定系统收到通知
  → TextBox.Text 自动更新

8.2 为什么绑定目标必须是 DP

1
2
3
4
5
6
7
绑定需要"属性变更通知"机制
  → CLR 属性没有内建通知
  → DP 的 PropertyChangedCallback 天然就是通知
  → 所以绑定目标必须是 DP

  TextBox.Text 是 DP ✅
  普通 POCO 的属性不能作为绑定目标 ❌

九、实战:CircularProgress 控件

把前面学的全部用上——一个带 Coerce 校验的进度环控件。

9.1 完整代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
public class CircularProgress : FrameworkElement
{
    public static readonly DependencyProperty ProgressProperty =
        DependencyProperty.Register(
            nameof(Progress),
            typeof(double),
            typeof(CircularProgress),
            new FrameworkPropertyMetadata(
                defaultValue: 0.0,
                propertyChangedCallback: OnProgressChanged,
                affectsRender: true),         // 改了要重绘
            validateValueCallback: IsValidProgress);

    public double Progress
    {
        get => (double)GetValue(ProgressProperty);
        set => SetValue(ProgressProperty, value);
    }

    public static readonly DependencyProperty StrokeColorProperty =
        DependencyProperty.Register(
            nameof(StrokeColor),
            typeof(Color),
            typeof(CircularProgress),
            new FrameworkPropertyMetadata(
                defaultValue: Colors.DodgerBlue,
                propertyChangedCallback: OnVisualPropChanged,
                affectsRender: true));

    public Color StrokeColor
    {
        get => (Color)GetValue(StrokeColorProperty);
        set => SetValue(StrokeColorProperty, value);
    }

    private static bool IsValidProgress(object value)
        => value is double d && !double.IsNaN(d) && !double.IsInfinity(d);

    private static void OnProgressChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var control = (CircularProgress)d;
        // 在 0~100 之间是数据约束(用 Validate)
        // 这里只是触发副作用:调试日志
        Trace.WriteLine($"Progress: {e.OldValue} → {e.NewValue}");
    }

    private static void OnVisualPropChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        ((CircularProgress)d).InvalidateVisual();
    }

    protected override void OnRender(DrawingContext dc)
    {
        var size = Math.Min(ActualWidth, ActualHeight);
        var center = new Point(ActualWidth / 2, ActualHeight / 2);
        var radius = size / 2 - 10;

        // 背景圆
        dc.DrawEllipse(null, new Pen(Brushes.LightGray, 6), center, radius, radius);

        // 进度圆弧
        var angle = Progress / 100 * 360;
        var pen = new Pen(new SolidColorBrush(StrokeColor), 6);
        var start = new Point(center.X + radius, center.Y);
        var arcEnd = ComputeArcEnd(center, radius, angle);

        var geometry = new PathGeometry();
        geometry.Figures.Add(new PathFigure(
            start,
            new[] { new ArcSegment(arcEnd, new Size(radius, radius), 0,
                                    angle > 180 ? SweepDirection.Counterclockwise : SweepDirection.Clockwise, true) },
            false));

        dc.DrawGeometry(null, pen, geometry);
    }

    private static Point ComputeArcEnd(Point center, double radius, double angle)
    {
        var rad = angle * Math.PI / 180;
        return new Point(
            center.X + radius * Math.Cos(rad),
            center.Y + radius * Math.Sin(rad));
    }
}

9.2 使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<Window xmlns:local="clr-namespace:MyApp">
    <StackPanel>
        <local:CircularProgress
            Width="100" Height="100"
            Progress="75"
            StrokeColor="DodgerBlue"/>

        <Slider x:Name="slider" Minimum="0" Maximum="100" Value="75"/>
        <local:CircularProgress
            Width="100" Height="100"
            Progress="{Binding ElementName=slider, Path=Value}"
            StrokeColor="Orange"/>
    </StackPanel>
</Window>

9.3 知识点覆盖

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
✅ DependencyProperty.Register(注册)
✅ CLR 属性包装器
✅ ValidateValueCallback(范围校验)
✅ PropertyChangedCallback(触发重绘)
✅ FrameworkPropertyMetadata(AffectsRender)
✅ 绑定作为 DP 目标(Slider.Value → Progress)

调试场景:
  - Progress = -5 → ValidateValueCallback 返回 false → ArgumentException
  - Progress = 200 → 同样失败
  - Progress = 50.5 → 通过 → OnProgressChanged 触发 → OnRender 重绘

十、DP 与 CLR 属性的对比

维度CLR 属性依赖属性
存储字段(每实例都有)稀疏数组(只存非默认值)
通知需手动 INotifyPropertyChanged内建 PropertyChangedCallback
默认值每实例字段引用类型级共享
优先级单一值11 级优先级
继承FrameworkPropertyMetadataOptions.Inherits
校验set 内写 ifValidateValueCallback
强制set 内调整CoerceValueCallback
绑定不能作为目标可以作为目标
动画不能动画可以动画
性能字段访问快GetValue/SetValue 略慢(但优化过)
1
2
3
4
5
6
7
后端视角总结:
  CLR 属性 = "字段 + 简单 getter/setter"
  DP      = "字段 + 多源优先级合并 + 校验/强制/通知链 + 稀疏存储"

  DP 比 CLR 属性"重",但提供了 UI 框架需要的一切
  WPF 的代价:每个属性访问多走一层 GetValue/SetValue
  WPF 的收益:绑定、动画、样式、继承全部白送

十一、常见陷阱

11.1 CLR 包装器里别写逻辑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ❌ 错误:包装器里写副作用
public double Progress
{
    get => (double)GetValue(ProgressProperty);
    set
    {
        SetValue(ProgressProperty, value);
        InvalidateVisual();  // ❌ 这里写没用!
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
为什么没用?
  - WPF 内部可能直接调 SetValue,绕过 CLR 包装器
  - 比如绑定、动画、样式 Setter 都不经过你的 wrapper
  - 副作用必须放在 PropertyChangedCallback

✅ 正确:
  private static void OnProgressChanged(DependencyObject d, ...)
  {
      ((CircularProgress)d).InvalidateVisual();
  }

11.2 默认值不能是引用类型实例

1
2
3
4
// ❌ 错误:所有实例共享同一个 Brush
new PropertyMetadata(new SolidColorBrush(Colors.Red))

// 修改默认值会"污染"所有实例
1
2
3
4
正确做法:
  1. 默认值用不可变类型(颜色、数字、字符串)
  2. 或在静态构造函数里注册默认值并 freeze
  3. 复杂类型默认值用 null + PropertyChanged 时延迟创建

11.3 不要用普通属性触发 DP 变更

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ❌
public double Progress
{
    get => ...;
    set
    {
        _progress = value;  // ❌ 直接改字段
        SetValue(ProgressProperty, value);  // ✅ 必须走 SetValue
    }
}

11.4 注册时类型要匹配

1
2
3
// ❌ 注册类型是 int,但 CLR 属性是 double
DependencyProperty.Register("Progress", typeof(int), ...)
public double Progress { ... }
1
2
3
WPF 不会编译期检查这个匹配
运行时会抛 InvalidCastException(难调试)
→ 注册类型和 CLR 属性类型必须严格一致

十二、小结

本文深入 WPF 的属性系统:

  • CLR 属性的四大局限(默认值/继承/通知/优先级)
  • DependencyProperty.Register 与全局哈希表
  • 稀疏存储模型(EffectiveValueEntry 数组)
  • 属性值 11 级优先级
  • PropertyMetadata 三回调链(Validate → Coerce → PropertyChanged)
  • 附加属性(RegisterAttached)的设计哲学
  • 属性继承(FrameworkPropertyMetadataOptions.Inherits)
  • DP 作为绑定目标
  • 实战:CircularProgress 控件(覆盖注册、校验、变更回调)
1
2
3
4
记住三句话:
  1. DP 不是"另一种属性",是"分层属性系统"——多源优先级合并
  2. SetValue 是入口——校验、强制、通知、绑定、动画都从这进
  3. CLR 属性包装器只是"门面",别在里面写逻辑(会被绕过)

下一篇将进入路由事件系统——WPF 的另一个地基。它和依赖属性一脉相承,理解了 DP,再看路由事件会非常顺。

Licensed under CC BY-NC-SA 4.0