WPF 学习笔记(十):动画与图形——保留模式渲染

写在前面

版本说明:基于 .NET 8 LTS。

WPF 的图形动画系统是它和 WinForms 最大的代际差异。WinForms 要动画,你得自己写定时器、自己算每帧位置、自己 Invalidate 触发重绘。WPF 的动画是声明式的——你描述"从 A 到 B 用时 1 秒",框架替你跑每一帧。

本篇要把 WPF 的渲染范式、动画系统、图形原语讲清楚。这是后端工程师相对陌生的领域,但理解了原理就不再觉得"魔法"。

本文要回答:

WPF 为什么不会因为动画卡 UI?动画结束后属性值为什么改不回去了?保留模式和立即模式到底差在哪?


一、保留模式 vs 立即模式

1.1 两种渲染范式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
立即模式(Immediate Mode):
  每帧由你重新画
  → 你写代码:每帧 OnRender(e) → e.Graphics.DrawXXX
  → 不画就消失
  → 例:WinForms、DirectX、OpenGL、ImGui

保留模式(Retained Mode):
  你描述一棵"视觉树",框架渲染
  → 视觉树常驻内存
  → 你改属性,框架自动重绘
  → 例:WPF、DOM/CSS、HTML

1.2 对比

维度立即模式保留模式
状态你管(每帧重画)框架管(树常驻)
API 风格DrawXXX() 命令设属性
性能极简场景快复杂场景友好(GPU 加速)
动画你写定时器声明式
适用游戏、绘图软件业务 UI
1
2
3
4
5
6
后端类比:
  立即模式 ≈ 每次请求重渲染(无缓存)
  保留模式 ≈ 状态机 + 增量更新(带缓存)

WPF 的视觉树 = 一棵"持久化的渲染结构"
  改属性 → 标记 dirty → Composition 线程提交 GPU → 渲染

二、WPF 多线程渲染架构

WPF 的渲染其实跨多个线程。

2.1 三类线程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
UI 线程(Main Thread):
  - 处理用户输入、路由事件
  - 维护视觉树(你在 XAML 写的所有东西)
  - 跑 Dispatcher 消息循环
  - 计算 Measure/Arrange

Composition 线程:
  - 把视觉树变化"打包"提交给 GPU
  - 跑动画(部分)
  - 独立于 UI 线程
  - 这就是为什么动画不卡 UI

Render 线程(GPU 线程):
  - 实际向 GPU 发命令
  - 由 milcore.dll 管理

2.2 数据流

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
UI 线程
  ↓ 改属性(DP)
  ↓ 标记 dirty
Composition 线程
  ↓ 收集变化
  ↓ 计算动画帧
  ↓ 打包到 GPU 命令
Render 线程(GPU)
  ↓ 渲染
屏幕

2.3 为什么动画不卡 UI

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
传统想象:
  动画 = 每秒 60 帧 × UI 线程跑 → UI 卡死

WPF 实际:
  动画声明 → Composition 线程接管
  → 每帧在 Composition 线程算
  → 不占 UI 线程
  → UI 线程照常响应输入

但有限制:
  → 涉及布局(Measure/Arrange)的动画还是要 UI 线程
  → 只渲染的动画(Opacity、RenderTransform)才能 Composition 跑
  → 所以"动画属性"选 Opacity/Transform 性能好

三、图形原语

3.1 Shape(基础形状)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<Canvas>
    <Rectangle Canvas.Left="10" Canvas.Top="10" Width="100" Height="60"
               Fill="DodgerBlue" Stroke="Black"/>
    <Ellipse Canvas.Left="120" Canvas.Top="10" Width="60" Height="60"
             Fill="OrangeRed"/>
    <Line Canvas.Left="10" Canvas.Top="80" X1="0" Y1="0" X2="200" Y2="0"
          Stroke="Black" StrokeThickness="2"/>
    <Polyline Points="10,100 50,140 90,100 130,140" Stroke="Green" StrokeThickness="2"/>
    <Polygon Points="200,100 240,140 280,100 260,60 220,60" Fill="Purple"/>
</Canvas>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Shape 系列:
  - Rectangle:矩形
  - Ellipse:椭圆
  - Line:线段
  - Polyline:折线
  - Polygon:多边形
  - Path:任意路径(最强,用 Geometry)

特点:
  - 都是 FrameworkElement(参与布局)
  - 自带 Stroke / Fill 属性
  - 相对较重(适合少量元素)

