📚 **严老师 · 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 个扩展思考)。
🔜 今天是周日,我会等你答完这三个题再发完整复盘。如果你要先看复盘,告诉我"先讲"。