🎓 严老师 · C# / WPF 教学
← 全部课程 Day 23 Phase 4 第 4 课

📚 **严老师 · Day 23 教学(Phase 4 第 4 课)**

4推送消息数
19403字符数
2026-08-23教学日期
<!-- message_id: om_x100b6797c31854a4b4b7abde009e731 --> 📚 **严老师 · Day 23 教学(Phase 4 第 4 课)** 📌 **今日主题:WPF 自定义控件开发(Custom Control)** 自定义控件是 WPF 进阶的"金字塔尖"——它把我们前面 Day 14-22 学的所有知识点(架构、DP、路由事件、视觉树、布局、资源、绑定、命令、样式与模板)**全部串联起来**。学完这一课,你会真正体会到 WPF "UI 与逻辑分离 + 模板化"的设计哲学带来的复利。 —— 一、今日知识点(4 维度) 🔹 1. 是什么:自定义控件 vs 用户控件的本质区别 WPF 里有两种"自定义 UI 组件"——它们看起来都能复用,**但本质完全不同**: | 维度 | 用户控件(UserControl) | 自定义控件(Custom Control) | | ------ | ------------------------------- | ------------------------------------------ | | 继承链 | 继承自 UserControl(一个具体类) | 继承自 Control / FrameworkElement 等通用基类 | | 代码位置 | XAML + C# 代码隐藏与 UserControl 强耦合 | C# 在控件库里,XAML 在独立的 Themes/Generic.xaml | | 主题化 | ❌ 不能主题化(换皮肤要重新实现) | ✅ 天然支持主题切换(Light/Dark/HighContrast) | | 复用粒度 | 整个 UserControl 实例("黑盒"复用) | 控件模板可被任意替换("白盒"复用) | | 谁来扩展 | 复制粘贴后改 XAML | 派生 + 添加新 DP / 路由事件 | | 典型场景 | 业务组件、复合控件(带业务逻辑的搜索框) | UI 原语、可主题化的基础控件(按钮、开关、星级) | | 模板定义位置 | 内嵌在 UserControl.xaml 里 | 独立的 Themes/Generic.xaml 配套 DefaultStyleKey | **一句话本质区别**: • **用户控件** = "组合"(Composition)→ 强类型 XAML 黑盒 • **自定义控件** = "派生"(Inheritance)→ 可主题化、可换肤、可扩展的 UI 原语 **洞察**:WPF 所有内置控件(Button / TextBox / ListBox / DataGrid)**全部**是"自定义控件"。没有内置"用户控件"——那是 WPF 团队为了方便业务代码组合而提供的便利类。 —— 🔜 下面我会继续讲:**为什么这么设计、自定义控件 5 大核心概念、完整生产实战 + 3 道面试题**。 --- <!-- message_id: om_x100b6797c07ec0a8b045913f75d99f8 --> 📚 **严老师 · Day 23(第 2 段)** 🔹 2. 为什么:自定义控件的 5 大核心价值 WPF 团队把"自定义控件"作为 UI 原语的根本范式,是因为它带来 5 大不可替代的价值: **① 可主题化(Themability)** 所有主题资源都在 Themes/Generic.xaml 里。换皮肤时只需替换这个字典,控件代码 **零修改**。这也解释了为什么 WPF 内置所有控件都基于"自定义控件"——它们能跟随系统主题自动切换 Light/Dark/HighContrast。 **② 模板可替换(Template Replaceable)** 消费者可以给同一个 ToggleSwitch 控件提供完全不同的 ControlTemplate——圆形开关、方块开关、滑动开关,**控件行为不变,外观千变万化**。这就是 Day 22 学的"模板分离"的复利。 **③ 扩展性极强(Extensibility)** 派生新控件时,只需要: - 添加新的 DependencyProperty(Day 15) - 添加新的 RoutedEvent(Day 16) - 暴露新的 CommandBinding 无需修改任何已有的"开关"控件代码。 **④ 复用粒度最优(Reusable Across Apps)** ToggleSwitch 写一次,可以在 100 个不同业务项目里直接 xmlns:controls="clr-namespace:MyCompany.Wpf.Controls;assembly=..." 引用。这是用户控件做不到的(用户控件一旦带业务逻辑就强耦合)。 **⑤ 统一的"PART_*"约定(Designer-Developer Collaboration)** 设计师改 Templates/MyToggleSwitch.xaml 时,他知道 PART_Thumb、PART_Rail 的命名——这是和开发者之间的**契约**。设计师和开发者并行工作,互不阻塞。 —— 🔹 3. 怎么用:自定义控件 5 大核心概念 **概念 1:Themes/Generic.xaml + [assembly: ThemeInfo] 声明** // 控件库 Properties/AssemblyInfo.cs 或任何 .cs 文件 using System.Windows; [assembly: ThemeInfo( resourceDictionaryLocation: ResourceDictionaryLocation.SourceAssembly, // Generic.xaml 在主程序集 resourceDictionarySourceName: "Themes/Generic.xaml" // 资源字典文件名 )] **WPF 加载 Generic.xaml 的完整流程**(这是面试必问点): 1. WPF 启动时扫描所有引用的程序集 2. 发现任何程序集带 [assembly: ThemeInfo] 特性 3. 注册 AssemblyResolve 回调 4. 当控件第一次被实例化时,WPF 调用 Control.DefaultStyleKey 查找样式 5. WPF 探测程序集中路径为 "Themes/<assembly-name>.<theme>.xaml" 的资源 6. 失败则退回 "Themes/Generic.xaml"(通用主题) 7. 加载后所有该控件类型的实例都套用这个默认样式 **关键洞察**: - **不写 [assembly: ThemeInfo] 也可以**——默认行为就是从主程序集找 Themes/Generic.xaml - 但**显式声明**有两个好处:① IDE 智能感知 ② AOT / IL 裁剪器能正确追踪资源 - **泛型主题**(Generic)= 不区分操作系统的中性样式 - **特定主题**:Themes/Aero.NormalColor.xaml / Themes/ Luna.NormalColor.xaml / Themes/Generic.xaml **概念 2:DefaultStyleKeyProperty.OverrideMetadata** 这是自定义控件的"灵魂调用"——不写它,Generic.xaml **永远找不到你的控件**: using System.Windows; using System.Windows.Controls; namespace MyCompany.Wpf.Controls { public class ToggleSwitch : Control // 注意不是 UserControl { static ToggleSwitch() { // ⭐ 这一行让 WPF 知道:"我的默认样式到 Generic.xaml 里去找 TargetType={x:Type ToggleSwitch} 的 Style" DefaultStyleKeyProperty.OverrideMetadata( typeof(ToggleSwitch), new FrameworkPropertyMetadata(typeof(ToggleSwitch))); } } } **原理**:每个 Control 派生类都继承一个 DefaultStyleKey DP(object 类型)。WPF 用这个 key 在资源字典里查找默认 Style。**OverrideMetadata 不是"重写"而是"提供本类型的默认值"**——父类 Control 提供的是 typeof(Control),所有派生类需要各自 Override 一份,否则都拿 Control 的样式。 —— 🔜 下一段讲:*PART_ 命名约定 + OnApplyTemplate + 自定义 DP / 路由事件生产模式 + OnRender vs DrawingVisual 性能对比 + ToggleSwitch 完整生产代码**。 --- <!-- message_id: om_x100b6797ded048a4b10a5fac0e5e0fc --> 📚 **严老师 · Day 23(第 3 段)** 🔹 3 续:3 大核心概念 **概念 3:PART_* 命名约定 + OnApplyTemplate** 这是 Day 17「视觉树与逻辑树」+ Day 22「样式与模板」的综合实战: public class ToggleSwitch : Control { // PART_* 是设计师和开发者之间的契约 private const string PartThumb = "PART_Thumb"; private const string PartRail = "PART_Rail"; private Border? _thumb; // 缓存模板元素引用 private Border? _rail; static ToggleSwitch() { DefaultStyleKeyProperty.OverrideMetadata( typeof(ToggleSwitch), new FrameworkPropertyMetadata(typeof(ToggleSwitch))); } public override void OnApplyTemplate() { // ⭐ 必须先调 base() —— 它负责模板展开 base.OnApplyTemplate(); // ⭐ 主题切换时 OnApplyTemplate 会再次调用!必须清理旧订阅 if (_thumb != null) { _thumb.MouseLeftButtonDown -= OnThumbClicked; } // ⭐ 唯一能 GetTemplateChild 的时机 _thumb = GetTemplateChild(PartThumb) as Border; _rail = GetTemplateChild(PartRail) as Border; if (_thumb != null) { _thumb.MouseLeftButtonDown += OnThumbClicked; } // 根据当前状态更新视觉 UpdateVisualState(); } private void OnThumbClicked(object sender, MouseButtonEventArgs e) { IsChecked = !IsChecked; } } **4 个铁律(务必记住)**: 1. **PART_ 是约定前缀** —— 设计师看到这个前缀就知道"这是代码会读的,不能改名/删掉" 2. **OnApplyTemplate 内必须 base() 优先** —— 否则模板树根本不展开 3. **缓存字段用 null 检查** —— 主题切换时这是"重新拿引用"信号 4. **GetTemplateChild 仅在 OnApplyTemplate 内有效** —— 其他地方调返回 null(Day 17 学过的) **概念 4:自定义 DP 标准模板(结合 Day 15)** public class ToggleSwitch : Control { // ========== IsChecked DP(核心业务属性) ========== public static readonly DependencyProperty IsCheckedProperty = DependencyProperty.Register( nameof(IsChecked), typeof(bool), typeof(ToggleSwitch), new FrameworkPropertyMetadata( false, // 默认值 FrameworkPropertyMetadataOptions.AffectsRender, // 改变时触发重绘 OnIsCheckedChanged, // PropertyChangedCallback CoerceIsChecked)); // CoerceValueCallback public bool IsChecked { get => (bool)GetValue(IsCheckedProperty); set => SetValue(IsCheckedProperty, value); } private static void OnIsCheckedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var ctrl = (ToggleSwitch)d; ctrl.UpdateVisualState(); // 触发视觉更新 ctrl.RaiseCheckedChangedEvent(); // 触发路由事件 } private static object CoerceIsChecked(DependencyObject d, object value) { // 例:如果未来有 IsEnabled 限制,可以在这里强制 return value is bool b && b; // NaN-style 防御:失败兜底 false } private void UpdateVisualState() { // ⭐ 关键:用 SetCurrentValue 而不是 SetValue!保留 Coerce & 绑定(Day 15 学的) if (_thumb != null) { _thumb.HorizontalAlignment = IsChecked ? HorizontalAlignment.Right : HorizontalAlignment.Left; } } // ========== IsCheckedNullable —— 可空三态 ========== public static readonly DependencyProperty IsCheckedNullableProperty = DependencyProperty.Register( nameof(IsCheckedNullable), typeof(bool?), typeof(ToggleSwitch), new FrameworkPropertyMetadata( false, FrameworkPropertyMetadataOptions.AffectsRender | FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnIsCheckedNullableChanged)); public bool? IsCheckedNullable { get => (bool?)GetValue(IsCheckedNullableProperty); set => SetValue(IsCheckedNullableProperty, value); } private static void OnIsCheckedNullableChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { var ctrl = (ToggleSwitch)d; if (e.NewValue is bool b) { ctrl.SetCurrentValue(IsCheckedProperty, b); // ⭐ SetCurrentValue 不打断绑定 } } } —— **概念 5:自定义路由事件标准模板(结合 Day 16)** public class ToggleSwitch : Control { // ========== CheckedChanged 路由事件 ========== public static readonly RoutedEvent CheckedChangedEvent = EventManager.RegisterRoutedEvent( nameof(CheckedChanged), RoutingStrategy.Bubble, // 向上冒泡 typeof(RoutedPropertyChangedEventHandler<bool>), typeof(ToggleSwitch)); public event RoutedPropertyChangedEventHandler<bool> CheckedChanged { add => AddHandler(CheckedChangedEvent, value); remove => RemoveHandler(CheckedChangedEvent, value); } private void RaiseCheckedChangedEvent() { var args = new RoutedPropertyChangedEventArgs<bool>( !IsChecked, // oldValue(夸张演示用 false) IsChecked, CheckedChangedEvent) { Source = this }; RaiseEvent(args); } // ========== Click 路由事件(暴露给 XAML 用) ========== public static readonly RoutedEvent ClickEvent = EventManager.RegisterRoutedEvent( nameof(Click), RoutingStrategy.Bubble, typeof(RoutedEventHandler), typeof(ToggleSwitch)); public event RoutedEventHandler Click { add => AddHandler(ClickEvent, value); remove => RemoveHandler(ClickEvent, value); } // 触发点击 protected virtual void OnClick() { var args = new RoutedEventArgs(ClickEvent, this); RaiseEvent(args); } } **3 个洞察**: • **为什么用 RoutedPropertyChangedEventArgs<bool>**——因为它带 OldValue/NewValue,订阅者能拿到变化细节 • **为什么不在 OnApplyTemplate 里直接触发**——而是把"状态变化"和"事件触发"分开。状态变化通过 OnIsCheckedChanged 回调处理,UI 无关 • **为什么 Click 是独立 RoutedEvent**——符合 WPF 内置惯例(Button.OnClick)。 —— **概念 5 续:OnRender 直接绘制 vs VisualHost + DrawingVisual 性能对比** 这是 WPF 性能优化的"分水岭": // ========== 方案 A:重写 OnRender(简单直观,但每次重绘都重画) ========== public class LedIndicator : FrameworkElement { protected override void OnRender(DrawingContext dc) { // ⭐ 每次 AffectsRender 触发都会调这里(包括父元素变化、主题切换等) var brush = IsOn ? Brushes.LimeGreen : Brushes.Gray; dc.DrawEllipse(brush, new Pen(Brushes.Black, 1), new Point(20, 20), 15, 15); } } // ========== 方案 B:VisualHost + DrawingVisual(性能最优) ========== public class HighPerfVisualHost : FrameworkElement { private readonly DrawingVisual _background = new(); private readonly DrawingVisual _indicator = new(); private readonly VisualCollection _children; // ⭐ VisualCollection 是必须的容器 public HighPerfVisualHost() { _children = new VisualCollection(this) { _background, _indicator }; // ⭐ 增删都要调 AddVisualChild / RemoveVisualChild + InvalidateMeasure(如果影响布局) AddVisualChild(_background); AddVisualChild(_indicator); } protected override int VisualChildrenCount => _children.Count; protected override Visual GetVisualChild(int index) => _children[index]; public void DrawStaticBackground() { using var dc = _background.RenderOpen(); dc.DrawRectangle(Brushes.Black, null, new Rect(0, 0, 100, 100)); // 背景只画一次!如果改成 OnRender 每次布局都会重画 } public void UpdateIndicator(bool isOn) { using var dc = _indicator.RenderOpen(); dc.DrawEllipse(isOn ? Brushes.LimeGreen : Brushes.Gray, null, new Point(50, 50), 15, 15); // 只画指示器,不动背景 —— 局部更新,性能最佳 } } | 维度 | OnRender 重写 | VisualHost + DrawingVisual | | -------- | -------------- | --------------------------------------- | | 使用门槛 | ⭐ 简单(一行方法) | 复杂(要 VisualCollection + AddVisualChild) | | 命中测试 | ✅ 自动支持 | ❌ 必须重写 HitTestCore 自己实现 | | 鼠标事件 | ✅ 自动支持 | ❌ 必须用 AddHandler 手动 + 通过路由事件暴露 | | 重绘成本 | 整个 OnRender 重跑 | 只重画变化的那个 DrawingVisual | | 性能(频繁更新) | 差(特别是复杂内容) | ⭐ 最优(按需重绘) | | 适用场景 | 简单控件、静态显示、低频更新 | 实时数据可视化、图表、密集点阵 | **选择铁律**: • **简单控件 → OnRender** • **复杂或频繁更新 → VisualHost + DrawingVisual** • **永远不要混用**(如果你已经在控件里重写 OnRender,就不要再 AddVisualChild) —— 🔜 下一段:**ToggleSwitch 完整 Themes/Generic.xaml 模板 + 5 大生产陷阱 + 8 大易错点 + 3 道递进面试题**。 --- <!-- message_id: om_x100b6797dfbd04a0b1defb06ac5ba6b --> 📚 **严老师 · Day 23(第 4 段 / 实战收官)** 🔹 完整 Themes/Generic.xaml 模板(ToggleSwitch) <!-- MyCompany.Wpf.Controls/Themes/Generic.xaml --> <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:MyCompany.Wpf.Controls"> <!-- ⭐ 默认样式:必须 TargetType 是控件类型,WPF 才会自动应用 --> <Style TargetType="{x:Type local:ToggleSwitch}"> <Setter Property="Width" Value="60"/> <Setter Property="Height" Value="28"/> <Setter Property="Cursor" Value="Hand"/> <Setter Property="Template"> <Setter.Value> <ControlTemplate TargetType="{x:Type local:ToggleSwitch}"> <Border x:Name="PART_Rail" CornerRadius="14" Background="{TemplateBinding IsChecked, Converter={StaticResource BoolToRailBrushConverter}}"> <Border x:Name="PART_Thumb" Width="24" Height="24" Margin="2" CornerRadius="12" Background="White" HorizontalAlignment="Left" RenderTransformOrigin="0.5,0.5"> <Border.RenderTransform> <TranslateTransform x:Name="ThumbTransform" X="0"/> </Border.RenderTransform> </Border> </Border> <ControlTemplate.Triggers> <!-- ⭐ DataTrigger + RelativeSource Self 绑到本控件的 DP --> <Trigger Property="IsChecked" Value="True"> <Setter TargetName="ThumbTransform" Property="X" Value="28"/> </Trigger> <Trigger Property="IsEnabled" Value="False"> <Setter Property="Opacity" Value="0.5"/> </Trigger> </ControlTemplate.Triggers> </ControlTemplate> </Setter.Value> </Setter> </Style> </ResourceDictionary> **3 个模板设计要点**: • **PART_Rail + PART_Thumb**——设计师不能改这两个名字(否则代码 GetTemplateChild 拿不到) • **TemplateBinding IsChecked**——绑定到控件自己的 DP,比 {Binding RelativeSource={RelativeSource TemplatedParent}, Path=IsChecked} **快 10-50 倍**(Day 22 学过的编译期优化) • **TranslateTransform X 触发 Trigger**——动画式开关切换的核心机制(不需要写 Storyboard) —— 🔹 4. 怎么用:控件继承链选择 4 维分析 | 需要的能力 | 选哪个基类 | 原因 | | ---------------------- | ------------------------ | --------------------------------- | | 仅绘制图形(无焦点、无点击) | FrameworkElement | 最轻量,无任何"控件"语义负担 | | 标准"可点击控件"(按钮/开关) | Control | 自动支持 Tab 焦点、IsEnabled、背景前景 | | 含 Header + Items(树形控件) | HeaderedItemsControl | 自动获得 Header 模板 + Items 模板 | | 列表项中可选择(ToggleList) | ItemsControl + Selector | 自动支持 SelectedItem / SelectedIndex | | 纯数据展示(标签) | Control 或 ContentControl | 简单内容控件 | | 自定义带面板(树状菜单) | ItemsControl | 自动虚拟化支持 | **铁律**:**继承链尽量浅**——多继承一层就多一份"我必须理解的语义负担"。需要 Tab 焦点?继承 Control。需要 Header?继承 HeaderedContentControl。 —— 🔹 5. 常见误区(5 大必踩坑) | 误区 | 后果 | 修复 | | ------------------------------------------------------ | --------------------------------- | ----------------------------------------------------------------------------------------------------- | | ❌ 忘记 DefaultStyleKeyProperty.OverrideMetadata | 控件不显示 / 显示为"Control 默认样式"(空白方块) | 写静态构造函数 | | ❌ 把 XAML 写在 UserControl.xaml 而不是 Themes/Generic.xaml | 失去主题化能力 | 改用 Control 派生 | | ❌ 在 OnApplyTemplate 外调 GetTemplateChild | 返回 null、找不到模板元素 | 严格放在 OnApplyTemplate 内 | | ❌ PART_* 名字拼写不一致(XAML 写 PartThumb 代码查 PART_Thumb) | 编译通过,运行时 GetTemplateChild 返回 null | 用常量字符串包起来,IDE 字符串可重构 | | ❌ 自定义 DP 设了 AffectsRender 但没注册 PropertyChangedCallback | 视觉不更新 | 加 PropertyChangedCallback,或用 AffectsRender/AffectsMeasure/AffectsArrange/AffectsParentMeasure 四个 flag | —— 🔹 6. 关联知识网络(Day 14-22 大串联) Day 14 WPF 架构 → [assembly: ThemeInfo] + Themes/Generic.xaml 加载机制(Milcore 通过 ResourceDictionary 加载) Day 15 DP → 自定义 DP 注册 + Coerce + PropertyChangedCallback + AffectsRender 标志 Day 16 路由事件 → EventManager.RegisterRoutedEvent + RaiseEvent + RoutedPropertyChangedEventArgs Day 17 视觉树/逻辑树 → OnApplyTemplate 唯一 GetTemplateChild 入口 + 主题切换重新展开 Day 18 布局 → FrameworkElement.MeasureOverride/ArrangeOverride(如果自定义控件需要非默认布局) Day 19 资源系统 → Generic.xaml 主题字典 + MergedDictionaries(多个主题合并) Day 20 数据绑定 → TemplateBinding 编译期优化(在 ControlTemplate 内用) Day 21 命令系统 → 自定义 ICommand + CommandBinding(如 ToggleSwitch 暴露 ToggleCommand) Day 22 样式与模板 → TargetType={x:Type local:ToggleSwitch} 应用本控件样式 PART_* 命名约定 + TemplateBinding 编译期优化 DataTrigger/Trigger 触发器在 ControlTemplate 内使用 **这就是为什么我说"自定义控件 = WPF 进阶金字塔尖"——它是 Day 14-22 所有知识的综合实战**。 —— 📝 3 道递进面试题 请你先思考再回答(不要立刻看复盘,等我下一段讲解)。 **Q1(基础概念)** **请简述:自定义控件(Custom Control)和用户控件(User Control)的本质区别是什么?** **请列出至少 4 个维度对比,并指出 WPF 内置的 Button/TextBox/ListBox 属于哪一种。** **什么时候该用自定义控件,什么时候该用用户控件?给出 3 个具体场景。** **Q2(原理与辨析)** **WPF 是如何加载 Themes/Generic.xaml 的?请描述完整加载流程,包括 [assembly: ThemeInfo]、AssemblyResolve、DefaultStyleKey 的角色。** **PART_* 命名约定为什么存在?为什么不能随便命名?设计师和开发者之间的"契约"具体是怎么生效的?** **主题切换时为什么会再次调用 OnApplyTemplate?如果不清旧订阅会引发什么具体的内存泄漏?** **Q3(实战与深度)** *请基于今天的教学内容,手写一个生产级的 ToggleSwitch 自定义控件完整代码(包含 2-3 个自定义 DP + 1-2 个路由事件 + Themes/Generic.xaml + OnApplyTemplate + 一个 PART* 视觉状态更新)。_* **要求:** 1. **包括 IsChecked(bool) + IsCheckedNullable(bool?)两个 DP**——后者变化时联动前者但不打断外部绑定 2. **暴露 CheckedChanged 路由事件**(RoutedPropertyChangedEventArgs<bool>) 3. **支持 4 种视觉状态**(Normal / Checked / Disabled / Focused) 4. **重点指出你做了哪些"防 5 大陷阱"的处理**(特别是 T2 主题切换清订阅 + T5 Trigger 状态不更新 → 用了哪个 hook) 5. **如有 OnRender 直接绘制与 VisualHost+D中 DrawingVisual 二选一,请给出**你为什么这么选 —— 💡 **等你回答完,我会发完整的全面复盘讲解**(Q1+Q2+Q3 答案要点 + 深度解析 + 易错点 + 1-2 个扩展思考)。 🔜 今天是周日,我会等你答完这三个题再发完整复盘。如果你要先看复盘,告诉我"先讲"。

📡 推送信息

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