3.2 Geometry(路径几何)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<Path Stroke="Black" StrokeThickness="2" Fill="LightBlue">
    <Path.Data>
        <PathGeometry>
            <PathFigure StartPoint="10,10" IsClosed="True">
                <LineSegment Point="100,10"/>
                <LineSegment Point="50,80"/>
            </PathFigure>
        </PathGeometry>
    </Path.Data>
</Path>

<!-- Mini-language(路径字符串) -->
<Path Stroke="Red" Data="M 10,10 L 100,10 L 50,80 Z"/>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Geometry vs Shape:
  - Geometry:纯几何描述(不带渲染)
  - Shape:UI 元素(含 Geometry + 渲染)

  Geometry 轻,可以多个 Path 共享
  Shape 重,但简单易用

后端类比:
  Geometry ≈ DTO(数据描述)
  Shape ≈ View Model + View(数据 + 渲染)

3.3 Brush(画刷)

 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
<!-- 纯色 -->
<Rectangle Fill="Red"/>
<Rectangle Fill="#FFFF0000"/>

<!-- 渐变 -->
<Rectangle>
    <Rectangle.Fill>
        <LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
            <GradientStop Offset="0" Color="Yellow"/>
            <GradientStop Offset="1" Color="Red"/>
        </LinearGradientBrush>
    </Rectangle.Fill>
</Rectangle>

<!-- 径向渐变 -->
<Ellipse>
    <Ellipse.Fill>
        <RadialGradientBrush>
            <GradientStop Offset="0" Color="White"/>
            <GradientStop Offset="1" Color="Black"/>
        </RadialGradientBrush>
    </Ellipse.Fill>
</Ellipse>

<!-- 图片 -->
<Rectangle>
    <Rectangle.Fill>
        <ImageBrush ImageSource="background.jpg"/>
    </Rectangle.Fill>
</Rectangle>

<!-- 视觉刷(用任意 UI 元素作为画刷) -->
<Rectangle>
    <Rectangle.Fill>
        <VisualBrush Visual="{Binding ElementName=sourceElement}"/>
    </Rectangle.Fill>
</Rectangle>

3.4 Transform(变换)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<Button Content="Rotate" RenderTransformOrigin="0.5,0.5">
    <Button.RenderTransform>
        <RotateTransform Angle="45"/>
    </Button.RenderTransform>
</Button>

<Button Content="Scale">
    <Button.RenderTransform>
        <ScaleTransform ScaleX="1.5" ScaleY="1.5"/>
    </Button.RenderTransform>
</Button>

<!-- 复合变换 -->
<Button Content="Combined">
    <Button.RenderTransform>
        <TransformGroup>
            <ScaleTransform ScaleX="1.5" ScaleY="1.5"/>
            <RotateTransform Angle="15"/>
            <TranslateTransform X="10" Y="20"/>
        </TransformGroup>
    </Button.RenderTransform>
</Button>
1
2
3
4
5
RenderTransform vs LayoutTransform(详见第 4 篇):
  RenderTransform:渲染时变换,不影响布局
  LayoutTransform:变换后重新参与布局

动画优先用 RenderTransform(性能好)

四、动画系统

4.1 声明式动画

1
2
3
<!-- 1 秒内,Opacity 从 1 → 0 -->
<DoubleAnimation Storyboard.TargetProperty="Opacity"
                 From="1" To="0" Duration="0:0:1"/>
1
2
3
4
5
6
7
8
动画的本质:
  你描述 From / To / Duration
  框架每帧算当前值(基于时间)
  把当前值赋给 TargetProperty

后端类比:
  动画 ≈ 定时器 + 插值函数
  框架替你管"每帧"

4.2 Timeline 与 AnimationTimeline

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Timeline(时间线):
  - 抽象基类
  - 有 Duration、BeginTime、RepeatBehavior
  - 表示"一段时间内发生的事"

AnimationTimeline(动画时间线):
  - Timeline 的派生
  - 知道怎么算"当前帧的值"
  - 例:DoubleAnimation、ColorAnimation

Storyboard(故事板):
  - 多个 AnimationTimeline 的容器
  - 统一管理 Begin/Stop/Pause
  - 触发动画的标准方式

4.3 三种基本动画类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!-- DoubleAnimation(数字属性) -->
<DoubleAnimation Storyboard.TargetProperty="Opacity"
                 From="1" To="0" Duration="0:0:1"/>

<!-- ColorAnimation(颜色) -->
<ColorAnimation Storyboard.TargetProperty="(Background).(SolidColorBrush.Color)"
                From="Red" To="Blue" Duration="0:0:1"/>

