0Pricing
Flutter Mobile Development · 课时

RepaintBoundary、常量组件与重建裁剪

使用边界和常量构造函数,减少不必要的重绘和重建。

RepaintBoundary、常量组件与重建裁剪 是 CoddyKit 上的免费 Flutter Mobile Development 课时。 这是第 3 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Flutter Mobile Development 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Flutter Mobile Development 课程共包含 4 节课。

本课时的部分内容尚未翻译,以英文显示。

Rebuilds vs Repaints

Flutter performance work lives at three layers, and conflating them wastes effort:

  • Rebuild — build() runs again, producing a new widget tree. Cheap if widgets are immutable and shallow, but it can cascade.
  • Relayout — RenderObjects recompute size/position. Triggered by constraint or child changes.
  • Repaint — pixels are re-rasterized to a layer. Expensive for gradients, shadows, and complex paths.

This lesson targets two distinct wins: pruning rebuilds with const and structure, and isolating repaints with RepaintBoundary. They solve different problems — do not reach for one when you need the other.

Why const Widgets Skip Rebuilds

A const widget is canonicalized by the Dart compiler: every evaluation of the same const expression returns the identical instance. When a parent rebuilds, Flutter compares the new child widget to the old one. If they are the same instance (identical(old, new) is true), Flutter short-circuits and does not rebuild that subtree at all.

  • Without const, each parent build creates a fresh Widget object, forcing the element to update its child.
  • With const, the canonical instance is reused, so the subtree is skipped entirely.

This is the cheapest optimization in Flutter — it costs you a keyword and prunes whole branches of the rebuild.

// Dart compile-time canonicalization: const instances are identical.
class Point {
  final int x;
  final int y;
  const Point(this.x, this.y);
}

void main() {
  const a = Point(1, 2);
  const b = Point(1, 2);
  // Both refer to the SAME canonical instance.
  print(identical(a, b)); // true

  final c = Point(1, 2); // runtime instance, not canonicalized
  print(identical(a, c)); // false
}

Applying const in a Widget Tree

Mark every widget you can as const. The rule: a widget can be const if all its constructor arguments are themselves compile-time constants. Static labels, icons, padding, and spacers are prime candidates.

  • A const child inside a frequently-rebuilding parent is the highest-value placement — the parent rebuilds, the child is skipped.
  • Enable the prefer_const_constructors and prefer_const_literals_to_create_immutables lints so the analyzer flags missed opportunities.

Below, only the live counter text changes; the title, divider, and icon never rebuild because they are const.

class CounterPanel extends StatelessWidget {
  final int count;
  const CounterPanel({super.key, required this.count});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Text('Live Counter'),      // const: skipped on rebuild
        const Divider(),                  // const: skipped
        Text('$count'),                   // dynamic: must rebuild
        const Icon(Icons.timer),          // const: skipped
      ],
    );
  }
}

When const Is Impossible: Hoist the Subtree

You cannot make a widget const if it depends on runtime values (a theme color, a fetched string, a callback closure). But you can still avoid rebuilding it by hoisting it out of the rebuilding scope.

  • Build the expensive-but-static subtree once in a parent, store it in a field or pass it as a child parameter, and let the rebuilding widget reuse that same reference.
  • This is exactly the pattern AnimatedBuilder and ValueListenableBuilder use with their child argument: the child is built once and threaded through every animation frame untouched.
// The child is built ONCE and reused on every animation tick.
AnimatedBuilder(
  animation: rotationController,
  // 'child' is created a single time, not per-frame.
  child: const ExpensiveLogo(),
  builder: (context, child) {
    return Transform.rotate(
      angle: rotationController.value * 6.28,
      child: child, // reused reference: no rebuild of ExpensiveLogo
    );
  },
)

How Repaints Propagate

Flutter composites the UI into layers. When a RenderObject is marked dirty for paint, Flutter repaints the entire layer it belongs to — not just that one object. By default, sibling widgets often share a layer.

  • An animation that constantly repaints (a spinner, a progress bar, a blinking cursor) marks its layer dirty every frame.
  • If a heavy static widget (a big image, a complex gradient background) shares that layer, it gets re-rasterized every frame too — pure waste.

The fix is to give the volatile widget its own layer so its repaints stay contained. That is what RepaintBoundary does.

RepaintBoundary: Isolating a Layer

RepaintBoundary wraps a subtree and forces it into a separate compositing layer. Repaints inside the boundary no longer dirty the parent layer, and repaints outside no longer force the boundary's contents to re-rasterize.

  • Wrap the frequently repainting widget so its churn stays isolated, OR
  • Wrap the expensive static widget so a noisy neighbor can't drag it into per-frame repaints.

The boundary has a real cost: an extra layer means extra memory and a compositing step. Use it surgically where the profiler shows repaint waste — not everywhere.

Stack(
  children: [
    // Heavy, static background — isolate it so the spinner can't
    // force it to re-rasterize every frame.
    const RepaintBoundary(
      child: ComplexGradientBackground(),
    ),
    // Constantly repainting overlay gets its own layer too.
    const Center(
      child: RepaintBoundary(
        child: CircularProgressIndicator(),
      ),
    ),
  ],
)

Lists Insert RepaintBoundary for You

A common question: do you need to wrap every ListView item in a RepaintBoundary? Usually no — ListView, GridView, and other SliverList-based widgets already wrap each item in a RepaintBoundary by default (controlled by the addRepaintBoundaries flag, which defaults to true).

  • This means scrolling repaints don't cascade across all visible rows.
  • Wrapping items again is redundant and adds layer overhead. Leave the default on.
  • You set addRepaintBoundaries: false only for trivially cheap items where the extra layer costs more than it saves.
