Creación de Animaciones Gráficas GPU a 60fps con Flutter CustomPainter y Canvas API

Animaciones GPU a 60fps con Flutter CustomPainter y Canvas API
Al desarrollar visualizadores de audio, gráficos dinámicos o sistemas de partículas en Flutter, instanciar cientos de objetos Widget estándar suele causar problemas severos de rendimiento (jank) debido a la carga en la CPU.
La solución definitiva consiste en utilizar CustomPainter y la Canvas API. Combinados con el motor de renderizado Impeller GPU de Flutter, permiten evitar la reconstrucción del árbol de widgets y dibujar gráficos directamente en la GPU a 60fps o 120fps.
Esta guía examina la canalización de renderizado de CustomPainter, el aislamiento de capas con RepaintBoundary y una implementación completa de partículas a 60fps.
1. Árbol de Widgets vs Arquitectura CustomPainter
| Métrica | Composición Estándar de Widgets | CustomPainter & Canvas API |
|---|---|---|
| Canalización | Widget Tree ➡️ RenderObject ➡️ Capas | RenderCustomPaint único ➡️ Dibujo Canvas Directo |
| Carga de 1.000 elementos | 1.000 instancias Element/RenderObject | 1 Painter ejecutando instrucciones en bucle GPU |
| Carga CPU vs GPU | Alto coste de diseño en CPU | Rasterización acelerada por GPU |
| Casos de uso | Formularios, listas, UI estática | Sistemas de partículas, gráficos en tiempo real |
2. 3 Reglas de Rendimiento para Animaciones a 60fps
Regla 1: Control Estricto en shouldRepaint
El método shouldRepaint(covariant CustomPainter oldDelegate) determina si es necesario redibujar el lienzo. Evite devolver true sin comprobar si las propiedades han cambiado realmente.
Regla 2: Aislamiento de Capas con RepaintBoundary
Envolver el widget CustomPaint con un RepaintBoundary aísla la capa de pintado respecto al resto de widgets contenedores.
RepaintBoundary(
child: CustomPaint(
painter: ParticlePainter(particles: _particles),
size: Size.infinite,
),
)
Regla 3: Pasar AnimationController al parámetro repaint de CustomPainter
En lugar de llamar a setState() y reconstruir todo el árbol de widgets, conecte el AnimationController directamente al parámetro repaint: Listenable del constructor de CustomPainter. El Listenable que pasa a super(repaint: repaint) invoca automáticamente paint() cada vez que cambia, de modo que solo se repinta la capa del lienzo en cada frame, sin necesidad de reconstruir nada. (Tenga en cuenta que willChange de CustomPaint es solo una simple pista bool para la caché de rasterizado; no acepta un Listenable.)
3. Código Práctico de Animación de Partículas en GPU
Ejemplo completo en Flutter ejecutando 150 partículas a 60fps:
import 'dart:math';
import 'package:flutter/material.dart';
class Particle {
double x, y, dx, dy, radius;
Color color;
Particle({
required this.x,
required this.y,
required this.dx,
required this.dy,
required this.radius,
required this.color,
});
void update(Size size) {
x += dx;
y += dy;
if (x < 0 || x > size.width) dx = -dx;
if (y < 0 || y > size.height) dy = -dy;
}
}
class ParticleCanvasScreen extends StatefulWidget {
const ParticleCanvasScreen({super.key});
@override
State<ParticleCanvasScreen> createState() => _ParticleCanvasScreenState();
}
class _ParticleCanvasScreenState extends State<ParticleCanvasScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
final List<Particle> _particles = [];
final Random _random = Random();
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..repeat();
for (int i = 0; i < 150; i++) {
_particles.add(Particle(
x: _random.nextDouble() * 400,
y: _random.nextDouble() * 800,
dx: (_random.nextDouble() - 0.5) * 3,
dy: (_random.nextDouble() - 0.5) * 3,
radius: _random.nextDouble() * 4 + 2,
color: Colors.indigoAccent.withValues(alpha: _random.nextDouble() * 0.7 + 0.3),
));
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0F172A),
body: RepaintBoundary(
child: CustomPaint(
painter: ParticlePainter(particles: _particles, repaint: _controller),
size: Size.infinite,
),
),
);
}
}
class ParticlePainter extends CustomPainter {
final List<Particle> particles;
ParticlePainter({required this.particles, required Listenable repaint})
: super(repaint: repaint);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.fill;
for (var particle in particles) {
particle.update(size);
paint.color = particle.color;
canvas.drawCircle(Offset(particle.x, particle.y), particle.radius, paint);
}
}
@override
bool shouldRepaint(covariant ParticlePainter oldDelegate) => true;
}
4. Conclusión
- Utilice
CustomPainteryCanvas APIpara dibujar grandes volúmenes de elementos dinámicos. - Aísle las capas con
RepaintBoundary. - Supervise el tiempo de renderizado con Flutter DevTools para asegurar tiempos por frame inferiores a 16.6ms.