命中测试与重绘优化
实现自定义命中测试和 shouldRepaint 逻辑,让绘制保持高性能。
命中测试与重绘优化 是 CoddyKit 上的免费 Flutter Mobile Development 课时。 这是第 4 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Flutter Mobile Development 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Flutter Mobile Development 课程共包含 4 节课。
本课时的部分内容尚未翻译,以英文显示。
Why Hit Testing and Repaint Matter
When you draw with CustomPainter you take full control of the canvas. But two costs are easy to overlook: hit testing (deciding which pixels respond to taps) and repainting (re-running your paint code every frame).
- A naive painter that always returns
truefromshouldRepaintrepaints on every rebuild, burning GPU and battery. - By default a custom-painted region is transparent to pointer events, so taps fall through to widgets behind it.
This lesson shows how to make custom drawings both interactive and efficient.
Anatomy of a CustomPainter
A CustomPainter exposes two key overrides: paint for drawing and shouldRepaint for deciding when to re-run paint. There is also an optional hitTest for pointer logic.
paint(Canvas canvas, Size size)— your drawing commands.shouldRepaint(covariant CustomPainter old)— returntrueonly when visual inputs changed.hitTest(Offset position)— returntrue/false/nullto control pointer routing.
class CirclePainter extends CustomPainter {
final Color color;
final double radius;
CirclePainter({required this.color, required this.radius});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color;
canvas.drawCircle(size.center(Offset.zero), radius, paint);
}
@override
bool shouldRepaint(covariant CirclePainter old) =>
old.color != color || old.radius != radius;
}shouldRepaint: The Core Optimization
shouldRepaint is called when a new painter instance is supplied to CustomPaint. If it returns false, Flutter skips the paint phase and reuses the cached layer.
- Compare only the fields that affect pixels.
- Returning
trueunconditionally defeats the optimization. - Returning
falsewhen something did change causes stale, frozen visuals.
Compare values, not identity — two painter instances with equal fields should yield false.
@override
bool shouldRepaint(covariant CirclePainter old) {
// Repaint ONLY when a visual input differs.
return old.color != color || old.radius != radius;
}
// Anti-pattern: always repaints, no matter what.
// bool shouldRepaint(old) => true;Repaint Boundaries Isolate Work
Even a correct shouldRepaint can't help if your painter shares a layer with a frequently-rebuilding subtree. CustomPaint automatically wraps itself in a RepaintBoundary when given a child, but for a standalone painter you often add one explicitly.
- A
RepaintBoundarygives the painted region its own compositing layer. - Repaints inside the boundary don't force ancestors or siblings to repaint.
- Use the Flutter DevTools "Highlight Repaints" overlay to see which layers flash.
RepaintBoundary(
child: CustomPaint(
size: const Size(200, 200),
painter: CirclePainter(color: Colors.blue, radius: 60),
),
)Default Hit Testing Behavior
By itself a CustomPaint does not capture pointer events on the painted shapes — its hitTest defaults to returning null, meaning "defer to default box behavior." To make drawings tappable you wrap them in a gesture detector and/or override hitTest.
- Return
true: this position is a hit; consume the event. - Return
false: not a hit; let it pass to widgets behind. - Return
null: use the default (the whole bounding box is a hit if opaque).
Implementing Precise hitTest
Override hitTest to restrict taps to the actual drawn shape rather than the rectangular bounds. For a circle, test the distance from the center against the radius.
This lets the corners of the bounding box pass touches through to whatever is behind, which feels natural for non-rectangular art.
class CirclePainter extends CustomPainter {
final Color color;
final double radius;
final Offset center;
CirclePainter({required this.color, required this.radius, required this.center});
@override
void paint(Canvas canvas, Size size) {
canvas.drawCircle(center, radius, Paint()..color = color);
}
@override
bool hitTest(Offset position) {
// True only inside the circle, not the whole box.
return (position - center).distance <= radius;
}
@override
bool shouldRepaint(covariant CirclePainter old) =>
old.color != color || old.radius != radius || old.center != center;
}Modeling Distance Without Flutter
The math behind circular hit testing is just the Euclidean distance. Here is a pure-Dart model you can run on any judge to verify the logic before wiring it into hitTest.
A point is inside the circle when its distance to the center is less than or equal to the radius.
import 'dart:math';
bool insideCircle(double px, double py, double cx, double cy, double r) {
final dx = px - cx;
final dy = py - cy;
return sqrt(dx * dx + dy * dy) <= r;
}
void main() {
const cx = 100.0, cy = 100.0, r = 60.0;
print(insideCircle(120, 110, cx, cy, r)); // true (inside)
print(insideCircle(10, 10, cx, cy, r)); // false (corner)
print(insideCircle(160, 100, cx, cy, r)); // true (on edge)
}Hit Testing with Path.contains
For arbitrary shapes, build a Path in paint and reuse it in hitTest via Path.contains. Storing the path as a field avoids rebuilding it twice per frame.
path.contains(position)returnstruewhen the point lies inside the filled region.- Cache the path; rebuild it only when geometry inputs change.
class StarPainter extends CustomPainter {
final Path _path;
final Color color;
StarPainter({required this.color, required Size size})
: _path = _buildStar(size);
static Path _buildStar(Size size) {
final p = Path();
p.moveTo(size.width / 2, 0);
p.lineTo(size.width, size.height);
p.lineTo(0, size.height);
p.close();
return p;
}
@override
void paint(Canvas canvas, Size size) {
canvas.drawPath(_path, Paint()..color = color);
}
@override
bool hitTest(Offset position) => _path.contains(position);
@override
bool shouldRepaint(covariant StarPainter old) => old.color != color;
}Repainting Only on Animation Ticks
For animated painters, pass the driving Listenable (such as an AnimationController) to CustomPainter's repaint super-parameter. The painter then repaints when the animation ticks — without rebuilding the whole widget tree.
super(repaint: animation)wires the painter to listen directly.- This skips
setStateand avoids rebuilding siblings every frame.
class PulsePainter extends CustomPainter {
final Animation<double> animation;
PulsePainter(this.animation) : super(repaint: animation);
@override
void paint(Canvas canvas, Size size) {
final r = 20 + animation.value * 40;
canvas.drawCircle(
size.center(Offset.zero), r, Paint()..color = Colors.teal);
}
// No need to compare values: the repaint Listenable drives it.
@override
bool shouldRepaint(covariant PulsePainter old) => false;
}Wiring Gestures to a Painter
Hit testing on the painter decides whether the painted region is opaque to pointers, but you still attach a GestureDetector to react. Combine them: precise hitTest plus a detector gives shape-accurate taps.
- The detector's
onTapDowngives a localOffset. - You can re-run the same geometry check to identify which sub-shape was hit.
GestureDetector(
behavior: HitTestBehavior.deferToChild,
onTapUp: (details) {
final local = details.localPosition;
if ((local - center).distance <= radius) {
debugPrint('Circle tapped at $local');
}
},
child: CustomPaint(
size: const Size(200, 200),
painter: CirclePainter(
color: Colors.blue, radius: 60, center: const Offset(100, 100)),
),
)A Repaint Optimization Checklist
Bring it together with a practical checklist for performant custom painting:
- Compare values in
shouldRepaint— never blanket-returntrue. - Use
repaint:for animations instead ofsetState. - Wrap in
RepaintBoundaryto isolate the painted layer. - Cache
PathandPaintobjects; don't allocate per frame. - Override
hitTestso non-rectangular art passes through unused space. - Profile with DevTools repaint highlighting and the timeline.
Quick Check: shouldRepaint
Consider what your shouldRepaint implementation should return to keep painting performant.
Recap
You learned how to keep custom-painted Flutter UIs both interactive and fast:
- shouldRepaint should compare only visual inputs by value, returning
truejust when they change. - hitTest controls pointer routing: precise shape tests let non-rectangular art pass unused taps through.
- Path.contains and distance math give shape-accurate hit detection; cache the path.
- RepaintBoundary isolates the painted layer so repaints don't cascade.
- repaint: wires animations directly to the painter, avoiding full rebuilds.
Together these techniques eliminate wasted frames and make your canvas drawings feel native and responsive.
常见问题解答
「命中测试与重绘优化」课时是免费的吗?
是的 — 「命中测试与重绘优化」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Flutter Mobile Development 课程的其余内容,请升级到 CoddyKit PRO。 Flutter Mobile Development 课程共包含 4 节课。
「命中测试与重绘优化」这节课中我会学到什么?
实现自定义命中测试和 shouldRepaint 逻辑,让绘制保持高性能。 你通过在浏览器中直接运行的动手代码来练习 Flutter Mobile Development,全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 Flutter Mobile Development 需要有经验吗?
无需任何先前经验。CoddyKit 上的 Flutter Mobile Development 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 4 节课,共 4 节。
「命中测试与重绘优化」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 Flutter Mobile Development 课中编写并运行代码吗?
能。每节 Flutter Mobile Development 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。