写在前面
版本说明:基于 .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>
|
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)" .../>
|
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_ 命名约定。