ListView.builder(
  itemCount: messages.length,
  // addRepaintBoundaries defaults to true — each row is already isolated.
  itemBuilder: (context, index) {
    return MessageTile(message: messages[index]);
  },
)

setState Rebuilds the Whole build()

Calling setState marks the entire State's build() method dirty. Everything that method returns is rebuilt — even widgets unrelated to the changed value. In a large screen this is the most common source of jank.

  • The narrower your build(), the cheaper each setState.
  • Push the volatile state down into the smallest possible widget so only it rebuilds.

Below, a single tap rebuilds the whole screen — including the static header and footer — because they live in the same build() as the counter.

class DashboardState extends State<Dashboard> {
  int taps = 0;

  @override
  Widget build(BuildContext context) {
    // setState rebuilds ALL of this, header and footer included.
    return Column(
      children: [
        const ExpensiveHeader(),
        Text('Taps: $taps'),
        ElevatedButton(
          onPressed: () => setState(() => taps++),
          child: const Text('Tap'),
        ),
        const ExpensiveFooter(),
      ],
    );
  }
}

Pruning with ValueListenableBuilder

To rebuild only the part that depends on a value, replace setState with a scoped listenable. ValueListenableBuilder rebuilds just its builder closure when the value changes; everything outside it stays put.

  • The header, footer, and surrounding layout are built once and never rebuild on value changes.
  • Combine with the child argument to hoist any static subtree inside the builder.

This converts a whole-screen rebuild into a surgical, single-widget rebuild.

final taps = ValueNotifier<int>(0);

@override
Widget build(BuildContext context) {
  return Column(
    children: [
      const ExpensiveHeader(), // built once, never rebuilt
      ValueListenableBuilder<int>(
        valueListenable: taps,
        builder: (context, value, child) => Text('Taps: $value'),
      ),
      ElevatedButton(
        onPressed: () => taps.value++, // no setState
        child: const Text('Tap'),
      ),
      const ExpensiveFooter(), // built once, never rebuilt
    ],
  );
}

Measuring Before You Optimize

Never guess — measure. Flutter ships tools that show exactly where rebuilds and repaints happen:

  • Repaint Rainbow (DevTools / debugRepaintRainbowEnabled): overlays a rotating border color on each layer every time it repaints. A widget that flickers colors constantly is repainting too often — a candidate for RepaintBoundary.
  • Track Widget Builds / Rebuild Stats: counts how many times each widget rebuilds, exposing widgets that rebuild far more than expected.
  • Performance Overlay: shows the UI (build) and raster (paint) thread budgets; spikes tell you which thread is the bottleneck.

Optimize only what the profiler flags. A const or a boundary added blindly can cost more than it saves.

import 'package:flutter/rendering.dart';

void main() {
  // Visualize layer repaints during development.
  debugRepaintRainbowEnabled = true;
  runApp(const MyApp());
}

A Decision Checklist

Put the three tools in order when you face jank:

  • Too many rebuilds? Add const where arguments are constant; hoist static subtrees via the child parameter; scope state with ValueListenableBuilder instead of screen-wide setState.
  • Too many repaints? Wrap the volatile or the expensive widget in RepaintBoundary to isolate its layer.
  • Not sure which? Turn on Rebuild Stats and Repaint Rainbow first.

Key distinction: const fights rebuilds; RepaintBoundary fights repaints. Using the wrong one leaves the bottleneck untouched and may add overhead.

Quick Check

A screen shows a static, expensive blurred background image. On top of it sits a small CircularProgressIndicator that animates every frame. The profiler shows the whole background re-rasterizing 60 times per second. What is the correct fix?

Recap

You now have a precise mental model for cutting Flutter render cost:

  • Rebuilds are pruned by const (canonical instances are skipped via identical), by hoisting static subtrees through the child parameter, and by scoping state with ValueListenableBuilder instead of whole-screen setState.
  • Repaints are isolated by RepaintBoundary, which moves a subtree onto its own compositing layer so volatile and static widgets stop contaminating each other.
  • Lists already insert repaint boundaries per item — don't double-wrap.
  • Measure first with Rebuild Stats, Repaint Rainbow, and the Performance Overlay; apply each tool only where the profiler proves it pays off.

The golden rule: const fights rebuilds, RepaintBoundary fights repaints — match the tool to the symptom.

常见问题解答

「RepaintBoundary、常量组件与重建裁剪」课时是免费的吗?

是的 — 「RepaintBoundary、常量组件与重建裁剪」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Flutter Mobile Development 课程的其余内容,请升级到 CoddyKit PRO。 Flutter Mobile Development 课程共包含 4 节课。

「RepaintBoundary、常量组件与重建裁剪」这节课中我会学到什么?

使用边界和常量构造函数,减少不必要的重绘和重建。 你通过在浏览器中直接运行的动手代码来练习 Flutter Mobile Development,全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Flutter Mobile Development 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Flutter Mobile Development 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 3 节课,共 4 节。

「RepaintBoundary、常量组件与重建裁剪」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Flutter Mobile Development 课中编写并运行代码吗?

能。每节 Flutter Mobile Development 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 三棵树:Widget、Element 与 RenderObject
  2. 使用 DevTools 时间线分析卡顿
  3. RepaintBoundary、常量组件与重建裁剪
  4. 着色器预热与 Impeller 迁移
← 返回 Flutter Mobile Development