Detección de fugas de memoria en Flutter con Observatory y patrones de liberación

Para detectar fugas de memoria en Flutter lo más conveniente es analizar el heap del Dart VM en modo Profile. Aunque DevTools ofrece una interfaz más moderna, Observatory permite examinar el perfil de asignaciones directamente.

Conexión con Observatory

Al ejecutar la aplicación, la consola muestra un mensaje similar a:

listening on ws://127.0.0.1:64673/hXsWR_ZOsGk=/ws

Abre esa URL en el navegador. En la esquina inferior derecha aparece allocation profile.

Perfil de asignaciones y GC manual

Entra en la página que deseas analizar, vuelve atrás y pulsa el botón GC. Si en la lista de clases persisten instancias que solo deberían existir mientras la página está visible, es muy probable que haya una retención accidental.

Selecciona la clase y accede a una instancia concreta para ver su retaining path: el árbol de referencias que impide que el recolector la elimine.

Diferencia entre el heap de Dart y la memoria total

El valor que muestra Observatory solo cubre la memoria gestionada por el Dart VM. Recursos nativos como objetos Skia, texturas o capas del motor pueden consumir mucho más y no se contabilizan con precisión; por ejemplo, el motor reporta un tamaño fijo aproximado para cada EngineLayer:

size_t EngineLayer::GetAllocationSize() {
  return 3000;
}

Por eso conviene complementar con Android Profiler o Instruments cuando se investiga el consumo real.

Escenarios típicos de fuga

1. Oyentes o callbacks no eliminados

Conservar una lista de oyentes sin retirarlos provoca que los objetos que implementan esos callbacks permanezcan en memoria.

abstract class EventListener {
  void onEvent(Event event);
}

class EventBroker {
  static final List<EventListener> _listeners = [];

  static void subscribe(EventListener listener) {
    _listeners.add(listener);
  }

  static void notify(Event event) {
    for (final listener in _listeners) {
      listener.onEvent(event);
    }
  }
}

Solución: proporcionar un método de baja explícito.

class EventBroker {
  static final List<EventListener> _listeners = [];

  static void subscribe(EventListener listener) {
    _listeners.add(listener);
  }

  static void unsubscribe(EventListener listener) {
    _listeners.remove(listener);
  }

  static void notify(Event event) {
    for (final listener in _listeners) {
      listener.onEvent(event);
    }
  }
}

2. Flujos de imagen sin cerrar

Si un State escucha un ImageStream y no cancela la suscripción en dispose, el ImageInfo puede quedar retenido.

class ImagePreview extends StatefulWidget {
  const ImagePreview({super.key});

  @override
  State<ImagePreview> createState() => _ImagePreviewState();
}

class _ImagePreviewState extends State<ImagePreview> {
  ImageStream? _stream;
  ImageStreamListener? _listener;
  ImageInfo? _frame;

  void _loadImage(ImageProvider provider) {
    _stream = provider.resolve(const ImageConfiguration());
    _listener = ImageStreamListener((info, _) {
      setState(() => _frame = info);
    });
    _stream!.addListener(_listener!);
  }

  @override
  void dispose() {
    if (_stream != null && _listener != null) {
      _stream!.removeListener(_listener!);
    }
    _frame?.image.dispose();
    _frame = null;
    _stream = null;
    _listener = null;
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

3. AssetBundle con cacheado permanente

CachingAssetBundle mantiene en memoria todo lo leído durante la vida de la app. Si se cargan JSON grandes desde assets, usa cache: false o una instancia que no almacene datos.

class AssetReader {
  static AssetReader? _instance;
  final AssetBundle _bundle;

  AssetReader._(this._bundle);

  factory AssetReader() {
    _instance ??= AssetReader._(PlatformAssetBundle());
    return _instance!;
  }

  Future<Map<String, dynamic>> readJson(String path) async {
    final content = await _bundle.loadString(path, cache: false);
    return jsonDecode(content) as Map<String, dynamic>;
  }
}

4. Listas largas con imágenes

En versiones antiguas de Flutter, desplazar listas con muchas imágenes podía aumentar el consumo de memoria. Desde Flutter 1.17 el motor mejora la liberación de recursos gráficos. Para minimizar el impacto, usa ListView.builder, limita las dimensiones de las imágenes con cacheWidth/cacheHeight y evita retener manualmente proveedores de imágenes fuera del árbol de widgets.

Etiquetas: Flutter Dart Observatory DevTools Memory Leaks

Publicado el 7-22 21:10