<!-- PointAnimation(点) -->
<PointAnimation Storyboard.TargetProperty="(PathGeometry.Figures)[0].StartPoint"
                From="10,10" To="100,100" Duration="0:0:1"/>
1
2
3
4
5
6
为什么是这三种?
  - double:最常见(位置、大小、透明度)
  - Color:颜色变化
  - Point:几何点变化

其他类型(bool、string 等)不能动画(用 ObjectAnimationUsingKeyFrames 替代)

五、Storyboard 触发动画

5.1 EventTrigger 触发

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<Button Content="Hover Me">
    <Button.Triggers>
        <EventTrigger RoutedEvent="Button.MouseEnter">
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetProperty="Opacity"
                                     To="0.7" Duration="0:0:0.3"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
        <EventTrigger RoutedEvent="Button.MouseLeave">
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetProperty="Opacity"
                                     To="1.0" Duration="0:0:0.3"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
    </Button.Triggers>
</Button>

5.2 Style 里的 EventTrigger

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<Style TargetType="Button">
    <Style.Triggers>
        <EventTrigger RoutedEvent="Button.MouseEnter">
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetProperty="FontSize"
                                     To="20" Duration="0:0:0.2"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
        <EventTrigger RoutedEvent="Button.MouseLeave">
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetProperty="FontSize"
                                     To="14" Duration="0:0:0.2"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
    </Style.Triggers>
</Style>

5.3 代码触发

1
2
3
// 在 code-behind
var storyboard = (Storyboard)FindResource("MyAnimation");
storyboard.Begin(myButton);

六、依赖属性动画"占用"机制

这是 WPF 动画最反直觉的部分。

6.1 现象

1
2
3
4
5
6
7
8
9
// 设置 Width
myButton.Width = 100;

// 跑动画到 200
var animation = new DoubleAnimation(200, TimeSpan.FromSeconds(1));
myButton.BeginAnimation(Button.WidthProperty, animation);

// 动画结束后
myButton.Width = 50;   // ⚠️ 没效果!
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
原因:
  动画结束后,WidthProperty 的"动画值"占据优先级 2(详见第 2 篇)
  本地值(你直接赋值的)优先级 3...等等,这个反过来了

  实际:
  Priority 2: Animation
  Priority 3: Local Value

  → 动画的值 > 你的本地值
  → 你的赋值"被动画压住"

FillBehavior:
  HoldEnd(默认):动画结束后保持最后值(继续占用属性)
  Stop:动画结束后释放,回到本地值

6.2 解决方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 方法 1:FillBehavior = Stop
var animation = new DoubleAnimation
{
    To = 200,
    Duration = TimeSpan.FromSeconds(1),
    FillBehavior = FillBehavior.Stop   // ← 释放占用
};
myButton.BeginAnimation(Button.WidthProperty, animation);
// 动画结束后 Width 回到 100(之前的本地值)

// 方法 2:手动清除动画
myButton.BeginAnimation(Button.WidthProperty, null);
myButton.Width = 50;   // ✅ 生效

6.3 占用机制总结

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
动画运行时:
  property = animation.CurrentValue
  优先级最高(仅次于 Coerce)

动画停止(HoldEnd):
  property = animation.To
  继续占用

动画停止(Stop):
  property = local value(你之前赋的)
  释放占用

清除动画:
  BeginAnimation(dp, null)
  完全清除占用

七、缓动函数 EasingFunction

线性动画生硬,缓动函数让动画自然。

7.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
<!-- 线性(默认) -->
<DoubleAnimation Storyboard.TargetProperty="X"
                 From="0" To="100" Duration="0:0:1"/>

<!-- 二次方缓入缓出 -->
<DoubleAnimation Storyboard.TargetProperty="X"
                 From="0" To="100" Duration="0:0:1">
    <DoubleAnimation.EasingFunction>
        <QuadraticEase EasingMode="EaseInOut"/>
    </DoubleAnimation.EasingFunction>
</DoubleAnimation>

<!-- 弹性 -->
<DoubleAnimation Storyboard.TargetProperty="X"
                 From="0" To="100" Duration="0:0:1">
    <DoubleAnimation.EasingFunction>
        <ElasticEase Oscillations="3" Springiness="5"/>
    </DoubleAnimation.EasingFunction>
</DoubleAnimation>

