28 · 自定义渲染:CustomPainter / Sliver / RenderObject
先看这段定位,避免和前几篇混淆:
篇 讲什么 心智 03 篇 三棵树、生命周期、rebuild/repaint/relayout 渲染是什么 04 篇 SliverAppBar、吸顶头、NestedScrollView用现成的 Sliver 本篇 CustomPainter、自定义RenderObject、自定义RenderSliver自己写渲染 ⚠️ 代码来源说明:HaierEnergy 当前
lib/中没有自绘代码(CustomPainter全库仅在 22 篇 被提及一次)。 因此本篇以通用可运行实战为主,所有示例均可直接粘进练习场运行,不虚构项目代码。
开篇:前端概念对照
| 前端 | Flutter | 说明 |
|---|---|---|
Canvas 2D API / SVG path | CustomPainter + Canvas | 命令式绘制,自己控制每一笔 |
react-window / 虚拟滚动 | Sliver 协议 | 只渲染可见区域 |
绝对定位 + transform | canvas.translate/rotate/scale | 画布变换 |
| Web Components 自定义元素 | 自定义 RenderObject | 下沉到渲染层 |
CSS display:flex 的布局算法 | performLayout() 自己实现 | 摆放规则自己定 |
getBoundingClientRect / 命中测试 | hitTest() | 判断点击落在哪 |
ctx.save() / ctx.restore() | canvas.save() / canvas.restore() | 完全一致,必须成对 |
一句话核心:Widget 只是配置(蓝图),真正量尺寸、画像素、判点击的是 RenderObject。 而 CustomPainter 是"在 RenderObject 给出的画布上作画"的简化入口——只管画,不管布局。
一、先决定:你真的需要自定义渲染吗?(逃生舱分层)
Flutter 的"逃生舱"是分层的,能用上一层就别下潜:
第 1 层(首选):组合现成 Widget
Row / Column / Stack / Container / DecoratedBox / ClipRRect / Card ...
→ 90% 的 UI 到这一层就解决了
第 2 层:CustomPainter(只画,不布局)
图表、进度环、签名板、波浪背景、渐变背景、虚线边框
→ 只负责"在一个给定矩形里画什么",尺寸仍由父级决定
第 3 层:自定义 RenderObject(布局 + 绘制 + 命中)
瀑布流、环形菜单、自定义标签流、精确 baseline 对齐
→ 要自己实现 performLayout / paint / hitTest
第 4 层(极少):自定义 RenderSliver(自定义滚动协议)
自定义吸顶、视差滚动、非线性滚动、"露出半个卡片"效果
→ 要实现 SliverConstraints → SliverGeometry判断口诀
| 症状 | 用哪层 |
|---|---|
| 只是长得不一样 | 第 2 层(CustomPainter) |
| 连摆放规则都不一样 | 第 3 层(RenderObject) |
| 连滚动行为都不一样 | 第 4 层(RenderSliver) |
💡 成本提醒:每下一层,代码量和"别人看不懂的程度"都显著上升。 下潜前先问:现成 Widget 真的组合不出来吗?
二、CustomPainter 实战(第 2 层)
2.1 最小骨架:进度环
class CircleProgressPainter extends CustomPainter {
CircleProgressPainter({required this.progress, required this.color});
final double progress; // 0.0 ~ 1.0
final Color color;
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2 - 4;
// 底环
final bgPaint = Paint()
..color = Colors.grey.shade300
..style = PaintingStyle.stroke
..strokeWidth = 8
..strokeCap = StrokeCap.round;
canvas.drawCircle(center, radius, bgPaint);
// 进度弧
final fgPaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 8
..strokeCap = StrokeCap.round;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-pi / 2, // 从 12 点方向起(angle 0 在 3 点方向)
pi * 2 * progress, // 扫过的弧度
false,
fgPaint,
);
}
@override
bool shouldRepaint(CircleProgressPainter oldDelegate) =>
oldDelegate.progress != progress || oldDelegate.color != color;
}使用:
CustomPaint(
painter: CircleProgressPainter(progress: 0.7, color: Colors.blue),
size: const Size(120, 120), // 无 child 时必填
child: const Center(child: Text('70%')), // 有 child 时可省略 size
)2.2 三个必须搞懂的点
① 坐标系与角度
- 原点
(0,0)在左上角,x 向右、y 向下(与 Web Canvas 一致) drawArc的startAngle:0= 3 点钟方向,顺时针为正 → 想从 12 点开始,写-pi / 2- 单位是弧度(
pi = 180°),不是角度
② size 是"父级给的",不是你定的
- 没有
child时必须给size,否则默认Size.zero→ "我画了但什么都不显示"的最常见原因 - 有
child时,CustomPaint尺寸 = child 尺寸
③ painter vs foregroundPainter
| 参数 | 绘制层级 |
|---|---|
painter | 在 child 下面(背景) |
foregroundPainter | 在 child 上面(前景:水印、遮罩、刮刮卡) |
2.3 shouldRepaint:性能开关
@override
bool shouldRepaint(MyPainter old) => old.progress != progress; // 逐字段比较这是重绘开关,返回 true 才重绘:
| 写法 | 后果 |
|---|---|
return true | 每次父级 rebuild 都重绘 → 浪费 GPU |
return false | 永不重绘(适合纯静态图案) |
| 逐字段比较(推荐) | 只在真正变化时才画 |
2.4 Canvas API 速查(前端对照)
| 需求 | Web Canvas 2D | Flutter Canvas |
|---|---|---|
| 画圆 | arc() | drawCircle() |
| 画矩形 | fillRect() | drawRect() |
| 圆角矩形 | roundRect() | drawRRect() |
| 路径 | beginPath()+stroke() | Path() + drawPath() |
| 画文字 | fillText() | TextPainter + paint()(见下) |
| 画图片 | drawImage() | drawImage() / drawImageRect() |
| 渐变 | createLinearGradient() | Paint()..shader = LinearGradient(...).createShader(rect) |
| 裁剪 | clip() | clipRect() / clipPath() |
| 变换 | translate/rotate/scale | translate/rotate/scale |
| 存/取状态 | save()/restore() | save()/restore() ✅ 成对 |
画文字必须用 TextPainter(Flutter 没有 fillText):
final tp = TextPainter(
text: const TextSpan(text: '70%', style: TextStyle(fontSize: 20)),
textDirection: TextDirection.ltr, // 必填!否则抛异常
)..layout(); // 必须先 layout
tp.paint(canvas, const Offset(40, 50));save/restore 铁律:做 translate / rotate / clip 之前 save(),之后 restore()。 否则变换会累积污染后续所有绘制。
2.5 实战:波浪背景(save/restore + Path)
class WavePainter extends CustomPainter {
WavePainter(this.phase);
final double phase; // 0.0 ~ 1.0,由动画驱动
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = Colors.blue.withOpacity(0.3);
final path = Path()
..moveTo(0, size.height * 0.6)
..quadraticBezierTo(
size.width * 0.25, size.height * (0.4 + 0.1 * sin(phase * 2 * pi)),
size.width * 0.5, size.height * 0.6,
)
..quadraticBezierTo(
size.width * 0.75, size.height * (0.8 - 0.1 * sin(phase * 2 * pi)),
size.width, size.height * 0.6,
)
..lineTo(size.width, size.height)
..lineTo(0, size.height)
..close();
canvas.save(); // 1. 先保存
canvas.drawPath(path, paint);
canvas.restore(); // 2. 后恢复
}
@override
bool shouldRepaint(WavePainter old) => old.phase != phase;
}驱动动画(配合 05 篇):
AnimatedBuilder(
animation: _controller, // AnimationController
builder: (_, __) => CustomPaint(
painter: WavePainter(_controller.value),
size: Size(double.infinity, 200),
),
)三、自定义 RenderObject(第 3 层)
3.1 什么时候需要它
要自定义布局算法——不是画得不一样,而是摆放规则不一样。 典型案例:瀑布流、环形菜单、Wrap 不够用的标签流、精确 baseline 对齐。
3.2 三种入口(按 child 数量选)
| child 数量 | Widget 继承 | RenderObject 继承 |
|---|---|---|
| 0 个(纯自绘) | LeafRenderObjectWidget | RenderBox |
| 1 个 | SingleChildRenderObjectWidget | RenderProxyBox / RenderBox |
| 多个 | MultiChildRenderObjectWidget | RenderBox + ContainerRenderObjectMixin |
3.3 RenderBox 的三件事
| 方法 | 职责 | 阶段 |
|---|---|---|
performLayout() | 定尺寸:读 constraints → 给自己 size → 给孩子 layout | 布局 |
paint() | 画像素:在 (offset, size) 区域内绘制 | 绘制 |
hitTest() | 判点击:要不要响应触摸 | 命中测试 |
3.4 布局协议(与 CSS 完全不同,务必记住)
Constraints go down. Sizes go up. Parent sets position.
约束向下传,尺寸向上报,位置由父级定。关键差异(前端最容易搞错的一点):
- 父级给孩子一个
BoxConstraints(min/max 宽高) - 孩子必须在约束内选一个
size并上报 - 但孩子不能决定自己在哪 —— 位置是父级在
paint时通过offset给的
对比 CSS:Web 里元素可以
position:absolute; left:50px自己定位; Flutter 里没有"自己定位置"这回事,位置永远由父级给。
3.5 实战:环形菜单(MultiChildRenderObjectWidget)
class RadialMenu extends MultiChildRenderObjectWidget {
RadialMenu({super.key, required List<Widget> children}) : super(children: children);
@override
RenderObject createRenderObject(BuildContext context) => RenderRadialMenu();
}
class RadialMenuParentData extends ContainerBoxParentData<RenderBox> {}
class RenderRadialMenu extends RenderBox
with ContainerRenderObjectMixin<RenderBox, RadialMenuParentData> {
static const double radius = 100.0;
@override
void setupParentData(RenderBox child) {
if (child.parentData is! RadialMenuParentData) {
child.parentData = RadialMenuParentData();
}
}
@override
void performLayout() {
size = constraints.biggest; // ① 先给自己定尺寸(占满父级给的约束)
var child = firstChild;
final count = childCount;
var i = 0;
while (child != null) {
// ② 给孩子约束并让它布局
child.layout(
BoxConstraints.loose(const Size(60, 60)),
parentUsesSize: true,
);
// ③ 计算孩子的偏移(位置由父级定!)
final angle = (2 * pi / count) * i - pi / 2;
final center = Offset(size.width / 2, size.height / 2);
final offset = center + Offset(cos(angle) * radius, sin(angle) * radius)
- Offset(child.size.width / 2, child.size.height / 2);
(child.parentData as RadialMenuParentData).offset = offset;
child = childAfter(child);
i++;
}
}
@override
void paint(PaintingContext context, Offset offset) {
var child = firstChild;
while (child != null) {
final pd = child.parentData as RadialMenuParentData;
context.paintChild(child, pd.offset + offset); // ④ 用记录的 offset 绘制
child = childAfter(child);
}
}
@override
bool hitTestChildren(BoxHitTestResult result, {required Offset position}) =>
defaultHitTestChildren(result, position: position); // ⑤ 默认实现通常够用
}要点拆解:
| 步骤 | 关键 API | 说明 |
|---|---|---|
| 管理 children | ContainerRenderObjectMixin | 提供 firstChild / childAfter() 链表遍历 |
| 记录每个 child 位置 | parentData + setupParentData() | 自定义 ParentData 存 offset |
| 布局 | child.layout(constraints, parentUsesSize: true) | parentUsesSize: true 表示父级要读 child 的 size |
| 绘制 | context.paintChild(child, offset) | offset 是相对本 RenderBox 的 |
| 命中 | defaultHitTestChildren() | 多数场景直接用默认 |
3.6 sizedByParent 优化
若尺寸只取决于约束、与 children 无关,覆写 sizedByParent => true 并在 performResize() 里设 size,可跳过部分 layout 流程:
@override
bool get sizedByParent => true;
@override
void performResize() {
size = constraints.biggest; // 尺寸只由约束决定
}
@override
void performLayout() {
// 这里只负责给孩子 layout,不用再给自己 size
}四、自定义 Sliver(第 4 层)
⚠️ 先确认 04 篇 的现成 Sliver 不够用。 90% 的滚动需求用
CustomScrollView+SliverList/SliverGrid/SliverPersistentHeader组合即可解决。
4.1 Sliver 协议:与 RenderBox 的根本差异
RenderBox | RenderSliver | |
|---|---|---|
| 输入约束 | BoxConstraints | SliverConstraints(含 scrollOffset、remainingPaintExtent、overlap…) |
| 输出结果 | size | SliverGeometry(scrollExtent、paintExtent、layoutExtent、visible…) |
| 坐标系 | 笛卡尔 (x, y) | 滚动偏移轴(主轴方向) |
| 典型实现 | Row / Flex | SliverList / SliverGrid / SliverAppBar |
核心区别(虚拟滚动的本质):
SliverGeometry.paintExtent 可以小于 scrollExtent —— 即"只绘制可见部分,但占位仍是完整高度"。这就是 Sliver 能承载超长列表却不卡的原因。
4.2 最小自定义 Sliver 骨架
class MySliver extends SingleChildRenderObjectWidget {
const MySliver({super.key, super.child});
@override
RenderObject createRenderObject(BuildContext context) => RenderMySliver();
}
class RenderMySliver extends RenderSliverSingleBoxAdapter {
static const double childExtent = 200.0; // 本 sliver 的总高度
@override
void performLayout() {
// 计算"当前有多少像素落在可见区域内"
final double painted = calculatePaintOffset(
constraints,
from: 0.0,
to: childExtent,
);
geometry = SliverGeometry(
scrollExtent: childExtent, // 占位(滚动条按这个算)
paintExtent: painted, // 实际绘制(可 < scrollExtent)
maxPaintExtent: childExtent,
hasVisualOverflow: childExtent > constraints.remainingPaintExtent,
);
child?.layout(
constraints.asBoxConstraints(minExtent: 0, maxExtent: childExtent),
parentUsesSize: true,
);
}
@override
void paint(PaintingContext context, Offset offset) {
if (child != null && geometry!.visible) {
context.paintChild(
child!,
Offset(0, offset.dy - constraints.scrollOffset), // 抵消已滚过的距离
);
}
}
}4.3 关键 API
| API | 作用 |
|---|---|
calculatePaintOffset(constraints, from:, to:) | 计算某段在可见区域内的高度(最常用) |
constraints.scrollOffset | 当前已滚过的距离 |
constraints.remainingPaintExtent | viewport 剩余可见高度 |
constraints.overlap | 被前一个 sliver(如 AppBar)遮挡的高度 |
geometry.visible | 当前是否可见(paintExtent > 0) |
4.4 什么时候才值得自己写
| 场景 | |
|---|---|
| ✅ 值得 | 非线性滚动视觉效果、自定义视差、动态高度吸顶、"露出半个卡片"布局 |
| ❌ 不值得 | 能用 SliverList / SliverGrid / SliverPersistentHeaderDelegate 组合出来的 |
💡 更轻量的替代方案(优先于自己写): ①
SliverPersistentHeaderDelegate定制吸顶头(04 篇已讲) ②SliverToBoxAdapter把普通 Box Widget 包进 Sliver ③RenderSliverToBoxAdapter把自定义 RenderBox 包进 Sliver
五、性能与调试
5.1 三条硬规则
① shouldRepaint 必须精确(见 2.3)——无脑 return true 是首要性能杀手。
② 用 repaint 参数隔离重绘范围:
class MyPainter extends CustomPainter { /* ... */ }
// 让 painter 继承 Listenable,只在该 Listenable 变化时重绘
CustomPaint(
painter: MyPainter(),
repaint: _animation, // ← 只有 animation 变化才重绘,父级 rebuild 不触发
)③ 复杂自绘区包 RepaintBoundary,避免影响兄弟 Widget(详见 03 篇「优化手段三」)。
5.2 调试工具
| 工具 | 看什么 |
|---|---|
| Flutter Inspector · Select Widget Mode | 自绘区域边界对不对 |
debugPaintSizeEnabled = true | 显示所有布局边界(查尺寸问题) |
debugPaintBaselinesEnabled = true | 文字基线对齐 |
| DevTools · Performance 面板 | paint 耗时是否异常 |
| Performance Overlay(彩虹) | 哪里在反复重绘(出现彩虹色 = 在重绘) |
void main() {
debugPaintSizeEnabled = true; // 临时打开,调试完记得关掉
runApp(const MyApp());
}5.3 常见性能坑
| 坑 | 解法 |
|---|---|
在 paint() 里 new Paint() / Path() / TextPainter() | 提到成员变量复用(paint 每秒可能调用 60 次) |
TextPainter 每次都 layout() | 文字不变时缓存 |
渐变每次 createShader() | 缓存 shader |
大面积 clipPath | 裁剪本身很贵,考虑改用 CustomClipper |
六、动手练习
练习 A:进度环(入门)
实现 2.1 的 CircleProgressPainter,加一个 Slider 拖动改变 progress。 对比实验:把 shouldRepaint 改成 return false,拖动时会怎样?再改成 return true,用 Performance Overlay 看重绘。
练习 B:波浪背景(进阶)
用 Path + quadraticBezierTo 画波浪,AnimationController 驱动横向流动。 注意:save/restore 成对、shouldRepaint 精确、Paint 对象复用。
练习 C:环形菜单(挑战)
用 MultiChildRenderObjectWidget 实现 3.5 的环形菜单,N 个 child 均匀分布圆周。 重点体会:constraints → size → 给每个 child 设 offset → paintChild。
练习场:见专题 README 的「配套练习场」(
lib/main_playground.dart)。03~06章通用 Demo 可直接粘入运行。
七、自检清单
学完本文,你应该能够:
- [ ] 说出"逃生舱四层",并在给定需求时判断该用哪一层
- [ ] 解释为什么
CustomPaint没给size时什么都不会画 - [ ] 说出 Flutter 角度
0在 3 点钟方向,并算出 12 点方向的起始角 - [ ] 写出
shouldRepaint的正确写法,说明无脑true的代价 - [ ] 用
TextPainter在 Canvas 上画文字(记得textDirection与layout()) - [ ] 说明
save/restore为什么必须成对 - [ ] 背出布局协议:"Constraints go down, sizes go up, parent sets position"
- [ ] 说出 RenderBox 的三件事(layout / paint / hitTest)
- [ ] 解释
paintExtent为何可以小于scrollExtent,并说明这就是虚拟滚动的本质 - [ ] 说出
calculatePaintOffset的作用 - [ ] 列出至少 3 个自绘性能坑及解法
- [ ] 判断一个滚动需求该用现成 Sliver 还是自己写
RenderSliver
八、常见误区
| 误区 | 正解 |
|---|---|
| "自定义渲染 = 性能更好" | ❌ 自绘写不好更慢。自绘是为了获得能力,不是优化手段。优化优先拆 Widget / const / RepaintBoundary(见 03、22 篇) |
| "UI 不好做就自己画" | ❌ 先确认现成 Widget 组合不出来。自绘维护成本高、他人难接手 |
"size 由我自己定" | ❌ size 来自父级 constraints;CustomPaint 无 child 时必须显式给 size |
| "角度 0 是正上方" | ❌ 0 在 3 点钟方向,12 点是 -pi/2 |
| "paint 里 new Paint() 没关系" | ❌ paint 每秒可达 60 次,对象要提到成员变量复用 |
| "Sliver 和 Box 差不多" | ❌ 输入输出完全不同(SliverConstraints → SliverGeometry),坐标系是滚动轴 |
| "命中测试要自己写" | ❌ defaultHitTestChildren() 多数场景够用,别重复造轮子 |
相关文档
- 03 · Flutter 渲染原理与生命周期 —— 三棵树、rebuild/repaint/relayout、
RepaintBoundary - 04 · Flutter 布局与滚动进阶 —— 现成 Sliver 组件用法(优先用它)
- 05 · Flutter 交互与动画 ——
AnimationController(本篇波浪练习的基础) - 11 · 组件与主题 —— 自绘组件如何纳入项目组件体系
- 22 · 性能分析与优化实战 —— 渲染性能的系统排查方法