Flutter 基础体系 · 第 44/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter CustomPainter:Canvas、坐标、重绘、命中和性能
CustomPainter 是 Flutter 中把“绘制逻辑”接入渲染树的接口。它通常与 CustomPaint 配合使用:
CustomPaint(
painter: MyPainter(),
size: const Size(240, 120),
)
其中:
CustomPainter描述“画什么、何时重绘、如何命中”;CustomPaint是一个 Widget,负责把 painter 放入布局和渲染流程;Canvas是绘制命令的目标;Paint描述颜色、线宽、样式、阴影等绘制属性;Offset、Rect、Size等类型描述几何数据。
CustomPainter 不等同于一个独立的画布窗口。它仍然受到 Flutter 的布局约束、坐标变换、裁剪、合成和命中测试机制影响。要正确使用它,必须同时理解几何坐标、绘制生命周期、重绘条件和事件分发。
一个可运行的最小示例
下面的示例绘制一个网格和圆形,并通过点击判断用户是否命中了圆形:
import 'dart:math' as math;
import 'package:flutter/material.dart';
void main() {
runApp(const MaterialApp(
home: CustomPainterDemoPage(),
));
}
class CustomPainterDemoPage extends StatefulWidget {
const CustomPainterDemoPage({super.key});
@override
State<CustomPainterDemoPage> createState() =>
_CustomPainterDemoPageState();
}
class _CustomPainterDemoPageState extends State<CustomPainterDemoPage> {
Offset? lastTap;
bool selected = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('CustomPainter Demo')),
body: Center(
child: GestureDetector(
onTapDown: (details) {
final position = details.localPosition;
setState(() {
lastTap = position;
selected = CirclePainter.isInsideCircle(position);
});
},
child: CustomPaint(
size: const Size(320, 200),
painter: CirclePainter(
selected: selected,
lastTap: lastTap,
),
),
),
),
);
}
}
class CirclePainter extends CustomPainter {
CirclePainter({
required this.selected,
required this.lastTap,
});
final bool selected;
final Offset? lastTap;
static const Offset center = Offset(160, 100);
static const double radius = 50;
static bool isInsideCircle(Offset point) {
final dx = point.dx - center.dx;
final dy = point.dy - center.dy;
// 不使用 sqrt,直接比较平方距离。
return dx * dx + dy * dy <= radius * radius;
}
@override
void paint(Canvas canvas, Size size) {
final gridPaint = Paint()
..color = Colors.grey.shade300
..strokeWidth = 1;
for (double x = 0; x <= size.width; x += 20) {
canvas.drawLine(
Offset(x, 0),
Offset(x, size.height),
gridPaint,
);
}
for (double y = 0; y <= size.height; y += 20) {
canvas.drawLine(
Offset(0, y),
Offset(size.width, y),
gridPaint,
);
}
final circlePaint = Paint()
..color = selected ? Colors.orange : Colors.blue
..style = PaintingStyle.fill;
canvas.drawCircle(center, radius, circlePaint);
if (lastTap != null) {
final tapPaint = Paint()
..color = Colors.red
..style = PaintingStyle.fill;
canvas.drawCircle(lastTap!, 4, tapPaint);
}
}
@override
bool shouldRepaint(covariant CirclePainter oldDelegate) {
return oldDelegate.selected != selected ||
oldDelegate.lastTap != lastTap;
}
}
运行后,CustomPaint 的逻辑尺寸是 320 × 200。点击位置通过 GestureDetector 的 localPosition 获得,该坐标与 painter 的局部坐标一致。若点击点满足:
就认为它位于圆内。
这里没有使用平方根,因为平方根是单调函数:
因此比较平方距离既保持结果正确,也避免了不必要的计算。
CustomPainter 在 Flutter 渲染流程中的位置
一个简化的流程如下:
flowchart TD
A[Widget 构建] --> B[CustomPaint]
B --> C[RenderCustomPaint]
C --> D[布局 layout]
D --> E[获得 Size]
E --> F[绘制 paint]
F --> G[Canvas 绘制命令]
G --> H[栅格化与合成]
I[状态变化或 Listenable 通知] --> J[标记需要重绘]
J --> F
K[指针事件] --> L[命中测试 hitTest]
L --> M[GestureDetector 或其他事件处理器]
CustomPainter.paint 的参数是:
void paint(Canvas canvas, Size size)
canvas表示当前绘制上下文;size是当前 painter 所在渲染对象的尺寸;- painter 不负责决定尺寸,尺寸来自
CustomPaint的布局结果。
因此,paint 不是一个可以随意设置组件大小的地方。以下代码不会因为 drawCircle 画到了 1000 坐标就自动让组件变大:
@override
void paint(Canvas canvas, Size size) {
canvas.drawCircle(const Offset(1000, 1000), 50, Paint());
}
超出布局尺寸的部分通常会被裁剪、不可见,或者影响后续命中判断,但不会反向改变父布局。
没有 child 时必须提供合理尺寸
CustomPaint 可以有 child,也可以没有:
CustomPaint(
painter: MyPainter(),
size: const Size(300, 200),
)
如果没有 child,应该通过 size 或父级约束提供明确尺寸。实际布局仍受父级 BoxConstraints 限制:
- 在
SizedBox、Container、Expanded等有界环境中,尺寸通常明确; - 在
Column、Row或滚动组件中,如果某个方向没有约束,可能出现无界尺寸问题; - painter 只能使用传入的
size,不能假设它一定等于某个固定值。
例如,下面的写法在某些无界约束环境中可能产生布局异常:
CustomPaint(
painter: MyPainter(),
)
更明确的写法是:
SizedBox(
width: 300,
height: 200,
child: CustomPaint(
painter: MyPainter(),
),
)
或者让 painter 根据真实尺寸布局内部图形,而不是硬编码整个画布尺寸。
Canvas:命令式绘制上下文
Canvas 提供的是命令式 API。代码执行顺序就是绘制命令提交顺序:
canvas.drawRect(rectA, paintA);
canvas.drawCircle(center, radius, paintB);
如果两个图形重叠,后绘制的圆通常覆盖先绘制的矩形。Canvas 本身不保存“对象列表”,也不提供类似 Flutter Widget 的自动对象级更新。下一次重绘时,painter 通常需要重新提交完整绘制过程。
Paint 决定如何画
Paint 主要描述绘制样式:
final paint = Paint()
..color = Colors.blue
..style = PaintingStyle.stroke
..strokeWidth = 2
..strokeCap = StrokeCap.round
..isAntiAlias = true;
常见属性含义如下:
color:颜色;style:填充fill或描边stroke;strokeWidth:描边宽度;strokeCap:线段端点样式;strokeJoin:折线连接处样式;isAntiAlias:是否进行抗锯齿。
PaintingStyle.stroke 的线通常以路径中心线为基准向两侧扩展。例如,线宽为 10 的水平线中心位于 y = 50,它大致覆盖 y = 45 到 y = 55。
Paint 可以复用,但不能在仍然依赖旧属性的绘制逻辑之间无意修改它。更清晰的做法是为不同语义的图形准备不同的 Paint,或者在每次使用前完整设置关键属性。
常见 Canvas API
canvas.drawLine(p1, p2, paint);
canvas.drawRect(rect, paint);
canvas.drawRRect(rrect, paint);
canvas.drawCircle(center, radius, paint);
canvas.drawOval(rect, paint);
canvas.drawPath(path, paint);
canvas.drawImage(image, offset, paint);
canvas.drawParagraph(paragraph, offset);
复杂图形通常使用 Path:
final path = Path()
..moveTo(20, 100)
..lineTo(100, 20)
..lineTo(180, 100)
..close();
canvas.drawPath(
path,
Paint()
..color = Colors.green
..style = PaintingStyle.fill,
);
Path.close() 会添加从当前点回到起点的闭合线段。它不仅影响填充区域,也影响描边连接方式。
坐标:局部坐标、变换和裁剪
默认坐标系
Flutter 的绘制坐标通常使用逻辑像素:
- 原点
(0, 0)位于当前绘制区域左上角; x向右增加;y向下增加;size.width和size.height是当前绘制区域的逻辑尺寸。
例如,在 Size(320, 200) 中:
Offset(0, 0) // 左上角
Offset(320, 0) // 右上边界
Offset(0, 200) // 左下边界
Offset(320, 200) // 右下边界
Offset(160, 100) // 中心
边界点是否可见还取决于图形本身的范围、描边宽度和裁剪区域。不能把“坐标等于宽度”简单理解为一个有宽度的图形一定完整可见。
坐标不是全局坐标
paint 收到的坐标是 painter 的局部坐标。假设一个 CustomPaint 被放在屏幕左侧 x = 40 的位置:
屏幕坐标 x = 100
局部坐标 x = 60
如果直接把屏幕坐标当作 painter 坐标,命中点和绘制点就会整体偏移。
GestureDetector 的:
details.localPosition
通常就是相对于接收手势的 RenderBox 的局部位置,适合直接与同一 CustomPaint 中的几何数据比较。若使用全局坐标,应显式转换:
final renderBox =
context.findRenderObject()! as RenderBox;
final localPosition = renderBox.globalToLocal(globalPosition);
save、restore 和变换矩阵
Canvas 支持保存和恢复绘制状态:
canvas.save();
canvas.translate(100, 50);
canvas.rotate(0.2);
canvas.drawRect(
const Rect.fromLTWH(0, 0, 80, 40),
Paint()..color = Colors.blue,
);
canvas.restore();
save() 保存当前状态,restore() 恢复状态。状态包括当前变换矩阵、裁剪区域等。Paint 对象的属性不会因为 save 自动回滚。
上面的矩形实际绘制在平移、旋转后的坐标系中。用齐次坐标表示,一个点的变换可以写成:
其中:
- 是图形提供的局部坐标;
- 是 Canvas 当前变换矩阵;
- 是最终送入后续绘制阶段的坐标。
translate、scale 和 rotate 都是在修改这个矩阵。变换具有累积性:
canvas.translate(100, 0);
canvas.translate(20, 0);
// 后续坐标整体平移 120
因此,局部变换应使用成对的 save 和 restore:
canvas.save();
canvas.translate(100, 0);
// 只在这个区域使用偏移
canvas.restore();
// 这里已经回到原来的坐标系
缺少 restore() 会让后续图形继承错误的坐标系,这是复杂 painter 中常见的“所有内容逐渐偏移”问题。
clip 是累积的,而且不能扩大
canvas.save();
canvas.clipRect(const Rect.fromLTWH(0, 0, 100, 100));
canvas.drawCircle(
const Offset(50, 50),
80,
Paint()..color = Colors.red,
);
canvas.restore();
虽然圆半径是 80,只有位于裁剪矩形内的部分能够保留。裁剪通常是逐步收窄的,之后的 clipRect 不能把已被裁掉的区域恢复回来。
裁剪对于地图、图表、滚动内容和局部动画很有用,但复杂裁剪也会增加栅格化成本。是否产生额外代价取决于绘制内容和具体渲染后端,不能用一个固定数字概括。
saveLayer 不是普通 save
saveLayer 会建立离屏图层:
canvas.saveLayer(
const Rect.fromLTWH(0, 0, 300, 200),
Paint(),
);
// 绘制需要整体混合、模糊或特殊混合模式的内容
canvas.restore();
它与普通 save 的区别是:内容先绘制到离屏缓冲区,恢复时再整体合成。这样可以正确实现某些混合、遮罩和组级透明度效果,但可能带来额外的内存带宽、纹理分配和合成成本。
例如,想让一组图形整体使用透明度,saveLayer 可能是正确工具;但给每一条普通线都包一层 saveLayer,通常是无必要的性能负担。
逻辑像素、设备像素和边缘清晰度
Flutter 的 Canvas 坐标主要是逻辑像素。实际渲染到屏幕时,还会结合设备像素比:
例如,逻辑坐标 10.0 在设备像素比为 3.0 时对应物理坐标约 30;逻辑宽度 1.0 对应约 3 个物理像素。
这意味着“对齐一个设备像素”不能简单地永远写成逻辑坐标加 0.5。在设备像素比为 1 时,绘制 1 逻辑像素描边时常见的做法是将中心线放在半逻辑像素位置:
canvas.drawLine(
const Offset(0, 0.5),
const Offset(100, 0.5),
Paint()
..color = Colors.black
..strokeWidth = 1,
);
但在 devicePixelRatio = 2 或 3 时,正确的对齐应根据实际物理像素换算。抗锯齿、变换矩阵、线宽和渲染后端也会共同影响结果。因此:
- 半像素偏移是特定场景下的经验技巧,不是所有设备的通用规则;
- 旋转、缩放后的线条通常不可能全部与设备像素严格对齐;
- 如果图形边缘模糊,应先检查线宽、变换、DPR 和抗锯齿,而不是盲目加
0.5。
Android、iOS、桌面和 Web 都遵循逻辑坐标模型,但最终栅格化和合成由平台渲染管线共同决定。Flutter 不保证所有后端在边缘抗锯齿和文本栅格化上逐像素一致。
绘制顺序:background、child 和 foreground
CustomPaint 可以同时拥有背景 painter、child 和前景 painter:
CustomPaint(
painter: BackgroundPainter(),
child: const SomeWidget(),
foregroundPainter: ForegroundPainter(),
)
顺序通常是:
background painter
↓
child
↓
foreground painter
因此:
- 背景 painter 适合画网格、背景色、装饰线;
- child 适合放置普通 Flutter Widget;
- foreground painter 适合画覆盖在 child 上面的高亮、标记或辅助线。
这也意味着 painter 不能随意假设自己位于最上层。若图形被 child 覆盖,可能不是绘制失败,而是绘制顺序符合预期。
重绘:shouldRepaint 到底控制什么
CustomPainter 对象不是状态容器
推荐让 painter 保持不可变:
class BarPainter extends CustomPainter {
const BarPainter({
required this.values,
});
final List<double> values;
@override
void paint(Canvas canvas, Size size) {
// 使用 values 绘制
}
@override
bool shouldRepaint(covariant BarPainter oldDelegate) {
return !listEquals(oldDelegate.values, values);
}
}
当 Widget 重建并创建了一个新的 painter 时,Flutter 会调用:
shouldRepaint(oldDelegate)
返回值的含义是:
true:新 painter 的绘制结果可能不同,需要重绘;false:新旧 painter 的绘制结果等价,可以跳过这次重绘。
如果 painter 使用多个字段,所有影响绘制结果的字段都必须参与判断:
@override
bool shouldRepaint(covariant ChartPainter oldDelegate) {
return oldDelegate.values != values ||
oldDelegate.selectedIndex != selectedIndex ||
oldDelegate.axisColor != axisColor;
}
这里的 != 对 List 默认是对象身份比较,不是逐元素比较。若列表内容原地修改但对象身份不变,可能导致错误判断:
values[0] = 42; // 原地修改
对于这种情况,应使用不可变数据、创建新列表,或执行内容比较。否则可能出现状态已经改变但画面不更新。
shouldRepaint 返回 false 的反例
class WrongPainter extends CustomPainter {
WrongPainter({required this.value});
final double value;
@override
void paint(Canvas canvas, Size size) {
canvas.drawCircle(
Offset(value, size.height / 2),
20,
Paint()..color = Colors.blue,
);
}
@override
bool shouldRepaint(covariant WrongPainter oldDelegate) {
return false;
}
}
如果 value 发生变化,新的圆应该移动,但 shouldRepaint 永远返回 false,渲染树可能继续复用旧绘制结果。表现通常是:
- Widget 状态已经更新;
- 调试日志显示新值;
- 画面却停留在旧位置。
shouldRepaint 不是“性能开关”,而是绘制结果等价性的声明。为了省一次重绘而错误返回 false,会直接破坏正确性。
repaint Listenable:绕过 build 和 layout
CustomPainter 的构造函数支持传入 Listenable:
class ProgressPainter extends CustomPainter {
ProgressPainter({
required this.progress,
super.repaint,
});
final double progress;
@override
void paint(Canvas canvas, Size size) {
// 使用 progress 绘制
}
@override
bool shouldRepaint(covariant ProgressPainter oldDelegate) {
return oldDelegate.progress != progress;
}
}
使用时:
final progress = ValueNotifier<double>(0);
CustomPaint(
painter: ProgressPainter(
progress: progress.value,
repaint: progress,
),
)
当 progress.notifyListeners() 触发时,相关渲染对象可以直接进入重绘流程,而不必先让包含它的 Widget 整棵子树重新 build 或重新 layout。
不过这里仍有一个重要条件:paint 必须能看到最新数据。若 painter 构造时把旧的 progress.value 复制进不可变字段,之后只通知而不更新 painter 字段,通知虽然发生,绘制仍可能使用旧值。常见方案有两种:
- 每次状态变化创建新的 painter,并让
shouldRepaint比较新旧值; - painter 持有一个稳定的、可监听的状态对象,在
paint时读取最新值。
第一种更容易维护,第二种适合高频动画,但必须明确状态所有权和销毁时机。
动画通常可以这样组织:
class AnimatedCircle extends StatefulWidget {
const AnimatedCircle({super.key});
@override
State<AnimatedCircle> createState() => _AnimatedCircleState();
}
class _AnimatedCircleState extends State<AnimatedCircle>
with SingleTickerProviderStateMixin {
late final AnimationController controller;
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..repeat(reverse: true);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: AnimatedCirclePainter(
animation: controller,
),
size: const Size(200, 200),
);
}
}
class AnimatedCirclePainter extends CustomPainter {
AnimatedCirclePainter({
required this.animation,
}) : super(repaint: animation);
final Animation<double> animation;
@override
void paint(Canvas canvas, Size size) {
final radius = 20 + animation.value * 30;
canvas.drawCircle(
size.center(Offset.zero),
radius,
Paint()..color = Colors.blue,
);
}
@override
bool shouldRepaint(covariant AnimatedCirclePainter oldDelegate) {
return oldDelegate.animation != animation;
}
}
AnimationController 本身是 Listenable,因此每帧变化可以直接触发 painter 重绘。dispose 必须释放 controller,否则会留下 ticker 和监听关系。
build、layout、paint 的区别
一次状态变化不一定要触发完整流程:
setState
├─ build:重新生成 Widget 配置
├─ layout:重新计算尺寸和位置
└─ paint:重新提交绘制命令
如果只需要修改图形颜色、角度或进度,而尺寸和布局没有变化,使用 painter 的 repaint 通知可以减少 build 和 layout。但它并不意味着“没有成本”:每次仍然要执行绘制命令,并可能重新栅格化和合成。
命中:绘制出来不等于自动可点击
绘制与交互是两个不同阶段:
paint决定像素如何显示;hitTest决定一个位置是否被认为命中;- 手势识别器决定如何把命中后的指针事件组合成 tap、drag、scale 等手势。
画出一个圆,不会自动让 Flutter 知道“只有圆内部可点击”。常见实现有两类。
用 GestureDetector 统一处理,再自行判断几何区域
前面的示例采用了这种方式:
onTapDown: (details) {
final localPosition = details.localPosition;
if (CirclePainter.isInsideCircle(localPosition)) {
// 命中圆
}
}
这个方案的优点是逻辑直接,适合:
- 画布整体都需要接收手势;
- 需要处理多个图形;
- 命中结果不只是 true/false,还要返回数据对象;
- 需要实现拖拽、悬停、框选等复杂交互。
多个图形可以按绘制顺序的反方向判断:
Shape? hitTestShape(Offset point) {
// 先判断最上层图形
for (final shape in shapes.reversed) {
if (shape.contains(point)) {
return shape;
}
}
return null;
}
绘制顺序通常是后绘制者覆盖先绘制者,因此命中时从上层到下层查找更符合视觉结果。
覆盖 CustomPainter.hitTest
CustomPainter 可以重写:
@override
bool hitTest(Offset position) {
return CirclePainter.isInsideCircle(position);
}
这里的 position 是 painter 局部坐标。它适用于让 painter 自身参与 RenderObject 命中测试,例如:
class HitTestCirclePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
canvas.drawCircle(
size.center(Offset.zero),
40,
Paint()..color = Colors.blue,
);
}
@override
bool hitTest(Offset position) {
final center = const Offset(100, 100);
final dx = position.dx - center.dx;
final dy = position.dy - center.dy;
return dx * dx + dy * dy <= 40 * 40;
}
@override
bool shouldRepaint(covariant HitTestCirclePainter oldDelegate) {
return false;
}
}
但是,CustomPainter.hitTest 只表达 painter 的命中结果,不会自动创建手势识别器。通常仍然需要 GestureDetector、Listener、MouseRegion 或其他事件组件来消费事件。
还要注意一个交互取舍:如果 painter 对空白区域返回 false,这些区域可能不会继续进入以该 RenderObject 为目标的命中路径。若需求是“点击画布空白处也能取消选中”,就不应把空白区域拒绝掉,而应让整个画布可命中,再在手势回调内部区分具体图形。
变换后的命中坐标
如果绘制时使用了变换:
canvas.save();
canvas.translate(100, 0);
canvas.rotate(angle);
canvas.drawRect(
const Rect.fromLTWH(0, 0, 80, 40),
paint,
);
canvas.restore();
命中测试不能继续直接用未变换的矩形判断。可以有两种方法:
- 用同一组几何变换正向变换图形,再判断点是否位于变换后的区域;
- 对命中点应用逆矩阵,把世界或画布坐标转换回图形局部坐标。
第二种更适合旋转、缩放和嵌套变换:
然后在局部坐标中判断:
final localPoint = inverseMatrix.transformPoint(position);
return localRect.contains(localPoint);
实际代码可使用 Matrix4 和 vector_math,但必须保证绘制和命中使用完全相同的矩阵定义,否则会出现“视觉上点在图形内,逻辑上却没有命中”的偏差。
触摸、鼠标和 Web 差异
移动端主要处理触摸指针;桌面和 Web 还会遇到:
- 鼠标移动和悬停;
- 右键、辅助键;
- 滚轮;
- 更细粒度的指针设备信息;
- Web 浏览器默认行为和 CSS/DOM 事件组合。
MouseRegion 可以处理悬停:
MouseRegion(
onHover: (event) {
// event.localPosition 是局部坐标
},
child: CustomPaint(
painter: painter,
size: const Size(300, 200),
),
)
同一套几何命中算法可以复用,但事件语义不能假设 Android 和 Web 完全相同。桌面/Web 应用通常还需要考虑键盘焦点、右键菜单和无障碍语义。
语义:命中测试不等于无障碍支持
自定义绘制的图形不会自动拥有普通 Widget 的语义信息。屏幕阅读器无法仅凭一组 drawCircle 命令推断“这是一个播放按钮”或“这是一个数值图表”。
CustomPainter 可以提供语义配置:
class GaugePainter extends CustomPainter {
GaugePainter(this.value);
final double value;
@override
void paint(Canvas canvas, Size size) {
// 绘制仪表盘
}
@override
SemanticsBuilderCallback? get semanticsBuilder {
return (Size size) {
return [
CustomPainterSemantics(
rect: Offset.zero & size,
properties: SemanticsProperties(
label: '进度 ${(value * 100).round()}%',
button: true,
),
),
];
};
}
@override
bool shouldRebuildSemantics(covariant GaugePainter oldDelegate) {
return oldDelegate.value != value;
}
@override
bool shouldRepaint(covariant GaugePainter oldDelegate) {
return oldDelegate.value != value;
}
}
这里有两个独立问题:
shouldRepaint判断像素绘制是否变化;shouldRebuildSemantics判断语义节点是否需要更新。
一个图形可能视觉上不变但语义发生变化,也可能视觉发生细微动画而语义不需要每帧更新。生产代码应根据实际信息分别判断。
如果交互区域复杂、需要焦点、文本、按钮状态和键盘操作,普通 Widget 组合往往比把所有语义都塞进一个 painter 更合适。CustomPainter 适合视觉密度高、几何结构明确的内容,但不应因为绘制方便而牺牲基本可访问性。
重绘性能:成本在哪里
paint 轻不代表整体一定快
每一帧的大致成本可能来自:
Widget build
→ layout
→ painter.paint
→ Canvas 命令记录
→ 栅格化
→ 图层合成
→ 平台显示
只优化 paint 中的 Dart 循环,不代表所有性能问题都被解决。常见成本包括:
- 大量路径和图形命令;
- 复杂裁剪;
saveLayer;- 阴影和模糊;
- 大面积半透明内容;
- 高频图片解码或上传;
- 频繁改变导致大区域重新栅格化;
- 多层叠加和过度绘制。
CustomPainter 可以减少 Widget 数量,但不是自动性能优化器。
shouldRepaint 的正确优化边界
如果新旧 painter 的结果确定相同,返回 false 可以避免一次无意义重绘:
@override
bool shouldRepaint(covariant GridPainter oldDelegate) {
return oldDelegate.lineCount != lineCount ||
oldDelegate.color != color;
}
如果结果可能不同,就不能为了优化返回 false。正确性优先于跳过重绘。
还应避免在 paint 中做不必要的对象创建,尤其是高频动画:
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color;
for (final value in values) {
// 如果每次循环都创建 Path、渐变或复杂对象,成本会更高
}
}
可以复用不依赖尺寸和状态的静态几何数据,但必须注意尺寸变化、DPR、主题颜色和变换变化。缓存错误通常比不缓存更难诊断。
RepaintBoundary 的作用和边界
RepaintBoundary 会在渲染树中建立重绘边界:
RepaintBoundary(
child: CustomPaint(
painter: ExpensivePainter(data),
size: const Size(800, 400),
),
)
它的核心作用是隔离重绘传播:
- 边界外的父级发生重绘时,边界内内容可以复用;
- 边界内发生重绘时,父级不必因此重新绘制整个相关区域。
但它不是“让 painter 每帧变快”的开关。若 painter 自己每帧更新,边界内仍然要重绘。边界还可能增加图层管理和缓存资源。因此是否添加,应通过实际帧分析验证,而不是对所有 CustomPaint 统一包裹。
适合考虑边界的场景包括:
- 大型静态背景;
- 与其他频繁变化内容相互独立的图表;
- 重绘区域明确且边界稳定的复杂绘制。
绘制前先裁剪可见区域
如果画布表示长列表、时间轴或地图,数据量可能远大于当前可见区域。不要无条件绘制所有对象:
for (final item in items) {
if (item.rect.overlaps(visibleRect)) {
drawItem(canvas, item);
}
}
这个条件的正确性来自几何事实:如果图形包围盒与可见矩形不相交,图形不可能在当前可见区域产生像素,因此可以跳过。
但包围盒只是粗略判断。旋转图形、阴影、描边和模糊可能超出原始几何边界,因此可见矩形应适当扩张:
final expandedVisibleRect = visibleRect.inflate(maxStrokeWidth);
这是一种正确性与性能之间的边界:裁剪过窄会截断内容,裁剪过宽则减少不了多少绘制。
drawPath、图片和文本的取舍
复杂图形应尽量减少重复构造:
- 静态路径可以在状态对象中预计算;
- 渐变、图片和文本布局对象不应在每个小图形循环中重复创建;
- 图片应使用合适的尺寸和缓存策略;
- 文本应通过
TextPainter布局后绘制,而不是手工逐字模拟。
示例:
final textPainter = TextPainter(
text: const TextSpan(
text: '42',
style: TextStyle(
color: Colors.black,
fontSize: 16,
),
),
textDirection: TextDirection.ltr,
)..layout();
textPainter.paint(canvas, const Offset(10, 10));
TextPainter.layout() 需要文本方向;在国际化场景中还应考虑 locale、字体回退、RTL 和字体加载。文本绘制结果还可能因平台字体栅格化差异而不同。
诊断性能问题
先区分 build、layout 和 raster
Flutter 性能工具中应观察至少两类问题:
- UI 线程问题:build、layout、Dart 计算过重;
- Raster 线程问题:Canvas 绘制、栅格化、图层合成过重。
如果 shouldRepaint 已经避免了无效重绘,但 raster 时间仍高,原因可能是:
- 单次绘制本身太复杂;
saveLayer或阴影成本高;- 透明层过多;
- 绘制区域过大;
- 图片或文本栅格化昂贵。
如果 paint 很轻但 UI 线程高,则应检查是否因为状态变化导致大量 Widget 重建或布局。
使用调试工具定位
在开发阶段,可以使用:
- Flutter DevTools 的 Performance 视图;
- 帧时间线;
- Flutter Inspector;
PerformanceOverlay;- Widget 重建和 RenderObject 重绘相关的调试选项。
例如:
MaterialApp(
showPerformanceOverlay: true,
home: const CustomPainterDemoPage(),
)
性能覆盖层适合快速观察帧时间,但不能代替真实设备测试。调试模式、模拟器、桌面窗口和浏览器环境都可能与发布模式不同。
还可以在 paint 中临时记录绘制次数:
int paintCount = 0;
@override
void paint(Canvas canvas, Size size) {
paintCount++;
debugPrint('paint count: $paintCount');
}
这只能帮助确认是否发生重绘,不能证明重绘本身是否昂贵;生产代码不应保留高频日志。
不要只看平均帧率
动画卡顿可能是偶发峰值造成的。应关注:
- 单帧最长耗时;
- UI 和 raster 两条线程各自的峰值;
- 首帧和图片/字体加载时的抖动;
- Web 浏览器窗口尺寸改变时的重绘;
- 低端 Android 设备和高 DPR 屏幕上的实际表现。
在 Web 上,Flutter 的渲染后端和浏览器环境会影响 Canvas、文本、图片以及合成行为。桌面平台可能拥有更大的窗口和不同的 DPR;移动平台则更容易受到 GPU 带宽和电量约束。跨平台绘制逻辑可以复用,但性能结论必须按目标平台验证。
常见失败模式
失败一:绘制超出边界后试图“撑大”组件
CustomPaint(
painter: PainterThatDrawsTo1000,
size: const Size(100, 100),
)
结果是 painter 仍然只有 100 × 100 的布局区域。绘制不会参与反向布局。修复方法是修改布局尺寸,而不是修改 paint 中的坐标。
失败二:用全局坐标直接命中局部图形
onTapDown: (details) {
// 错误前提:假设 details.globalPosition 可以直接和 painter 几何比较
}
全局坐标需要转换为接收组件的局部坐标。优先使用 localPosition,或者调用 globalToLocal。
失败三:改变 painter 字段但 shouldRepaint 永远为 false
这会造成状态和画面不一致。排查时比较:
- Widget 是否创建了新的 painter;
- painter 字段是否真的变化;
shouldRepaint是否覆盖了所有影响绘制的字段;- 数据集合是否被原地修改;
- 是否存在
repaintListenable 但 painter 读取的仍是旧快照。
失败四:在 paint 中修改状态
以下做法会制造渲染重入或生命周期问题:
@override
void paint(Canvas canvas, Size size) {
setState(() {
// 错误:paint 不应驱动 Widget 状态更新
});
}
paint 应是根据当前状态产生绘制命令的过程,不应在其中调用 setState、触发布局或修改外部业务状态。
失败五:遗漏 save/restore
canvas.translate(100, 0);
drawFirstObject(canvas);
// 忘记 restore
drawSecondObject(canvas);
第二个对象也会被平移。修复方式是让每个局部变换成对出现:
canvas.save();
canvas.translate(100, 0);
drawFirstObject(canvas);
canvas.restore();
drawSecondObject(canvas);
失败六:把 hitTest 当作手势识别器
重写 CustomPainter.hitTest 只能参与“是否命中”的判断,不能替代 GestureDetector、Listener 或 MouseRegion。如果事件已经命中但没有手势处理器,仍然不会自动产生业务回调。
失败七:把 saveLayer 当成普通分组 API
saveLayer 能解决混合和离屏效果问题,但不是普通的代码分组工具。若只是需要保存坐标和裁剪状态,应使用 save;只有确实需要离屏合成时才使用 saveLayer。
何时使用 CustomPainter,何时使用普通 Widget
CustomPainter 特别适合:
- 图表、曲线、坐标轴;
- 仪表盘、进度环、波形;
- 画布、标注、编辑器辅助线;
- 大量规则几何图形;
- 需要精确控制绘制顺序和像素外观的内容。
普通 Widget 更适合:
- 文本、按钮、列表等具有独立交互和语义的元素;
- 需要自动布局、焦点、无障碍和主题继承的界面;
- 图形数量少但每个图形都是独立业务组件的场景。
实际项目经常采用混合方式:用 CustomPainter 绘制背景和大量几何内容,再用 Widget 叠加按钮、标签、工具提示和可访问交互区域。这样可以让绘制效率与 Flutter 的布局、手势和语义系统分别承担适合自己的职责。
结语
理解 CustomPainter 的关键不是记住若干 drawXxx 方法,而是建立完整因果链:
- 父布局决定
CustomPaint的尺寸; - painter 在自己的局部坐标系中接收
Canvas和Size; Canvas按顺序执行绘制命令,并通过矩阵和裁剪改变后续结果;- 新 painter 是否需要重新绘制由
shouldRepaint或repaint通知决定; - 绘制结果不会自动形成交互,命中测试和手势识别需要单独设计;
- 语义信息也不会自动从像素中产生;
- 性能既取决于重绘频率,也取决于绘制命令、栅格化、离屏图层和合成成本。
在工程实践中,先确保尺寸、坐标、变换和命中逻辑一致,再用 DevTools 区分 build、layout 与 raster 成本,最后针对真实平台验证。这样才能在保持视觉正确性的前提下,对重绘和性能做出可靠取舍。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 资源与图片:Asset、网络缓存、解码、分辨率和内存
- 下一篇:Flutter 隐式动画:Tween、曲线、状态切换和适用边界
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论