Widget build(BuildContext context, WidgetRef ref) { return Scaffold( body: Column( children: [ // Solo se reconstruye cuando cambia temperatura actual Consumer( builder: (context, ref, child) { final temperature = ref.watch(currentTemperatureProvider); return CurrentTemperatureWidget(temperature: temperature); }, ), // Solo se reconstruye cuando cambia la lista de forecast Expanded( child: Consumer( builder: (context, ref, child) { final forecast = ref.watch(forecastListProvider); return ForecastList(forecast: forecast); }, ), ), ], ), ); } } // ValueNotifier para cambios ultra-frecuentes (ej: progreso de descarga) class DownloadProgressWidget extends StatefulWidget { @override _DownloadProgressWidgetState createState() => _DownloadProgressWidgetState(); } class _DownloadProgressWidgetState extends State<DownloadProgressWidget> { final ValueNotifier<double> _progressNotifier = ValueNotifier(0.0); @override Widget build(BuildContext context) { return ValueListenableBuilder<double>( valueListenable: _progressNotifier, builder: (context, progress, child) { // Solo este builder se ejecuta cuando cambia progreso // El resto del widget tree permanece intacto return LinearProgressIndicator(value: progress); }, ); } void updateProgress(double newProgress) { _progressNotifier.value = newProgress; // ¡Extremadamente eficiente! } }
Las listas son uno de los componentes más comunes y problemáticos en términos de rendimiento. Una lista mal optimizada puede arruinar la experiencia de usuario completamente.
⚠️ Error común: Usar Column/Row para listas grandes en lugar de ListView. Esto crea TODOS los widgets de una vez, causando stuttering masivo y uso excesivo de memoria.
// ✅ Lista optimizada para gran cantidad de datos
class OptimizedWeatherList extends StatelessWidget {
final List<WeatherData> weatherData;
const OptimizedWeatherList({required this.weatherData});
@override
Widget build(BuildContext context) {
return ListView.builder(
// Optimización clave: especificar altura del item si es fija
itemExtent: 80.0, // Evita expensive layout calculations
// Lazy loading: solo construye widgets visibles
itemCount: weatherData.length,
// Cache extra items fuera de viewport para scroll suave
cacheExtent: 200.0,
itemBuilder: (context, index) {
final weather = weatherData[index];
return RepaintBoundary(
// Cada item aislado para repaints independientes
child: WeatherListItem(
key: ValueKey(weather.id), // Key estable para reordering
weather: weather,
),
);
},
);
}
}
// Widget de item optimizado
class WeatherListItem extends StatelessWidget {
final WeatherData weather;
const WeatherListItem({Key? key, required this.weather}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
height: 80.0, // Altura fija = mejor performance
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Row(
children: [
// Imagen con caching y placeholder
CachedNetworkImage(
imageUrl: weather.iconUrl,
width: 48,
height: 48,
placeholder: (context, url) => const CircularProgressIndicator(),
errorWidget: (context, url, error) => const Icon(Icons.error),
// Crucial: evita rebuilds por cada imagen
memCacheWidth: 48 * MediaQuery.of(context).devicePixelRatio.round(),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
weather.location,
style: const TextStyle(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'${weather.temperature}°C',
style: TextStyle(color: Colors.grey[600]),
),
],
),
),
// Parte estática que nunca cambia
const Icon(Icons.chevron_right, color: Colors.grey),
],
),
);
}
}
// Para listas MUY largas: implementar paginación virtual
class VirtualizedWeatherList extends StatefulWidget {
@override
_VirtualizedWeatherListState createState() => _VirtualizedWeatherListState();
}
class _VirtualizedWeatherListState extends State<VirtualizedWeatherList> {
final ScrollController _scrollController = ScrollController();
final List<WeatherData> _loadedItems = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadInitialData();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
// Load more when near bottom
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
_loadMoreData();
}
}
Future<void> _loadMoreData() async {
if (_isLoading) return;
setState(() => _isLoading = true);
// Simulate API call
await Future.delayed(Duration(milliseconds: 500));
final newItems = await _fetchMoreWeatherData();
setState(() {
_loadedItems.addAll(newItems);
_isLoading = false;
});
}
}
Las imágenes mal optimizadas pueden consumir cientos de MB de memoria innecesariamente. En aplicaciones como BorealisClima, que muestran múltiples iconos de clima, esto es crítico.
💡 Regla de oro: Nunca cargues una imagen de 1920x1080 para mostrarla en 48x48 píxeles. El costo de memoria es proporcional a los píxeles totales, no al tamaño de archivo.
// ✅ Carga eficiente de imágenes con caching y redimensionado
class OptimizedImageWidget extends StatelessWidget {
final String imageUrl;
final double width;
final double height;
const OptimizedImageWidget({
required this.imageUrl,
required this.width,
required this.height,
});
@override
Widget build(BuildContext context) {
final pixelRatio = MediaQuery.of(context).devicePixelRatio;
return CachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
// CRUCIAL: Especifica tamaño de cache para evitar overhead de memoria
memCacheWidth: (width * pixelRatio).round(),
memCacheHeight: (height * pixelRatio).round(),
// Configuración de cache optimizada
cacheManager: CustomCacheManager(),
// Placeholders eficientes
placeholder: (context, url) => Container(
width: width,
height: height,
color: Colors.grey[200],
child: const Icon(Icons.image, color: Colors.grey),
),
errorWidget: (context, url, error) => Container(
width: width,
height: height,
color: Colors.red[100],
child: const Icon(Icons.error, color: Colors.red),
),
// Filtro de calidad para imágenes pequeñas
filterQuality: width < 100 ? FilterQuality.medium : FilterQuality.high,
);
}
}
class CustomCacheManager extends CacheManager {
static const key = 'customCacheKey';
static CustomCacheManager? _instance;
factory CustomCacheManager() {
return _instance ??= CustomCacheManager._();
}
CustomCacheManager._() : super(
Config(
key,
stalePeriod: const Duration(days: 7), // Cache 7 días
maxNrOfCacheObjects: 200, // Máximo 200 imágenes en cache
repo: JsonCacheInfoRepository(databaseName: key),
fileService: HttpFileService(),
),
);
}
// Precarga inteligente de imágenes críticas
class ImagePreloader {
static final Set<String> _preloadedImages = {};
static Future<void> preloadCriticalImages(BuildContext context) async {
final criticalImages = [
'assets/weather/sunny.png',
'assets/weather/cloudy.png',
'assets/weather/rainy.png',
'assets/weather/snowy.png',
];
for (final imagePath in criticalImages) {
if (!_preloadedImages.contains(imagePath)) {
await precacheImage(AssetImage(imagePath), context);
_preloadedImages.add(imagePath);
}
}
}
}
// Uso de SVG para iconos escalables sin pérdida de calidad
class SvgIconWidget extends StatelessWidget {
final String assetPath;
final double size;
final Color? color;
const SvgIconWidget({
required this.assetPath,
required this.size,
this.color,
});
@override
Widget build(BuildContext context) {
return SvgPicture.asset(
assetPath,
width: size,
height: size,
color: color,
// Cache SVG parsing para evitar re-parsing
placeholderBuilder: (context) => Container(
width: size,
height: size,
color: Colors.grey[300],
),
);
}
}
Las animaciones mal implementadas pueden causar frame drops severos. La clave está en usar el tipo correcto de animación para cada situación.
// ✅ Animación optimizada para weather card
class AnimatedWeatherCard extends StatefulWidget {
final WeatherData weather;
const AnimatedWeatherCard({required this.weather});
@override
_AnimatedWeatherCardState createState() => _AnimatedWeatherCardState();
}
class _AnimatedWeatherCardState extends State<AnimatedWeatherCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
// Usar Curves optimizadas para mejor feeling
_scaleAnimation = Tween<double>(
begin: 0.8,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOutBack, // Curve natural y satisfactoria
));
_opacityAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
_controller.forward();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: child,
),
);
},
// Child es const para evitar rebuilds durante animación
child: const WeatherCardContent(),
);
}
@override
void dispose() {
_controller.dispose(); // CRÍTICO: evitar memory leaks
super.dispose();
}
}
// Animación de loading optimizada
class OptimizedLoadingAnimation extends StatefulWidget {
@override
_OptimizedLoadingAnimationState createState() => _OptimizedLoadingAnimationState();
}
class _OptimizedLoadingAnimationState extends State<OptimizedLoadingAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _rotationController;
@override
void initState() {
super.initState();
_rotationController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
)..repeat(); // Animación infinita
}
@override
Widget build(BuildContext context) {
return RepaintBoundary(
// Aísla la animación para evitar repaints del parent
child: RotationTransition(
turns: _rotationController,
child: const Icon(
Icons.refresh,
size: 24,
color: Colors.blue,
),
),
);
}
@override
void dispose() {
_rotationController.dispose();
super.dispose();
}
}
// Custom Painter para animaciones ultra-performantes
class WeatherChartPainter extends CustomPainter {
final List<double> temperatures;
final Animation<double> animation;
WeatherChartPainter({required this.temperatures, required this.animation})
: super(repaint: animation);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue
..strokeWidth = 2.0
..style = PaintingStyle.stroke;
final path = Path();
final animatedProgress = animation.value;
// Solo dibuja hasta el progreso actual de la animación
final pointsToShow = (temperatures.length * animatedProgress).round();
for (int i = 0; i < pointsToShow; i++) {
final x = (i / temperatures.length) * size.width;
final y = size.height - (temperatures[i] / 40) * size.height;
if (i == 0) {
path.moveTo(x, y);
} else {
path.lineTo(x, y);
}
}
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(WeatherChartPainter oldDelegate) {
return oldDelegate.animation.value != animation.value ||
oldDelegate.temperatures != temperatures;
}
}
El garbage collection puede causar frame drops perceptibles. Minimizar allocations innecesarias y gestionar memoria eficientemente es crucial para apps fluidas.
🎯 Meta: Mantener memory usage estable durante uso normal. Spikes de memoria solo durante cargas de datos, que deben ser breves y predecibles.
// ✅ Pool de objetos para evitar allocations frecuentes
class WeatherDataPool {
static final Queue<WeatherData> _pool = Queue<WeatherData>();
static const int _maxPoolSize = 50;
static WeatherData obtain() {
if (_pool.isNotEmpty) {
return _pool.removeFirst()..reset(); // Reutiliza objeto existente
}
return WeatherData(); // Crea nuevo solo si es necesario
}
static void release(WeatherData object) {
if (_pool.length < _maxPoolSize) {
_pool.add(object);
}
// Si el pool está lleno, deja que GC maneje el objeto
}
}
class WeatherData {
double temperature = 0.0;
String condition = '';
DateTime timestamp = DateTime.now();
void reset() {
temperature = 0.0;
condition = '';
timestamp = DateTime.now();
}
}
// Manejo eficiente de streams y subscriptions
class WeatherService {
StreamSubscription? _weatherSubscription;
final StreamController<WeatherData> _controller = StreamController.broadcast();
Stream<WeatherData> get weatherStream => _controller.stream;
void startListening() {
_weatherSubscription?.cancel(); // Cancela subscription anterior
_weatherSubscription = weatherApiStream.listen(
(data) {
// Procesa datos y libera objetos temporales inmediatamente
final processedData = _processWeatherData(data);
_controller.add(processedData);
// Si usas object pool, libera aquí
// WeatherDataPool.release(data);
},
onError: (error) {
print('Weather service error: $error');
},
);
}
void dispose() {
_weatherSubscription?.cancel();
_controller.close();
}
}
// Lazy loading de datos pesados
class WeatherDatabase {
static final Map<String, WeatherData> _cache = {};
static const int _maxCacheSize = 100;
static Future<WeatherData?> getWeatherData(String locationId) async {
// Check cache first
if (_cache.containsKey(locationId)) {
return _cache[locationId];
}
// Load from database
final data = await _loadFromDatabase(locationId);
if (data != null) {
// Add to cache with LRU eviction
if (_cache.length >= _maxCacheSize) {
final oldestKey = _cache.keys.first;
_cache.remove(oldestKey);
}
_cache[locationId] = data;
}
return data;
}
static Future<WeatherData?> _loadFromDatabase(String locationId) async {
// Implementación de carga desde DB
// Usar isolates para operaciones pesadas
return await compute(_parseWeatherData, locationId);
}
}
// Función pura para ejecutar en isolate
WeatherData _parseWeatherData(String locationId) {
// Parsing pesado ejecutado en background thread
// No bloquea UI thread
return WeatherData();
}
Aplicando estas técnicas en BorealisClima y PillSync, logré mejoras significativas en dispositivos reales, especialmente en gama baja:
Para facilitar la aplicación de estas técnicas, aquí tienes un checklist ordenado por impacto vs esfuerzo:
Si tu aplicación Flutter sufre de rendimiento lento o frame drops, puedo ayudarte a identificar y resolver los cuellos de botella específicos.
⚡ Consultoría de Performance