<!-- 三次贝塞尔(最灵活) -->
<DoubleAnimation Storyboard.TargetProperty="X"
                 From="0" To="100" Duration="0:0:1">
    <DoubleAnimation.EasingFunction>
        <CubicEase EasingMode="EaseOut"/>
    </DoubleAnimation.EasingFunction>
</DoubleAnimation>

7.2 缓动类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
EasingMode:
  EaseIn:开始慢,后面快
  EaseOut:开始快,后面慢(推荐,UI 动画最自然)
  EaseInOut:两端慢中间快

内置缓动:
  Linear、Quadratic、Cubic、Quartic、Quintic
  Sine、Circle、Elastic、Back、Bounce
  Exponential、Power
  CubicBezier(自定义贝塞尔)

设计原则:
  - UI 进入动画:EaseOut
  - UI 退出动画:EaseIn
  - 状态切换:EaseInOut

7.3 关键帧动画

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="X">
    <!-- 线性关键帧 -->
    <LinearDoubleKeyFrame KeyTime="0:0:0" Value="0"/>
    <LinearDoubleKeyFrame KeyTime="0:0:0.5" Value="50"/>
    <LinearDoubleKeyFrame KeyTime="0:0:1" Value="100"/>

    <!-- 缓和关键帧 -->
    <EasingDoubleKeyFrame KeyTime="0:0:1.5" Value="120">
        <EasingDoubleKeyFrame.EasingFunction>
            <CubicEase/>
        </EasingDoubleKeyFrame.EasingFunction>
    </EasingDoubleKeyFrame>

    <!-- 离散(瞬间跳变) -->
    <DiscreteDoubleKeyFrame KeyTime="0:0:2" Value="200"/>
</DoubleAnimationUsingKeyFrames>

八、动画实战:加载动画 + 进度环

8.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
<!-- XAML -->
<Window.Resources>
    <Style TargetType="ProgressBar" x:Key="CircularProgress">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="ProgressBar">
                    <Grid>
                        <Path Name="PART_Track" Stroke="LightGray" StrokeThickness="6"
                              Data="M 50,10 A 40,40 0 1 1 49.99,10" Fill="Transparent"/>
                        <Path Name="PART_Indicator" Stroke="DodgerBlue" StrokeThickness="6"
                              StrokeStartLineCap="Round" StrokeEndLineCap="Round"
                              Data="M 50,10 A 40,40 0 1 1 49.99,10" Fill="Transparent"/>
                    </Grid>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</Window.Resources>

<!-- 实际加载旋转动画 -->
<Grid Width="60" Height="60">
    <Border Width="40" Height="40" BorderBrush="DodgerBlue" BorderThickness="4"
            CornerRadius="20" RenderTransformOrigin="0.5,0.5">
        <Border.RenderTransform>
            <RotateTransform x:Name="spinRotation"/>
        </Border.RenderTransform>
    </Border>

    <Border.Triggers>
        <EventTrigger RoutedEvent="Loaded">
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetName="spinRotation"
                                     Storyboard.TargetProperty="Angle"
                                     From="0" To="360" Duration="0:0:1"
                                     RepeatBehavior="Forever"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
    </Border.Triggers>
</Grid>

8.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
35
36
37
38
<Canvas Width="100" Height="100">
    <!-- 背景圆 -->
    <Path Stroke="LightGray" StrokeThickness="6"
          Data="M 50,10 A 40,40 0 1 1 49.99,10"
          Fill="Transparent"/>

    <!-- 进度圆弧(用 StrokeDashOffset 控制长度) -->
    <Path x:Name="progressArc" Stroke="DodgerBlue" StrokeThickness="6"
          StrokeStartLineCap="Round" StrokeEndLineCap="Round"
          Data="M 50,10 A 40,40 0 1 1 49.99,10"
          Fill="Transparent"
          StrokeDashArray="251.32 251.32"
          StrokeDashOffset="251.32">
        <Path.RenderTransform>
            <RotateTransform x:Name="arcRotate" CenterX="50" CenterY="50"/>
        </Path.RenderTransform>
    </Path>

    <Path.Triggers>
        <EventTrigger RoutedEvent="Loaded">
            <BeginStoryboard>
                <Storyboard>
                    <!-- 进度 0 → 75%(offset 从 251.32 → 251.32*0.25=62.83) -->
                    <DoubleAnimation Storyboard.TargetName="progressArc"
                                     Storyboard.TargetProperty="StrokeDashOffset"
                                     From="251.32" To="62.83"
                                     Duration="0:0:1"
                                     EasingFunction="{...}"/>
                    <!-- 持续旋转(让进度环转起来,更动感) -->
                    <DoubleAnimation Storyboard.TargetName="arcRotate"
                                     Storyboard.TargetProperty="Angle"
                                     From="0" To="360" Duration="0:0:2"
                                     RepeatBehavior="Forever"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
    </Path.Triggers>
