🎓 严老师 · C# / WPF 教学
← 全部课程 Day 15 Phase 3 第 2 课

📚 严老师·Day 15 | WPF 依赖属性(DependencyProperty)核心

4推送消息数
21652字符数
2026-08-14教学日期
<!-- message_id: om_x100b68d1febdb8a0b31eaf5d765064d --> 📚 严老师·Day 15 | WPF 依赖属性(DependencyProperty)核心 **Phase 3 第 2 课**。WPF 属性系统的**基石**。学完这个,你对 DP 的认知就超过 80% 的 WPF 开发者。 ─── 今日知识点:依赖属性(DependencyProperty) 🎯 是什么 WPF 依赖属性(DP)是 WPF 属性系统的**核心基石**。它是一个 **静态注册 + 动态存储** 的属性系统,由两部分组成: • **标识符**:DependencyProperty 静态字段(static readonly),**全局单例** • **存储**:DependencyObject 内部维护的 EffectiveValueEntry[] 数组(**按需懒分配**) **核心定义**: DP 是一个**值解析框架** —— 当你 GetValue(dp) 时,WPF 会按 **11 级优先级** 从动画、绑定、本地值、样式、继承等来源**计算**最终值,而不是简单的"读字段"。 ─── 🤔 为什么(CLR 属性的 9 大局限) | # | 局限 | CLR 属性 | DP | | --- | -------------------------------------- | ----------------------------- | ----- | | 1 | 值继承(子元素继承父元素) | ❌ | ✅ | | 2 | 数据绑定(Binding) | ❌ | ✅ | | 3 | 动画(Storyboard) | ❌ | ✅ | | 4 | 样式 Setter(<Setter>) | ❌ | ✅ | | 5 | 模板 TemplatedParent | ❌ | ✅ | | 6 | 强制值(CoerceValueCallback) | ❌ | ✅ | | 7 | 验证回调(ValidateValueCallback) | ❌ | ✅ | | 8 | 变更通知 + 依赖链 | ❌(需手写 INotifyPropertyChanged) | ✅(自动) | | 9 | 资源引用(StaticResource / DynamicResource) | ❌ | ✅ | 💡 关键洞察:WPF 为什么"不抛弃"CLR 属性? | 维度 | DP | CLR 属性 | | ---------------- | ------------- | ------------ | | 编译时类型检查 | ❌(运行时才报错) | ✅(编译期拦截) | | IDE IntelliSense | ❌ | ✅ | | XAML 设计器智能提示 | ❌ | ✅ | | 重构友好 | ❌ | ✅(rename 同步) | | 普通访问性能 | 慢(查表 + 优先级计算) | 快(直接读字段) | | 静态分析 | 弱 | 强 | **结论**:WPF 的设计哲学是 **CLR 属性做"编译期门面" + DP 做"运行时能力"**。CLR 包装的 get/set 必须**纯转发**(只调 GetValue/SetValue),**禁止有任何业务逻辑** —— 业务逻辑统一放 3 个回调里。 ─── 🛠️ 怎么用(完整生产代码) using System.Windows; using System.Windows.Controls; using System.Windows.Media; public class NumericUpDown : Control { // ============== ① DP 注册:静态字段、类加载时执行一次 ============== public static readonly DependencyProperty ValueProperty = DependencyProperty.Register( name: nameof(Value), propertyType: typeof(int), ownerType: typeof(NumericUpDown), typeMetadata: new FrameworkPropertyMetadata( defaultValue: 0, FrameworkPropertyMetadataOptions.AffectsRender | FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, propertyChangedCallback: OnValueChanged, // ③ 变更回调(最后) coerceValueCallback: CoerceValue), // ② 强制值回调(中间) validateValueCallback: ValidateValue); // ① 验证回调(最先) // ============== ② CLR 包装:纯转发,禁止有业务逻辑 ============== public int Value { get => (int)GetValue(ValueProperty); set => SetValue(ValueProperty, value); } // ① 验证值(最早,非法值直接抛 ArgumentException) private static bool ValidateValue(object value) => value is int; // ② 强制值(调整到 [Min, Max] 范围) private static object CoerceValue(DependencyObject d, object baseValue) { var ctrl = (NumericUpDown)d; return Math.Clamp((int)baseValue, ctrl.Min, ctrl.Max); } // ③ 值变更(最后,值已确定,触发业务逻辑) private static void OnValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) => ((NumericUpDown)d).RaiseValueChanged((int)e.OldValue, (int)e.NewValue); protected virtual void RaiseValueChanged(int oldValue, int newValue) { /* 业务 */ } // Min/Max DP(改了之后要触发 Value 的 Coerce) public static readonly DependencyProperty MinProperty = DependencyProperty.Register(nameof(Min), typeof(int), typeof(NumericUpDown), new PropertyMetadata(0, (d, e) => ((NumericUpDown)d).CoerceValue(ValueProperty))); public int Min { get => (int)GetValue(MinProperty); set => SetValue(MinProperty, value); } public static readonly DependencyProperty MaxProperty = DependencyProperty.Register(nameof(Max), typeof(int), typeof(NumericUpDown), new PropertyMetadata(100, (d, e) => ((NumericUpDown)d).CoerceValue(ValueProperty))); public int Max { get => (int)GetValue(MaxProperty); set => SetValue(MaxProperty, value); } } XAML 中使用: <local:NumericUpDown Value="50" Min="0" Max="100" x:Name="ctrl" /> 📊 三大回调执行顺序 | 顺序 | 回调 | 作用 | 失败行为 | | --- | ----------------------- | --------- | ------------------------------ | | ① | ValidateValueCallback | 验证值的合法性 | 返回 false → 抛 ArgumentException | | ② | CoerceValueCallback | 把值强制到合法范围 | 返回 baseValue 或调整后的值 | | ③ | PropertyChangedCallback | 通知值已变更 | 仅通知,无返回 | **关键洞察**: • ValidateValue = **门卫**(拒绝非法值) • CoerceValue = **矫正师**(调整值到合法范围) • PropertyChanged = **广播员**(通知外界) ─── ⚠️ 常见误区(8 个高频陷阱) 1. **DP 注册必须在静态字段** —— static readonly 是**类加载期单次执行**的保证,绝不能在实例构造里 Register 2. **CLR 包装必须纯转发** —— 业务逻辑写进 get/set 是大忌(XAML 赋值、动画、绑定都不走 CLR 包装) 3. **SetValue 不触发 CLR 属性 setter** —— DP 内部 PropertyChanged 不会回调 CLR 包装的 set(WPF 设计) 4. **CoerceValue 内 SetValue(自己) = 无限递归** —— 必须直接 return 新值,或用 SetCurrentValue 5. **ClearValue ≠ SetValue(defaultValue)** —— ClearValue 删除本地值(回到继承/默认),SetValue(defaultValue) 是显式本地值(优先级最高的本地层) 6. **RegisterAttached ≠ 普通 DP** —— 附加属性(Attached Property)是"寄生"在其他类上的 DP(如 Grid.Row),**不能写 CLR 包装** 7. **RegisterReadOnly 才是只读 DP 的正确方式** —— 用 DependencyPropertyKey 保护 SetValue,外部只能 GetValue 8. **OverrideMetadata vs AddOwner 区别** —— 前者是**改变继承类的元数据**(同 DP),后者是**把别人的 DP 借到自己类上**(多 Owner) ─── 🔗 关联知识网络(对接未来 Phase 3-4 课程) • **Day 14 WPF 架构** → DP 注册走 static readonly 字段反射 → 是 WPF **不能 NativeAOT** 的第二大根因 • **Day 11 ThreadPool** → DispatcherSynchronizationContext 让 await 后自动回 UI 线程,触发 OnValueChanged 在 UI 线程执行 • **Day 6 反射** → DependencyPropertyDescriptor 是反射访问 DP 的入口 • **Day 8 GC** → DependencyObject._effectiveValues 数组,只在显式 SetValue 时分配槽位,**未设置的 DP 不占空间** • **未讲 Day 17 路由事件** → RoutedEventArgs.OriginalSource 本质是 DP • **未讲 Phase 4 数据绑定** → Binding 的目标**必须是 DP**(CLR 属性无法绑定) • **未讲 Phase 4 样式/模板** → <Setter Property="..."> 只能填 DP,{TemplateBinding} 只能绑 DP • **未讲 Phase 4 动画** → Storyboard 改写 DP 时优先级 **2️⃣ 最高**(超过本地值) ─── (第 2 段:三道递进面试题,稍后送达) --- <!-- message_id: om_x100b68d1fe28a8a8b2714b03d82204e --> 📝 Day 15 | 三道递进面试题 Q1(基础概念) **请简述 WPF 中 DependencyProperty 与普通 CLR 属性的本质区别,并列举依赖属性的 5 大核心能力。** **追问:WPF 为什么不完全抛弃 CLR 属性,反而要为每个 DP 保留 CLR 包装?** **考察点**:对 DP 本质的准确理解,以及 WPF "CLR 门面 + DP 能力" 的设计哲学。 Q2(原理与辨析) **WPF 依赖属性的值解析优先级是怎样的?请至少列出 8 级。** **追问 1**:当 CoerceValueCallback、PropertyChangedCallback、ValidateValueCallback 三个回调**同时存在**时,执行顺序是什么? **追问 2**:SetValue(dp, newValue) 触发的**完整事件链**是怎样的(用伪代码或调用序列说明)? **考察点**:对 DP 值解析机制的深度理解,以及三大回调时序的精确把握。 Q3(实战与深度) 在 WPF 中实现一个 **PasswordStrengthIndicator** 控件,要求: 1. 接受 Password 字符串作为依赖属性(**默认空字符串**) 2. 实时计算密码强度(**Weak / Medium / Strong**)作为**只读**依赖属性 Strength 3. Password 变化时**自动重算** Strength 4. 当 Password 为空时,Strength **强制为 Weak**(即使 Coerce 试图返回 Strong) 5. 用 Foreground 颜色区分(Weak=红 / Medium=黄 / Strong=绿) **请给出完整生产级代码**: - DP 注册(包含 3 个关键回调的完整签名) - Password 变更触发 Strength 重算的关键代码 - SetCurrentValue vs SetValue 的选择理由 - XAML 演示 - **至少指出 4 个生产级陷阱及解决方案** **考察点**:对 RegisterReadOnly / DependencyPropertyKey / SetCurrentValue / CoerceValueCallback 协同的实战掌握。 (第 3 段:全面复盘讲解,稍后送达) --- <!-- message_id: om_x100b68d1fcf2f4a0b11a101b381d6e2 --> 🎯 Day 15 | 全面复盘讲解(上篇:Q1 + Q2) ─── Q1 复盘 ✅ 答案要点 1. **本质区别**: • **CLR 属性** = **编译期字段**(IL 指令直接读字段,0 间接) • **DP** = **运行时值解析系统**(每次 GetValue 都要查 11 级优先级表,可能触发 EffectiveValueEntry 重新计算) 2. **5 大核心能力**(任选 5 个即可): 3. 数据绑定(Binding) 4. 样式(Style.Setter) 5. 动画(Storyboard) 6. 值继承(Inheritance) 7. 强制值(CoerceValueCallback) 8. 资源引用(StaticResource / DynamicResource) 9. 模板绑定(TemplateBinding / TemplatedParent) 10. **不抛弃 CLR 包装的 3 大原因**: • 编译时类型检查(Value="abc" 编译期报错 vs DP 运行时抛 XamlParseException) • IDE IntelliSense + XAML 设计器智能提示 • 重构友好(rename 同步所有引用) 11. **隐藏第 4 点(加分项)**:CLR 包装是 XAML 解析器发现属性的入口 —— XAML 只认 public CLR 属性 + 匹配的 DP 静态字段 🔍 深度解析 很多候选人回答时会把"5 大能力"答成"绑定、样式、动画、值继承、强制值",但漏掉一个关键点:**DP 的"动态存储"本身就是核心能力** —— DependencyObject 内部 EffectiveValueEntry[] 按需分配,未设置的 DP **零内存占用**。1000 个 Button 只设 10 个的 Width,只有 10 个槽位被分配。 另一个常被忽略的设计点:**DP 是进程级全局单例**。Button.WidthProperty 整个 AppDomain 只有一个,所有 Button 实例共享,只是各自的 EffectiveValueEntry 不同。这是 WPF "**内存换性能**" 的典型设计 —— 注册一次,使用无数次。 ⚠️ 易错点 1. ❌ 答成"DP 是线程安全的" → **半对**。DP 自身注册是线程安全的(类构造期单次执行),但 SetValue 不是线程安全的,**跨线程 SetValue 必须 Dispatcher.Invoke** 2. ❌ 漏掉"CLR 包装"是 XAML 入口 → 漏掉这个就答不到满分 3. ❌ 把"值继承"理解成"继承链上的属性" → 错,值继承是**沿着视觉树**向下传递(子元素没显式设置时继承父元素的值,如 FontSize、Foreground) 4. ❌ 把 DP 当成"高级版 CLR 属性" → 错,DP 是**值解析框架**,CLR 属性是**编译期字段**,二者是**正交**的两个系统,WPF 用 CLR 包装把二者桥接 ─── Q2 复盘 ✅ 答案要点 **WPF 依赖属性值解析优先级**(11 级,合并为 8 大类): | 优先级 | 来源 | 说明 | 覆盖行为 | | ------ | --------------- | --------------------------------------- | ------------- | | 1️⃣ 最高 | 强制值(Coerced) | 通过 CoerceValueCallback | 任何来源都被 Coerce | | 2️⃣ | 动画值(Animation) | Storyboard / DynamicResource 动画 | 覆盖本地值 | | 3️⃣ | 本地值(Local) | SetValue 显式设置 / XAML 赋值 | 覆盖样式/继承 | | 4️⃣ | TemplatedParent | 模板内的属性 | 模板生效 | | 5️⃣ | 样式触发器(Triggers) | Trigger / DataTrigger / MultiTrigger | 覆盖 Setter | | 6️⃣ | 样式 Setter | <Style><Setter Property="..."/></Style> | 覆盖主题样式 | | 7️⃣ | 主题样式触发器 | ThemeStyle.Triggers | 覆盖主题 Setter | | 8️⃣ | 主题样式 Setter | Generic.xaml 主题默认 | 覆盖继承值 | | 9️⃣ | 继承值(Inherited) | 父元素 AddOwner(Inherits=true) 的 DP | 子元素默认 | | 🔟 最低 | 默认值 | Register 时的 defaultValue | 兜底 | **三大回调执行顺序**(SetValue 完整事件链伪代码): void SetValue(DependencyProperty dp, object newValue) { // ① 验证值(最早,非法值直接抛 ArgumentException) if (dp.ValidateValueCallback != null && !dp.ValidateValueCallback(newValue)) throw new ArgumentException($"Invalid value for {dp.Name}"); // ② 强制值(把值调整到合法范围) object coerced = dp.CoerceValueCallback != null ? dp.CoerceValueCallback(this, newValue) : newValue; // ③ 检测是否真变化(短路优化) object oldValue = GetValue(dp); if (object.Equals(oldValue, coerced)) return; // 没变就什么都不做 // ④ 写入 EffectiveValueEntry 内部存储 SetEffectiveValue(dp, coerced, EffectiveValueSource.Local); // ⑤ 触发值变更回调 dp.PropertyChangedCallback?.Invoke(this, new DependencyPropertyChangedEventArgs(dp, oldValue, coerced)); // ⑥ 触发 PropertyChanged 事件 + 通知数据绑定源 OnPropertyChanged(dp.Name); // 给 INotifyPropertyChanged 订阅者 BindingExpression.UpdateSource(); // 双向绑定反向通知 } **执行顺序总结**:**Validate → Coerce → PropertyChanged** 🔍 深度解析 **Q2 真正考察的是对"值解析"的本质理解 —— DP 不是字段,是表达式**。WPF 内部用 **表达式树** 表示 DP 的"当前值来源": EffectiveValueEntry { DependencyProperty dp; object value; EffectiveValueSource source; // Local / Inherited / Animated / Coerced / TemplatedParent / Style / Default Expression expression; // 绑定/动态资源的"未求值"表达式 } GetValue 时的实际行为: 1. **找到最高优先级的源**(从 1️⃣ 到 🔟 扫描) 2. **如果该源是 Expression(绑定/动态资源)**,调用 GetValue() 求值 3. **应用 CoerceValueCallback**(如果存在) 4. **返回最终值** 这就是为什么 Binding 不需要 SetValue 就能改变 DP —— WPF 在 Expression 层就拦截了。 ⚠️ 易错点 1. ❌ **答成"动画 > 本地值 > 样式 > 继承"**(把强制值漏了) → 强制值永远是**最顶层**,任何来源都被 Coerce 2. ❌ **混淆"样式触发器"和"样式 Setter"** → 触发器优先级**高于** Setter(Trigger 条件命中时覆盖 Setter) 3. ❌ **答"Coerce 在 PropertyChanged 之后"** → 错!Coerce 永远在 PropertyChanged **之前** 4. ❌ **漏掉 SetValue 内部短路** → 同一个值连续 SetValue,PropertyChangedCallback 不会重复触发 5. ❌ **混淆 TemplatedParent 和 TemplateBinding** → TemplatedParent 是**值来源**({RelativeSource TemplatedParent}),TemplateBinding 是 XAML 标记扩展 6. ❌ **不知道 Inherited 必须在元数据里显式标记** → FrameworkPropertyMetadataOptions.Inherits,否则值不会沿视觉树向下传 7. ❌ **默认值的"陷阱"** → 默认值是**最低优先级**,但 SetValue(defaultValue) 是**本地值**(最高优先级之一),二者完全不同 ─── (第 4 段:Q3 实战复盘 + 扩展思考,稍后送达) --- <!-- message_id: om_x100b68d1fd1c4ca4b4ae22e595d8cd9 --> 🎯 Day 15 | 全面复盘讲解(下篇:Q3 实战) ─── Q3 复盘 ✅ 完整生产级代码 public enum PasswordStrength { Weak, Medium, Strong } public class PasswordStrengthIndicator : Control { // ============== ① Password DP(双向绑定 + 影响渲染) ============== public static readonly DependencyProperty PasswordProperty = DependencyProperty.Register( nameof(Password), typeof(string), typeof(PasswordStrengthIndicator), new FrameworkPropertyMetadata( string.Empty, FrameworkPropertyMetadataOptions.AffectsRender | FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnPasswordChanged)); // 密码变 → 触发重算 public string Password { get => (string)GetValue(PasswordProperty); set => SetValue(PasswordProperty, value); } // ============== ② Strength DP(只读,RegisterReadOnly) ============== private static readonly DependencyPropertyKey StrengthPropertyKey = DependencyProperty.RegisterReadOnly( nameof(Strength), typeof(PasswordStrength), typeof(PasswordStrengthIndicator), new PropertyMetadata( PasswordStrength.Weak, null, CoerceStrength)); // ⭐ 关键:Coerce 强制空密码为 Weak public static readonly DependencyProperty StrengthProperty = StrengthPropertyKey.DependencyProperty; // 公开给外部只读访问 public PasswordStrength Strength => (PasswordStrength)GetValue(StrengthProperty); // ============== ③ Foreground DP(Inherits 让子元素继承) ============== public new static readonly DependencyProperty ForegroundProperty = Control.ForegroundProperty.AddOwner( typeof(PasswordStrengthIndicator), new FrameworkPropertyMetadata( Brushes.Gray, FrameworkPropertyMetadataOptions.AffectsRender | FrameworkPropertyMetadataOptions.Inherits)); // ============== ④ 密码变更 → 重算强度(用 SetCurrentValue) ============== private static void OnPasswordChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var ctrl = (PasswordStrengthIndicator)d; var pwd = (string?)e.NewValue ?? string.Empty; var newStrength = ComputeStrength(pwd); // ⭐ 用 SetCurrentValue(只改当前值,保留 Coerce/绑定/动画/样式触发器) ctrl.SetCurrentValue(StrengthPropertyKey, newStrength); // 若用 SetValue → 写入本地层,会"覆盖" Coerce(下一帧才被 Coerce 纠正) } // ============== ⑤ 强制值:空密码必须 Weak(题目核心陷阱) ============== private static object CoerceStrength(DependencyObject d, object baseValue) { var ctrl = (PasswordStrengthIndicator)d; // 即使 OnPasswordChanged 计算出 Strong,空密码也必须 Weak return string.IsNullOrEmpty(ctrl.Password) ? PasswordStrength.Weak : baseValue; } // ============== ⑥ 强度算法(生产级简化版) ============== private static PasswordStrength ComputeStrength(string pwd) { if (string.IsNullOrEmpty(pwd)) return PasswordStrength.Weak; if (pwd.Length < 6) return PasswordStrength.Weak; bool hasUpper = pwd.Any(char.IsUpper); bool hasDigit = pwd.Any(char.IsDigit); bool hasSpecial = pwd.Any(c => !char.IsLetterOrDigit(c)); int score = (hasUpper ? 1 : 0) + (hasDigit ? 1 : 0) + (hasSpecial ? 1 : 0); return score switch { 0 => PasswordStrength.Weak, 1 => PasswordStrength.Medium, _ => PasswordStrength.Strong }; } } **XAML 演示**: <Window xmlns:local="clr-namespace:MyApp.Controls"> <StackPanel> <TextBox x:Name="pwdBox" /> <!-- 双向绑定:用户输入 → Password,代码改 Password → TextBox 文本 --> <local:PasswordStrengthIndicator> <local:PasswordStrengthIndicator.Password> <Binding ElementName="pwdBox" Path="Text" Mode="TwoWay" UpdateSourceTrigger="PropertyChanged" /> </local:PasswordStrengthIndicator.Password> </local:PasswordStrengthIndicator> </StackPanel> </Window> ─── 🔍 深度解析:SetValue vs SetCurrentValue 的本质区别 | 行为 | SetValue(dp, v) | SetCurrentValue(dp, v) | | ------------------ | --------------- | ---------------------- | | 写入层级 | 本地值(Local) | 当前值(Current) | | 打断动画 | ✅(动画被覆盖) | ❌(保留动画优先级) | | 打断数据绑定 | ✅(绑定源被清空) | ❌(保留绑定源) | | 打断样式触发器 | ✅ | ❌ | | 触发 PropertyChanged | ✅ | ✅ | | 触发 Coerce | ✅ | ✅ | | 适用场景 | 业务层主动设值 | 回调内更新(避免打断其他源) | **关键洞察**: • SetValue = **完全掌控**(清空所有其他源) • SetCurrentValue = **柔和更新**(只改当前展示值,不动其他优先级) **Q3 的核心考点**:在 OnPasswordChanged 内更新 Strength,必须用 SetCurrentValue。如果用 SetValue(StrengthProperty, ...),会: 1. 把 Strength 写入**本地层**(优先级 3️⃣) 2. 下一帧 WPF 做 Coerce 时,看到本地值已经是 Strong,就不再 coerce 了 3. 导致**空密码也被显示为 Strong** → 题目要求 4️⃣ 失败 ─── ⚠️ 5 大生产级陷阱 + 解决方案 | # | 陷阱 | 根因 | 解决方案 | | --- | -------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------ | | T1 | Password 双向绑定死循环 | TextBox.Text ↔ Password 双向 + Password 触发 Coerce → 无限循环 | 单向 Mode=OneWay,或 UpdateSourceTrigger=PropertyChanged(用户输入立即同步,但 Password→TextBox 单向) | | T2 | Coerce 无限递归 | CoerceStrength 内 SetValue(StrengthProperty, ...) → 递归 | 直接 return 计算结果,绝不在 Coerce 内 SetValue 自己 | | T3 | OnPasswordChanged 用 SetValue 清空 Coerce | SetValue 写入本地层,Coerce 看到本地值不再 coerce | 改用 SetCurrentValue(StrengthPropertyKey, ...) —— 只改当前值,Coerce 仍生效 | | T4 | Strength DP 没注册为只读 | 外部可以直接 SetValue(StrengthProperty, Weak) 绕过 Coerce | 用 RegisterReadOnly + 私有 StrengthPropertyKey 保护 SetValue | | T5 | Foreground 没标记 Inherits | 子元素(如嵌入的 TextBlock)无法继承颜色 | FrameworkPropertyMetadataOptions.Inherits 标志 + AddOwner 到 Control.ForegroundProperty | **隐藏陷阱(加分项)**: • T6: ComputeStrength 是**纯函数**,但**生产环境需要异步**(防 HaveIBeenPwned API 调用阻塞 UI)→ async void + ConfigureAwait(false) + SetCurrentValue 在 finally 块 • T7: OnPasswordChanged 内**不要抛异常** —— 会打断 WPF 内部状态机,导致 EffectiveValueEntry 不一致 • T8: ForegroundProperty 用 AddOwner 而非 new PropertyMetadata —— 直接复用 Control 的 Foreground,避免两个不同名的 Foreground DP ─── 🚀 扩展思考(2 个引导性问题) **Q-A**:如何让这个控件支持**异步密码强度计算**(例如调用 HaveIBeenPwned API 检查泄露)? • 提示:ComputeStrength 改为 Task<PasswordStrength>,OnPasswordChanged 改为 async void,用 CancellationTokenSource 取消上一次未完成的计算(防抖动),SetCurrentValue 在 try/finally 中保证执行 • 进阶:UI 显示"检查中..."状态(用 IsChecking DP + IsChecked 触发器联动) **Q-B**:如何在 ControlTemplate 中用 TemplateBinding 把 Strength 暴露给模板里的 TextBlock? • 提示:模板根元素 <ControlTemplate TargetType="local:PasswordStrengthIndicator"> • 子元素 <TextBlock Text="{TemplateBinding Strength}" /> —— TemplateBinding 走 4️⃣ **TemplatedParent 优先级**(高于样式 Setter) • 进阶:把 Foreground 改成颜色转换器(Strength → Brush),用 TemplateBinding Foreground 暴露 ─── 📊 今日总结 | 维度 | 关键收获 | | ----- | -------------------------------------------------------------------------------------------- | | 核心抽象 | DP = static readonly DependencyProperty 标识符 + DependencyObject 内部 EffectiveValueEntry[] 动态存储 | | 设计哲学 | CLR 属性 = 编译期门面,DP = 运行时能力,二者通过 CLR 包装桥接 | | 优先级系统 | 11 级优先级,Coerce 最高,动画次之,本地值再次,默认值最低 | | 3 大回调 | Validate(门卫)→ Coerce(矫正师)→ PropertyChanged(广播员) | | 实战铁律 | 回调内更新自己用 SetCurrentValue,Coerce 内绝不 SetValue 自己,只读 DP 必须 RegisterReadOnly | | 核心易错点 | SetValue 写本地层会打断 Coerce/绑定/动画,SetCurrentValue 才是"柔和更新" | ─── 🔗 关联知识网络(今日新建立) • **Day 14 WPF 架构** → DP 注册走 static readonly 反射,剪裁器看不到运行时枚举 → 是 WPF 不能 NativeAOT 第二大根因 • **Day 11 ThreadPool** → DispatcherSynchronizationContext 让 OnPasswordChanged 自动在 UI 线程执行 • **Day 6 反射** → DependencyPropertyDescriptor 是反射访问 DP 的元数据描述器 • **Day 8 GC** → EffectiveValueEntry[] 按需分配,未设置的 DP 零内存占用 • **未讲 Day 17 路由事件** → RoutedEventArgs.OriginalSource 本质是 DP • **未讲 Phase 4 数据绑定** → Binding 目标必须是 DP • **未讲 Phase 4 样式/动画** → <Setter> 和 Storyboard 只能操作 DP ─── 🎯 下次起点(预告) **Day 16:路由事件(Routed Event)** —— DP 解决了"值怎么存",路由事件解决"事件怎么传"。WPF 的事件系统是**树形传播**(冒泡 + 隧道 + 直接),不是 WinForms 的"控件各自为政"。包括: • 路由事件 vs CLR 事件的本质区别 • 3 种路由策略(Bubble / Tunnel / Direct)的执行顺序 • RoutedEventArgs 的 Source / OriginalSource 区别 • 自定义路由事件 + EventManager.RegisterRoutedEvent + RaiseEvent • 附加事件(Attached Event)与路由事件的关系 • WPF 路由事件 5 大使用陷阱(已注册事件不可修改路由策略 / 隧道事件必须配对 / Handled 一旦置 true 就无法重置 / 路由事件在模板中的传播 / CommandManager 与路由事件的关系) ─── 📚 严老师 · 2026-08-14 · Day 15 完成 ⏰ 等待主人回复(Q1/Q2/Q3 任选作答,我来评估 + 推进)

📡 推送信息

教学日期
2026-08-14
所属阶段
Phase 3 第 2 课
消息条数
4 条(飞书 DM teacher-yan-bot 推送)
消息 ID
om_x100b68d1febdb8a0b31eaf5d765064dom_x100b68d1fe28a8a8b2714b03d82204eom_x100b68d1fcf2f4a0b11a101b381d6e2om_x100b68d1fd1c4ca4b4ae22e595d8cd9
数据来源
feishu_via_memory_mid