Skip to content

28 · 自定义渲染:CustomPainter / Sliver / RenderObject ​

先看这段定位,避免和前几篇混淆:

篇讲什么心智
03 篇三棵树、生命周期、rebuild/repaint/relayout渲染是什么
04 篇SliverAppBar、吸顶头、NestedScrollView用现成的 Sliver
本篇CustomPainter、自定义 RenderObject、自定义 RenderSliver自己写渲染

⚠️ 代码来源说明:HaierEnergy 当前 lib/ 中没有自绘代码(CustomPainter 全库仅在 22 篇 被提及一次)。 因此本篇以通用可运行实战为主,所有示例均可直接粘进练习场运行,不虚构项目代码。


开篇:前端概念对照 ​

前端Flutter说明
Canvas 2D API / SVG pathCustomPainter + Canvas命令式绘制,自己控制每一笔
react-window / 虚拟滚动Sliver 协议只渲染可见区域
绝对定位 + transformcanvas.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 最小骨架:进度环 ​

dart
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;
}

使用:

dart
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:性能开关 ​

dart
@override
bool shouldRepaint(MyPainter old) => old.progress != progress;  // 逐字段比较

这是重绘开关,返回 true 才重绘:

写法后果
return true每次父级 rebuild 都重绘 → 浪费 GPU
return false永不重绘(适合纯静态图案)
逐字段比较(推荐)只在真正变化时才画

2.4 Canvas API 速查(前端对照) ​

需求Web Canvas 2DFlutter 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/scaletranslate/rotate/scale
存/取状态save()/restore()save()/restore() ✅ 成对

画文字必须用 TextPainter(Flutter 没有 fillText):

dart
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) ​

dart
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 篇):

dart
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 个(纯自绘)LeafRenderObjectWidgetRenderBox
1 个SingleChildRenderObjectWidgetRenderProxyBox / RenderBox
多个MultiChildRenderObjectWidgetRenderBox + 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) ​

dart
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说明
管理 childrenContainerRenderObjectMixin提供 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 流程:

dart
@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 的根本差异 ​

RenderBoxRenderSliver
输入约束BoxConstraintsSliverConstraints(含 scrollOffset、remainingPaintExtent、overlap…)
输出结果sizeSliverGeometry(scrollExtent、paintExtent、layoutExtent、visible…)
坐标系笛卡尔 (x, y)滚动偏移轴(主轴方向)
典型实现Row / FlexSliverList / SliverGrid / SliverAppBar

核心区别(虚拟滚动的本质):

SliverGeometry.paintExtent 可以小于 scrollExtent —— 即"只绘制可见部分,但占位仍是完整高度"。这就是 Sliver 能承载超长列表却不卡的原因。

4.2 最小自定义 Sliver 骨架 ​

dart
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.remainingPaintExtentviewport 剩余可见高度
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 参数隔离重绘范围:

dart
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(彩虹)哪里在反复重绘(出现彩虹色 = 在重绘)
dart
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() 多数场景够用,别重复造轮子

相关文档 ​