</Canvas>

8.3 用 Trigger 实现 Material 风格按钮悬停

 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
<Style TargetType="Button">
    <Setter Property="Background" Value="#2196F3"/>
    <Setter Property="Foreground" Value="White"/>
    <Setter Property="Padding" Value="24,12"/>
    <Setter Property="RenderTransformOrigin" Value="0.5,0.5"/>
    <Setter Property="RenderTransform">
        <Setter.Value>
            <ScaleTransform ScaleX="1" ScaleY="1"/>
        </Setter.Value>
    </Setter>

    <Style.Triggers>
        <EventTrigger RoutedEvent="MouseEnter">
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleX)"
                                     To="1.05" Duration="0:0:0.2">
                        <DoubleAnimation.EasingFunction>
                            <CubicEase EasingMode="EaseOut"/>
                        </DoubleAnimation.EasingFunction>
                    </DoubleAnimation>
                    <DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleY)"
                                     To="1.05" Duration="0:0:0.2"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
        <EventTrigger RoutedEvent="MouseLeave">
            <BeginStoryboard>
                <Storyboard>
                    <DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleX)"
                                     To="1.0" Duration="0:0:0.2"/>
                    <DoubleAnimation Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleY)"
                                     To="1.0" Duration="0:0:0.2"/>
                </Storyboard>
            </BeginStoryboard>
        </EventTrigger>
    </Style.Triggers>
</Style>

8.4 知识点覆盖

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
✅ DoubleAnimation + Storyboard
✅ EventTrigger 触发
✅ RepeatBehavior="Forever"
✅ StrokeDashArray + StrokeDashOffset(圆弧进度技巧)
✅ EasingFunction(CubicEase)
✅ RenderTransform(性能友好的动画属性)

观察:
  - 动画期间 UI 仍然响应(Composition 线程跑)
  - 加载圈"永不结束"用 RepeatBehavior="Forever"
  - Material 按钮的 1.05 缩放非常微妙但舒服

九、VisualStateManager 与动画

CustomControl 推荐用 VSM 触发动画(详见第 8 篇)。

9.1 VSM 与动画

 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
<ControlTemplate TargetType="Button">
    <Grid>
        <Border x:Name="root" Background="DodgerBlue"/>
        <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>

        <VisualStateManager.VisualStateGroups>
            <VisualStateGroup x:Name="CommonStates">
                <VisualState x:Name="Normal"/>
                <VisualState x:Name="MouseOver">
                    <Storyboard>
                        <ColorAnimation Storyboard.TargetName="root"
                                        Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)"
                                        To="LightBlue" Duration="0:0:0.2"/>
                    </Storyboard>
                </VisualState>
                <VisualState x:Name="Pressed">
                    <Storyboard>
                        <DoubleAnimation Storyboard.TargetName="root"
                                         Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleX)"
                                         To="0.95" Duration="0:0:0.1"/>
                        <DoubleAnimation Storyboard.TargetName="root"
                                         Storyboard.TargetProperty="(UIElement.RenderTransform).(ScaleTransform.ScaleY)"
                                         To="0.95" Duration="0:0:0.1"/>
                    </Storyboard>
                </VisualState>
            </VisualStateGroup>
        </VisualStateManager.VisualStateGroups>
    </Grid>
</ControlTemplate>
1
2
3
4
VSM 的优势:
  - 状态机抽象(Normal / MouseOver / Pressed)
  - 状态间自动过渡动画
  - 微软推荐 CustomControl 用 VSM

十、性能优化

10.1 选对动画属性

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
✅ 性能好(Composition 线程跑):
  Opacity
  RenderTransform(Translate、Scale、Rotate)
  这些属性不影响布局

❌ 性能差(UI 线程跑):
  Width / Height(触发 layout)
  Margin / Padding
  Canvas.Left / Top

实践:
  动画尽量用 RenderTransform 而不是 Width
  例:缩放用 ScaleTransform 而不是改 Width

10.2 RenderOptions

1
2
3
4
<!-- 缓存渲染(适合复杂静态元素) -->
<Path RenderOptions.CachingHint="Cache"
      RenderOptions.CacheInvalidationThresholdMaximum="2"
      RenderOptions.CacheInvalidationThresholdMinimum="0.5"/>
