1. 为什么要自定义 RenderObject
在 Flutter 中,我们平时开发中最常打交道的是 Widget。它们像是 UI 的“配置表”,描述界面要长什么样。但 Widget 并不真正负责绘制,它只是告诉框架“我要一个红色的方块”或者“我要一个可滚动的列表”。真正负责把这些需求落到屏幕上的,是底层的 RenderObject。
Flutter 的渲染体系大致可以理解为三层:
- Widget:声明式配置,告诉框架“我要什么”。
- Element:连接桥梁,管理生命周期和树的更新。
- RenderObject:执行者,负责布局、绘制和事件响应。
虽然在日常开发中,我们大多数场景只需要写 Widget 就够了,但有时候,会发现 Widget 层的封装并不能完全满足需求:
- 比如说要画一个圆形进度条,官方库里没有现成组件。用 Container + Stack + 各种组合方式,性能和灵活性都不太理想
- 想在滚动场景下实现特殊效果(比如一个“可伸缩的头部”或“性能更优的超大网格”),用现成的 SliverList、GridView 就显得有点力不从心。
- 想在优化性能时,如果 Widget 组合太复杂,层级过深,就会导致频繁 rebuild / relayout,渲染卡顿。
这时候,就需要“下潜”到 RenderObject 层,去直接操作布局和绘制逻辑了。
写自定义 RenderObject 看似“黑魔法”,但本质上它只是更贴近底层的工具:我们可以跳过 Widget 的限制,直接告诉 Flutter 如何计算子元素的大小、如何摆放、如何在画布上绘制。这样做既能提升性能,也能实现官方组件做不到的效果。
2. RenderObject
在 Flutter 中,RenderObject 是真正负责布局与绘制的执行者,但它并不是单一的存在,而是一个体系,有着不同的分工与扩展。理解这些角色和它们的生命周期方法,是知道怎样自定义渲染器的前提。
2.1 RenderObject 功能
可以把 Widget 树想象成一份 设计图纸,而 RenderObject 树就是负责施工的 工程队。在工程队内部,不同工种各司其职:
- RenderObject
所有渲染对象的基类。定义了最核心的接口:布局(layout)、绘制(paint)、事件响应(hitTest)等。但它本身比较抽象,很少直接继承。
- RenderBox
最常用的子类,它采用二维 盒模型(box model),约束(constraints)决定宽高,最终计算出 size。大部分常见组件(Text、Container、Image)底层都是 RenderBox。
- RenderSliver
专为滚动场景设计。它不直接用宽高描述大小,而是使用 SliverConstraints / SliverGeometry,能描述“在可见窗口里渲染多少像素”。像 ListView、GridView 的底层都是基于 RenderSliver。
简而言之,RenderObject 是 抽象基类,RenderBox 负责 常规布局,RenderSliver 负责 滚动优化。
2.2 生命周期的关键方法
RenderObject 的生命周期可以理解为一套流水线:布局 → 绘制 → 事件响应。
- performLayout:负责测量和确定自身(以及子节点)的大小与位置。在 RenderBox 中,这通常就是根据 BoxConstraints 计算 size。
- paint:负责把内容画到画布(Canvas)上,例如绘制矩形、圆形、图片等,paint 不关心布局,只关心“怎么画”。
- hitTest:处理事件响应,判断点击、拖拽等交互是否落在当前区域,如果返回 true,事件就会被传递给对应的子节点。
这三个方法是自定义 RenderObject 时最常 override 的。
2.3 computeDryLayout 的意义
在 Flutter 2.0 之后,框架引入了 computeDryLayout 方法,用于在不真正触发布局的情况下 预估大小。
为什么需要这个?
因为某些父组件(比如 IntrinsicWidth、IntrinsicHeight)需要提前知道子组件的理想尺寸,但又不能真的触发 layout。而computeDryLayout 提供了一个“试算”机会,可以在不影响布局树的前提下返回一个 Size。
因此,当我们写 RenderBox 时,推荐同时实现 performLayout 和 computeDryLayout,确保你的控件在各种情况下表现一致。
2.4 markNeedsLayout / markNeedsPaint
除了生命周期方法,RenderObject 还提供了“通知系统需要重新计算”的机制:
- markNeedsLayout:告诉框架“我的布局无效了,需要重新走 performLayout”。常见于属性变化影响大小时。
- markNeedsPaint:告诉框架“我的绘制内容变了,需要重绘”。常见于颜色、进度值变化但不影响大小时。
这两个方法会把节点标记为 dirty,等待下一帧统一处理,从而避免重复计算。
2.5 自定义 RenderBox 生命周期
下面是一个示例,打印布局和绘制的调用顺序:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| class DebugBox extends RenderBox { @override void performLayout() { size = constraints.constrain(Size(100, 100)); debugPrint('performLayout called, size = $size'); }
@override void paint(PaintingContext context, Offset offset) { debugPrint('paint called at $offset'); final Paint paint = Paint()..color = const Color(0xFF42A5F5); context.canvas.drawRect(offset & size, paint); }
@override bool hitTestSelf(Offset position) { debugPrint('hitTest at $position'); return true; } }
|
当我们在界面中嵌入这个组件时,可以看到:先走 performLayout,接着 paint,最后在点击时触发 hitTest。这就是 RenderObject 的完整工作流程。
3. CustomPainter 与 Canvas
在真正深入 RenderObject 的 paint 方法之前,我们先从一个更易上手的入口切入:CustomPainter。它是 Flutter 提供的“绘制接口”,我们可以在其中直接操作 Canvas,实现各种自定义图形,而不必一开始就写 RenderBox。
3.1 CustomPainter 的定位
如果把 Flutter 的绘制体系比作“造房子”:
- Widget 是户型设计图
- RenderBox.paint 是工人亲手砌墙、刷漆
- CustomPainter 就像雇了一支“外包团队”,你只需定义绘制逻辑,它就能被插入到渲染流程中。
使用 CustomPainter 有几个优点:
- 快速验证绘制逻辑,不用直接继承 RenderBox。
- 高度灵活,可随时挂到 CustomPaint Widget 上。
- 天然支持组合,一个界面可以有多个 CustomPainter 叠加绘制。
因此,CustomPainter 往往是初学者进入 RenderObject 世界的第一步。
3.2 生命周期与 shouldRepaint
编写 CustomPainter 时,必须实现 paint 和 shouldRepaint:
paint(Canvas, Size)
:在这里写绘制逻辑,例如画矩形、圆形、路径等。
shouldRepaint(CustomPainter oldDelegate)
:用来判断“是否需要重绘”。如果返回 true,Flutter 会在下一帧重新调用 paint。
很多初学者会偷懒,直接这样写:
1 2
| @override bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
这样虽然简单,但意味着 每一帧都会重绘,即使内容没变化,也会浪费性能。
正确做法是:比较关键属性是否变化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| class RingPainter extends CustomPainter { final double progress; RingPainter(this.progress);
@override void paint(Canvas canvas, Size size) { final Paint paint = Paint() ..color = const Color(0xFF42A5F5) ..strokeWidth = 8 ..style = PaintingStyle.stroke; final Rect rect = Offset.zero & size; canvas.drawArc(rect, -3.14 / 2, 2 * 3.14 * progress, false, paint); }
@override bool shouldRepaint(covariant RingPainter oldDelegate) { return oldDelegate.progress != progress; } }
|
这样只有进度变化时才触发重绘,大大减少无效开销。
3.3 PictureRecorder 与缓存思路
有时绘制逻辑非常复杂(例如地图瓦片、复杂曲线),哪怕 shouldRepaint 正确实现,也可能导致性能瓶颈。
这时我们可以借助 PictureRecorder,它可以提前把绘制内容“录制”成 Picture 对象,下次需要绘制时,直接复用 Picture,而不是重新执行绘制逻辑。
例如:
1 2 3 4 5
| final recorder = PictureRecorder(); final canvas = Canvas(recorder); drawSomething(canvas); final picture = recorder.endRecording();
|
这就是 Flutter 内部很多控件(如 Icon)的缓存思路。
3.4 Layer 与绘制分层
Flutter 的绘制不是一次性画到底,而是基于 Layer Tree。每个 Layer 都可以单独缓存和复用,例如:
- PictureLayer:存放实际绘制内容
- TransformLayer:存放矩阵变换
- ClipLayer:存放裁剪区域
得益于分层机制,Flutter 可以只重绘变化的部分,而不是整个画布。
例如一个固定背景 + 移动小球的场景:
- 背景可以缓存到 PictureLayer,不必每帧重绘。
- 只有小球的 Layer 每帧更新,节省大量性能。
这也是为什么在复杂界面中,合理拆分 Layer 和缓存,是性能优化的关键。
3.5 示例:圆环进度条
3.5.1 代码实现
结合上面的知识,我们用 CustomPainter 实现一个圆环进度条:
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 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
|
class RingProgress extends StatelessWidget { final double progress; final double strokeWidth; final Color color; final Size size; const RingProgress({ super.key, required this.progress, this.strokeWidth = 6.0, this.color = Colors.blue, this.size = const Size(100.0, 100.0), }); @override Widget build(BuildContext context) { return CustomPaint( size: size, painter: _RingPainter( progress: progress, strokeWidth: strokeWidth, color: color, ), ); } }
class _RingPainter extends CustomPainter { final double progress; final double strokeWidth; final Color color; _RingPainter({ required this.progress, required this.strokeWidth, required this.color, }); @override void paint(Canvas canvas, Size size) { final Offset center = size.center(Offset.zero); final double radius = (size.shortestSide - strokeWidth) / 2; final Paint paint = Paint() ..style = PaintingStyle .stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round; paint.color = color.withValues(alpha: 0.2); canvas.drawCircle(center, radius, paint); paint.color = color; final Rect rect = Rect.fromCircle(center: center, radius: radius); final double sweepAngle = 2 * math.pi * progress; canvas.drawArc( rect, -math.pi / 2, sweepAngle, false, paint, ); } @override bool shouldRepaint(covariant _RingPainter oldDelegate) { return oldDelegate.progress != progress || oldDelegate.color != color || oldDelegate.strokeWidth != strokeWidth; } }
|
当 progress 更新时,只有进度条需要重绘;如果保持不变,就不会触发多余的 paint,保证帧率稳定。
3.5.2 使用示例
1 2 3 4 5 6 7 8 9 10 11 12
| class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'CustomPainter Demo', theme: ThemeData(primarySwatch: Colors.blue), home: const Scaffold(body: Center(child: RingProgress(progress: 0.4),),), ); } }
|
4. 自定义 RenderBox
在上一节里,我们用 CustomPainter 画了一个圆环进度条。但 CustomPainter 仍然依赖于现成的 RenderBox,它只接管了绘制逻辑。要想彻底理解 Flutter 渲染体系,我们必须自己动手写一个 RenderBox ——从布局到绘制都掌控在手。
这一节,我们会通过两个案例,完整体验 RenderBox 的工作流程:
- 圆形进度条 RenderBox:无子组件,练习 constraints → size → paint 的基本流程。
- 自定义两列网格 RenderBox:有子组件,演示如何调用 layoutChild。
同时,我们还会穿插讲解一些常见坑(如约束报错、无限大小)和性能注意点。
4.1 RenderBox 的定位
Flutter 的渲染管线是分层的:
- Widget:配置数据,声明 UI。
- Element:Widget 的实例,负责管理生命周期。
- RenderObject:真正执行 布局和绘制 的对象。
而 RenderBox 是 RenderObject 的一个子类,采用 盒模型(Box Constraints) 协议,最常见的 UI 元素(Container、Text、Image 等)都基于它。
4.1.1 constraints 和 size 的关系
在 RenderBox 里,constraints(约束)决定了可用空间,而我们必须在 performLayout 里通过 size = … 来给自己定一个最终尺寸。
BoxConstraints 提供了 最小/最大宽高:
1 2
| constraints.minWidth, constraints.maxWidth constraints.minHeight, constraints.maxHeight
|
规则:
- 不能超出 maxWidth / maxHeight
- 不能小于 minWidth / minHeight
- 如果你随便赋值一个 size 不满足 constraints,就会报错
4.1.2 如何调用layoutChild
子组件必须通过 child.layout(constraints, parentUsesSize: true)
来进行布局,constraints就是要给它分配的空间,parentUsesSize: true:表示父组件会依赖子组件的大小(否则访问 child.size 会报错)
注意:layoutChild 本质就是上面这行,只不过在 MultiChildRenderObjectWidget 的封装里会帮我们循环调用。
4.1.3 paint 阶段如何正确绘制
在 paint 方法里,你可以用 Canvas 来画东西,或者把子组件画出来。
1 2 3 4 5 6
| @override void paint(PaintingContext context, Offset offset) { final canvas = context.canvas; final Paint paint = Paint()..color = Colors.blue; canvas.drawCircle(offset + size.center(Offset.zero), size.shortestSide / 2, paint); }
|
1
| context.paintChild(child, childParentData.offset + offset);
|
这里 childParentData.offset 是在 performLayout 时保存的位置
我们写自定义 RenderBox,就是直接操作约束、大小和绘制逻辑,拥有很高的自由度。
4.2 圆形进度条 RenderBox
我们先从最简单的“无子节点” RenderBox 开始:一个圆环进度条。
4.2.1 核心流程
- 布局(performLayout)
RenderBox 必须设置自己的 size,否则会报错。constraints 提供父组件给的最大/最小宽高。 我们需要根据 constraints 决定最终大小。
- 绘制(paint)
获取 Canvas,调用绘制 API。使用进度值决定圆弧角度。
4.2.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 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103
| import 'dart:math'; import 'package:flutter/rendering.dart'; import 'package:flutter/widgets.dart';
class RingProgressBox extends LeafRenderObjectWidget { final double progress; final double strokeWidth; final Color color;
const RingProgressBox({ super.key, required this.progress, this.strokeWidth = 10, this.color = const Color(0xFF2196F3), });
@override RenderObject createRenderObject(BuildContext context) { return _RenderRingProgress(progress, strokeWidth, color); }
@override void updateRenderObject( BuildContext context, covariant _RenderRingProgress renderObject) { renderObject ..progress = progress ..strokeWidth = strokeWidth ..color = color; } }
class _RenderRingProgress extends RenderBox { double _progress; double _strokeWidth; Color _color;
_RenderRingProgress(this._progress, this._strokeWidth, this._color);
set progress(double value) { if (_progress != value) { _progress = value; markNeedsPaint(); } }
set strokeWidth(double value) { if (_strokeWidth != value) { _strokeWidth = value; markNeedsLayout(); } }
set color(Color value) { if (_color != value) { _color = value; markNeedsPaint(); } }
@override void performLayout() { final sizeValue = constraints.hasBoundedWidth && constraints.hasBoundedHeight ? Size(constraints.maxWidth, constraints.maxHeight) : const Size(100, 100);
size = sizeValue; }
@override void paint(PaintingContext context, Offset offset) { final canvas = context.canvas;
final center = offset + Offset(size.width / 2, size.height / 2); final radius = size.shortestSide / 2 - _strokeWidth / 2;
final backgroundPaint = Paint() ..color = _color.withValues(alpha: 0.2) ..style = PaintingStyle.stroke ..strokeWidth = _strokeWidth;
final progressPaint = Paint() ..color = _color ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..strokeWidth = _strokeWidth;
canvas.drawCircle(center, radius, backgroundPaint);
final sweepAngle = 2 * pi * _progress; canvas.drawArc( Rect.fromCircle(center: center, radius: radius), -pi / 2, sweepAngle, false, progressPaint, ); } }
|
这样我们就完全绕过了 CustomPainter,直接从 RenderBox 层实现了进度条。
4.2.3 使用示例
1 2 3
| Center( child: RingProgressBox(progress: 0.6), )
|
4.3 自定义两列网格 RenderBox
我们再来一个更复杂的例子:实现一个 两列网格布局,内部可以放子组件。
4.3.1 关键点
- RenderBox 支持子组件时,需要继承 RenderBox + ContainerRenderObjectMixin。
- 调用 layoutChild(child, constraints) 来让子组件自己布局。
- 再根据子组件的大小,决定整个父 RenderBox 的大小。
4.3.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 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
| import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'dart:math' as math;
class TwoColumnGrid extends MultiChildRenderObjectWidget { const TwoColumnGrid({super.key, required super.children}); @override RenderObject createRenderObject(BuildContext context) { return RenderTwoColumnGrid(); } }
class TwoColumnParentData extends ContainerBoxParentData<RenderBox> {}
class RenderTwoColumnGrid extends RenderBox with ContainerRenderObjectMixin<RenderBox, TwoColumnParentData>, RenderBoxContainerDefaultsMixin<RenderBox, TwoColumnParentData> { @override void setupParentData(RenderBox child) { if (child.parentData is! TwoColumnParentData) { child.parentData = TwoColumnParentData(); } } @override void performLayout() { final double maxWidth = constraints.hasBoundedWidth ? constraints.maxWidth : 300; final double childMaxWidth = maxWidth / 2; double dx = 0; double dy = 0; double rowHeight = 0; RenderBox? child = firstChild; int column = 0; while (child != null) { final childParentData = child.parentData as TwoColumnParentData; child.layout( BoxConstraints(maxWidth: childMaxWidth), parentUsesSize: true, ); childParentData.offset = Offset(dx, dy); rowHeight = math.max(rowHeight, child.size.height); if (column == 0) { dx = childMaxWidth; column = 1; } else { dx = 0; dy += rowHeight; rowHeight = 0; column = 0; } child = childParentData.nextSibling; } size = constraints.constrain(Size(maxWidth, dy + rowHeight)); } @override void paint(PaintingContext context, Offset offset) { defaultPaint(context, offset); } }
|
这个例子展示了如何 遍历子组件 → 调用 layout → 决定偏移量 → 设定 size。
4.3.3 使用示例
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
| class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: Center( child: SizedBox( width: 300, child: TwoColumnGrid( children: List.generate( 5, (i) => Container( height: 50.0 + (i * 10), color: Colors.primaries[i % Colors.primaries.length], alignment: Alignment.center, child: Text("Item $i"), ), ), ), ), ), ), ); } }
|
4.4 常见坑
- 忘记设置 size
如果 performLayout 里没有给 size 赋值,Flutter 会报错。所以需要始终保证 size = …。
- 遇到无限约束报错
例如放在 ListView 里时,可能 constraints 为 unbounded。一般需要给定一个默认大小(如 100×100)。
- 重绘过多
不要在 paint 里做状态修改。属性变化时用 markNeedsPaint 或 markNeedsLayout,否则会造成死循环或性能问题
5. 可交互的 RenderBox
在前面,我们主要写的是 静态的 RenderBox ——它们能测量大小、布局子组件、在画布上绘制图形。但在实际业务里,很多控件需要 交互,比如点击、拖拽、滑动。要让 RenderBox 响应用户操作,就要深入理解 事件分发机制 和 hitTest 流程。
5.1 事件分发模型回顾
Flutter 的事件分发路径大致分为三步:
- PointerEvent 从引擎传入 → 交给 RenderView 根节点。
- hitTest 阶段:从树顶往下遍历,判断哪些 RenderObject 被命中。每个 RenderBox 会根据自己是否包含触点决定是否加入 HitTestResult。顺序是“父先检查,再传给子”。
- 事件派发阶段:事件会倒序传递给命中的对象(子优先),调用它们的 handleEvent 方法。
一句话概括:hitTest 决定能不能点到,handleEvent 决定点到后做什么。
5.2 hitTest的实现
在 RenderBox 里,通常我们会重写 hitTestSelf 或 hitTestChildren。
hitTestSelf(Offset position)
:决定当前控件是否接收事件(true 表示命中自己)。
hitTestChildren(HitTestResult result, Offset position)
:决定是否把事件继续传递给子组件。
例子:圆形区域命中检测
1 2 3 4 5 6
| @override bool hitTestSelf(Offset position) { final double radius = size.shortestSide / 2; return (position - size.center(Offset.zero)).distance <= radius; }
|
这样,用户点击在圆圈外部时,事件就不会触发。
5.3 事件处理:handleEvent
当命中成功后,事件会传给 handleEvent。
这里我们可以判断事件类型(按下、移动、抬起),并执行相应逻辑:
1 2 3 4 5 6 7 8 9 10
| @override void handleEvent(PointerEvent event, HitTestEntry entry) { if (event is PointerDownEvent) { } else if (event is PointerMoveEvent) { } else if (event is PointerUpEvent) { } }
|
5.4 自定义 Slider
下面我们来实现一个Slider,包含一条线和一个可拖动的小圆点。
5.4.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 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107
| import 'package:flutter/material.dart';
class CustomSlider extends LeafRenderObjectWidget { const CustomSlider({ super.key, required this.value, required this.onChanged, });
final double value; final ValueChanged<double> onChanged;
@override RenderObject createRenderObject(BuildContext context) { return RenderCustomSlider() ..value = value ..onChanged = onChanged; }
@override void updateRenderObject( BuildContext context, covariant RenderCustomSlider renderObject) { renderObject ..value = value ..onChanged = onChanged; } }
class RenderCustomSlider extends RenderBox { double _value = 0.0; ValueChanged<double>? _onChanged;
double get value => _value; set value(double v) { v = v.clamp(0.0, 1.0); if (_value == v) return; _value = v; markNeedsPaint(); }
ValueChanged<double>? get onChanged => _onChanged; set onChanged(ValueChanged<double>? cb) { _onChanged = cb; }
final double _thumbRadius = 10; final double _trackHeight = 6;
@override void performLayout() { final double height = constraints.hasBoundedHeight ? constraints.maxHeight : _thumbRadius * 2 + 4; final double width = constraints.hasBoundedWidth ? constraints.maxWidth : 200; size = Size(width, height); }
@override void paint(PaintingContext context, Offset offset) { final Canvas canvas = context.canvas; final double centerY = size.height / 2;
final Paint trackPaint = Paint() ..color = Colors.grey.shade300 ..strokeWidth = _trackHeight ..strokeCap = StrokeCap.round; canvas.drawLine( offset + Offset(_thumbRadius, centerY), offset + Offset(size.width - _thumbRadius, centerY), trackPaint, );
final double progressX = _thumbRadius + _value * (size.width - 2 * _thumbRadius); final Paint progressPaint = Paint() ..color = Colors.blue ..strokeWidth = _trackHeight ..strokeCap = StrokeCap.round; canvas.drawLine( offset + Offset(_thumbRadius, centerY), offset + Offset(progressX, centerY), progressPaint, );
final Paint thumbPaint = Paint()..color = Colors.blue; canvas.drawCircle(offset + Offset(progressX, centerY), _thumbRadius, thumbPaint); }
@override bool hitTestSelf(Offset position) => true;
@override void handleEvent(PointerEvent event, HitTestEntry entry) { if (event is PointerDownEvent || event is PointerMoveEvent) { final double x = (event.localPosition.dx - _thumbRadius).clamp(0.0, size.width - 2 * _thumbRadius); value = x / (size.width - 2 * _thumbRadius); _onChanged?.call(value); } } }
|
5.4.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
| class HomePage extends StatefulWidget { const HomePage({super.key});
@override State<HomePage> createState() => _HomePageState(); }
class _HomePageState extends State<HomePage> { double sliderValue = 0.5;
@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("Custom Slider Demo")), body: Center( child: SizedBox( width: 300, height: 60, child: CustomSlider( value: sliderValue, onChanged: (v) => setState(() => sliderValue = v), ), ), ), ); } }
|
5.5 拖拽小球
再看一个更直观的例子:一个可拖动的小球。
5.5.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 42 43
| class DraggableBallWidget extends LeafRenderObjectWidget { const DraggableBallWidget({super.key}); @override RenderObject createRenderObject(BuildContext context) { return RenderDraggableBall(); } }
class RenderDraggableBall extends RenderBox { Offset _center = const Offset(50, 50); final double _radius = 20; @override void performLayout() { size = constraints.constrain(const Size(200, 200)); } @override void paint(PaintingContext context, Offset offset) { final Canvas canvas = context.canvas; final Paint paint = Paint()..color = Colors.red; canvas.drawCircle(offset + _center, _radius, paint); } @override bool hitTestSelf(Offset position) { return (position - _center).distance <= _radius; } @override void handleEvent(PointerEvent event, HitTestEntry entry) { if (event is PointerMoveEvent) { _center = event.localPosition; markNeedsPaint(); } } }
|
5.5.2 使用示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Draggable Ball Demo', home: Scaffold( appBar: AppBar(title: const Text('Draggable Ball Demo')), body: const Center( child: SizedBox( width: 200, height: 200, child: DraggableBallWidget(), ), ), ), ); } }
|
运行后,你可以直接拖动小球到区域内的任意位置。