1
2
// 强制软件渲染(GPU 渲染反而慢的场景)
RenderOptions.ProcessRenderMode = RenderMode.SoftwareOnly;

10.3 Freeze Brush

1
2
3
4
5
6
7
8
9
// ❌ Brush 不变也每帧检查
LinearGradientBrush brush = new LinearGradientBrush();
brush.GradientStops.Add(new GradientStop(Colors.Red, 0));
brush.GradientStops.Add(new GradientStop(Colors.Blue, 1));
button.Background = brush;

// ✅ Freeze(不可变,性能好)
brush.Freeze();
button.Background = brush;

10.4 减少视觉树复杂度

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<!-- ❌ 嵌套太多 -->
<Grid>
    <Border>
        <Grid>
            <Border>
                <Button/>
            </Border>
        </Grid>
    </Border>
</Grid>

<!-- ✅ 扁平 -->
<Grid>
    <Button/>
</Grid>

十一、常见陷阱

11.1 动画结束后改属性无效

1
2
// 详见第 6 节
// 用 FillBehavior.Stop 或 BeginAnimation(dp, null) 解决

11.2 颜色动画路径写错

1
2
3
4
5
6
<!-- ❌ 直接 Background.Color -->
<ColorAnimation Storyboard.TargetProperty="Background.Color" .../>
<!-- 找不到 -->

<!-- ✅ 要写完整路径 -->
<ColorAnimation Storyboard.TargetProperty="(Control.Background).(SolidColorBrush.Color)" .../>

11.3 RenderTransform 没设 RenderTransformOrigin

1
2
3
4
5
6
7
8
9
<!-- ❌ 缩放原点在 (0,0)(左上角),看起来"飞走" -->
<Button RenderTransformOrigin="0,0">
    <Button.RenderTransform>
        <ScaleTransform ScaleX="2" ScaleY="2"/>
    </Button.RenderTransform>
</Button>

<!-- ✅ 缩放原点在中心 -->
<Button RenderTransformOrigin="0.5,0.5">...</Button>

11.4 动画死循环 UI 卡

1
2
3
4
5
6
7
8
// ❌ 用 Width 动画(每帧触发 layout)
DoubleAnimation widthAnim = new(100, 500, TimeSpan.FromSeconds(1));
myButton.BeginAnimation(WidthProperty, widthAnim);

// ✅ 用 ScaleTransform
DoubleAnimation scaleAnim = new(1, 1.5, TimeSpan.FromSeconds(1));
myButton.RenderTransform = new ScaleTransform();
((ScaleTransform)myButton.RenderTransform).BeginAnimation(ScaleTransform.ScaleXProperty, scaleAnim);

十二、与 CSS 动画的对比

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
CSS                       WPF
──────────────────────────────────────────
@keyframes                <Storyboard>
animation: 1s ease        Duration + EasingFunction
transform: scale()        ScaleTransform
opacity                   Opacity
transition                EventTrigger + DoubleAnimation
animation-fill-mode       FillBehavior

共同点:
  - 都是声明式
  - 都用 GPU 加速
  - 都有缓动函数

差异:
  - CSS 的 transition 触发简单(属性变化)
  - WPF 要写完整 Storyboard(更冗长)
  - WPF 动画类型严格(double/Color/Point 分开)

十三、小结

本文深入 WPF 的图形与动画:

  • 保留模式 vs 立即模式(WPF 选保留)
  • WPF 多线程渲染架构(UI / Composition / Render)
  • 为什么动画不卡 UI(Composition 线程)
  • 图形原语:Shape / Geometry / Brush / Transform
  • 动画系统:Timeline / AnimationTimeline / Storyboard
  • 三种基本动画(Double / Color / Point)
  • 依赖属性动画"占用"机制(FillBehavior)
  • 缓动函数与关键帧
  • 实战:加载旋转 + Material 按钮悬停
  • 性能优化(选对属性、Freeze、RenderOptions)
1
2
3
4
记住三句话:
  1. WPF 是保留模式——你改属性,框架渲染
  2. 动画跑在 Composition 线程——选 Opacity/RenderTransform 不卡 UI
  3. 动画结束后属性"被占用"——FillBehavior.Stop 或 BeginAnimation(null) 释放

下一篇进入 WPF 的自定义控件——UserControl / CustomControl / 重写 OnRender 三条路线,以及 PART_ 命名约定。

Licensed under CC BY-NC-SA 4.0