5.6 常见坑
- 命中区域没写对:如果 hitTestSelf 一直返回 false,控件永远点不到。
- 忘记 markNeedsPaint:事件更新了状态但没调用 markNeedsPaint(),UI 不会刷新。
- 无限大小报错:交互组件也要有明确的 size,否则 hitTest 时坐标无法判断。
6. Sliver 协议与高性能滚动
在 Flutter 中,滚动视图(ScrollView)不仅仅是简单地把 Widget 堆在一起,它内部有一个高性能的 Sliver 协议 来控制布局与渲染。Sliver 是 RenderObject 层的一类特殊布局协议,它关注的是 可见区域、滚动偏移和子元素管理。通过 Sliver,Flutter 能够只布局和绘制当前屏幕可见的部分,从而实现大列表的高性能滚动。
6.1 Sliver 的约束系统
Sliver 的核心是 SliverConstraints 和 SliverGeometry:
SliverConstraints:父(viewport)传给 Sliver 的约束信息,包括:
- scrollOffset:当前滚动偏移量
- overlap:Sliver 与前一个 Sliver 的重叠
- viewportMainAxisExtent:viewport 在主轴方向的长度
- crossAxisExtent:cross 轴的尺寸
- growthDirection:滚动方向(向前/向后)
换句话说,它告诉 Sliver:“你的显示窗口范围是这里,你应该从什么偏移开始布局”。
SliverGeometry:Sliver 返回给父的信息,告诉 viewport:
- scrollExtent:当前 Sliver 占据的滚动空间
- paintExtent:可见区域的长度
- maxPaintExtent:内容的最大长度
- hasVisualOverflow:内容是否超出 viewport
- hitTestExtent:用于事件命中检测
打个比方:SliverConstraints 是父给你的“作业单”,SliverGeometry 是你交回的“作业完成情况”。通过这个机制,viewport 可以精确管理滚动、重绘和子组件可见性。
6.2 Viewport 与 child 管理
Viewport 是 Sliver 的父级容器,负责 滚动偏移和子组件管理:
- Viewport 根据滚动偏移(scrollOffset)确定 可见范围。
- 对每个子 Sliver 调用 layout 方法,传入 SliverConstraints。
- 子 Sliver 返回 SliverGeometry,告诉 viewport:
- Viewport 根据 geometry 信息决定:
- 哪些子 Sliver 可见 → 调用 paint
- 哪些子 Sliver 不可见 → 可能回收或缓存(keepAlive)
在大列表中,只有可见区域的子 Sliver 被 layout 和 paint,这就是 懒加载,也是高性能滚动的核心。
6.3 KeepAlive 的意义
在滚动中,如果一个 Sliver 内的子元素被移出可见区域,它可能会被销毁以节省内存。keepAlive 的作用是:
- 保持移出屏幕的子元素状态(比如滑动位置、输入内容)。
- 避免重复布局和状态丢失,提高用户体验。
在自定义 SliverList 或 SliverGrid 时,可以通过 RenderSliverMultiBoxAdaptor 提供的 keepAlive 机制管理子节点状态。
6.4.1 代码实现
伸缩头部是 Sliver 中的经典用法,常见于 Collapsing Toolbar:
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 42 43 44
| import 'package:flutter/material.dart';
class MyHeaderDelegate extends SliverPersistentHeaderDelegate { final double maxHeight; final double minHeight; MyHeaderDelegate({this.maxHeight = 200, this.minHeight = 80}); @override double get minExtent => minHeight; @override double get maxExtent => maxHeight; @override Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { final double progress = (shrinkOffset / (maxExtent - minExtent)).clamp(0.0, 1.0); final Color backgroundColor = Color.lerp(Colors.blue, Colors.lightBlue, progress)!; final double fontSize = 24 * (1 - progress) + 16 * progress; return Container( color: backgroundColor, alignment: Alignment.center, child: Text( "Header", style: TextStyle( color: Colors.white, fontSize: fontSize, fontWeight: FontWeight.bold, ), ), ); } @override bool shouldRebuild(covariant MyHeaderDelegate oldDelegate) { return maxHeight != oldDelegate.maxHeight || minHeight != oldDelegate.minHeight; } }
|
当滚动时,shrinkOffset 会增加,头部逐渐收缩。SliverPersistentHeader 会返回合适的 SliverGeometry,viewport 根据 geometry 控制可见范围和绘制。
6.4.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
| class HomePage extends StatelessWidget { const HomePage({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: CustomScrollView( slivers: [ SliverPersistentHeader( pinned: true, delegate: MyHeaderDelegate( maxHeight: 200, minHeight: 80, ), ), SliverList( delegate: SliverChildBuilderDelegate( (context, index) => ListTile( title: Text("Item $index"), ), childCount: 30, ), ), ], ), ); } }
|
6.5 自定义 SliverList(懒加载)
在大列表中,完全构建 1,000 个 Widget 会很慢。自定义 SliverList 可以只构建可见的子 Widget:
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
| class HomePage extends StatelessWidget { const HomePage({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: MySliverList(), ); } }
class MySliverList extends StatelessWidget { const MySliverList({super.key}); @override Widget build(BuildContext context) { return CustomScrollView( slivers: [ SliverList( delegate: SliverChildBuilderDelegate( (context, index) => ListTile(title: Text("Item $index")), childCount: 1000, ), ), ], ); } }
|
SliverChildBuilderDelegate 会按需构建子 Widget。不可见的子 Widget 会被缓存或销毁,配合 keepAlive 可保持状态。
理解 Sliver 协议,就可以实现各种滚动效果,如 折叠头部、横向列表、瀑布流,同时保证 高性能滚动和可扩展性。
7. 复合渲染与合成优化
7.1 为什么需要 Layer
在 Flutter 的渲染管线中,RenderObject 负责布局与绘制,而最终交给 GPU 的并不是单个个体的绘制指令,而是一棵 Layer 树(Layer Tree)。
可以这么理解RenderObject Tree描述“该画什么”,而Layer Tree描述“如何组合这些画面”。
Layer 就像是“舞台布景”或“滤镜叠层”,它决定了最终画面如何被合成。
Flutter 中每一帧都会把 Layer Tree 提交给 GPU 合成(compositing)。因此,合理使用 Layer 可以带来 灵活的特效,但滥用则会增加内存与 GPU 的负担。
7.2 常见的 Layer 类型
Flutter 提供了多种常见 Layer,用来支持各种渲染效果:
- OpacityLayer:让子节点整体透明,而不是逐个子节点单独绘制透明度。这样可以避免重复绘制,提升性能。
典型应用:Opacity Widget、FadeTransition。
- TransformLayer:对子树进行缩放、旋转、平移等操作。
应用场景:页面切换动画、缩放列表项。
- ClipRectLayer / ClipRRectLayer / ClipPathLayer:限定绘制区域。但过度使用裁剪会增加 GPU 负担。
应用场景:圆角裁剪、图片遮罩。
- BackdropFilterLayer:实现毛玻璃(模糊)等效果,比较耗性能,谨慎使用。
应用场景:半透明背景、弹窗毛玻璃。
7.3 合成边界的意义
默认情况下,多个 RenderObject 会被合并到一个 Layer 里绘制。但在某些情况下,我们希望“强制隔离”,给某一部分单独建一个 Layer,这就是 合成边界(Compositing Boundary)。
典型例子:
RepaintBoundary
:强制子树绘制结果缓存到一个独立的 Layer,下次如果没有变化就直接复用。
CompositedTransformTarget / CompositedTransformFollower
:依赖独立 Layer 来实现精准的坐标跟随。
合成边界的好处有:避免重复绘制(缓存复用),还可以支持局部坐标系变换。代价是增加内存(每个 Layer 都要缓存位图),GPU 合成负担变大。
所以,如果小组件会频繁变动,建议不要随便加 Layer。如果大片区域有着稳定的内容(如复杂背景),就比较适合单独成 Layer。
7.4 OpacityLayer 与直接绘制
假设有一个自定义 RenderBox,需要整体半透明:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| class RenderOpacityBox extends RenderProxyBox { double opacity; RenderOpacityBox({this.opacity = 1.0, RenderBox? child}) : super(child);
@override void paint(PaintingContext context, Offset offset) { if (opacity == 1.0) { super.paint(context, offset); } else { context.pushOpacity(offset, (opacity * 255).toInt(), super.paint); } } }
|
如果直接在每个子节点 paint 时设置 color.withOpacity,会导致 每个子节点都被重新绘制。用 OpacityLayer 则是先绘制子树到缓存,再整体应用透明度,性能更高。
7.5 BackdropFilter 的性能消耗
用作毛玻璃效果的 BackdropFilter 看起来很炫酷,但其实代价很大。因为它需要:
- 先把背景内容先缓存到一个纹理。
- 再对纹理做 GPU 模糊。
- 再与前景内容合成。
这意味着每一帧都要额外做一遍缓存 + 模糊运算。
因此,只适合用在小区域(如导航栏模糊背景),如果用在全屏背景(如整页模糊)则很可能掉帧。
如果只是想要“半透明”效果,可以用 Colors.white.withOpacity(0.5)
,避免使用 BackdropFilter。
首先不要盲目加 Layer,对于复杂但不常变化的区域可以加合成边界。对于频繁更新的小组件,让系统自动合成即可。对于使用 BackdropFilter 等高成本 Layer要谨慎。
理解 Layer 的取舍,本质就是在 性能(减少绘制) 和 内存/GPU 开销 之间做平衡。
8. 总结
RenderObject是Flutter渲染体系的底层核心,负责真正的布局和绘制。当需要极致性能或特殊效果时,就需要绕过Widget直接操作RenderObject。
三个关键方法:
performLayout
:根据约束计算大小
paint
:在画布上绘制内容
hitTest
:处理交互事件
性能要点:
- 正确使用
markNeedsLayout
/markNeedsPaint
可以避免过度重绘
- 复杂内容考虑用
PictureRecorder
缓存
- 滚动场景使用Sliver协议实现懒加载
选择建议:
- 简单绘制 → CustomPainter
- 复杂布局/交互 → 自定义RenderBox
- 长列表优化 → RenderSliver
掌握RenderObject让你能突破Widget限制,实现高性能自定义UI。
9. 备注
环境:
- mac: 15.2
- fluttter: 3.35.4
参考: