Как находить и предотвращать утечки памяти во Flutter

15 мая 2026 · ? просмотров · ? мин
Телефон с логотипом flutter из которого вытекает вода
Содержание
Утечки памяти — одна из тех проблем, которые долго могут оставаться незаметными в мобильном приложении, а затем приводить к росту потребления ресурсов, снижению производительности и нестабильной работе интерфейса. Чаще всего причиной становятся ошибки в управлении жизненным циклом объектов, подписок и контроллеров.

В данной статье рассмотрим, какие ошибки чаще всего приводят к утечкам памяти во Flutter, как их обнаруживать и какие практики помогают избежать подобных проблем в реальных проектах.

Введение: Почему утечки памяти во Flutter
- это реальная проблема

Flutter хорошо скрывает от разработчика многие низкоуровневые детали работы с памятью. Мы не выделяем и не освобождаем память вручную,
как в некоторых других языках, а большую часть рутины берет на себя сборщик мусора Dart. Из-за этого может возникнуть ощущение, что утечки памяти во Flutter почти невозможны.

На практике память все равно остается важной частью стабильности приложения. Если лишние объекты продолжают накапливаться, интерфейс может начать реагировать медленнее, а переходы между экранами
— выполняться с задержкой. На устройствах с небольшим объемом памяти это особенно заметно: система может принудительно закрыть приложение,
а пользователь увидит это как зависание или внезапный вылет.
Чаще всего проблема появляется не сразу. Экран открывается нормально, список скроллится, данные загружаются. Но после нескольких переходов туда-обратно, долгой работы приложения или повторного открытия одних
и тех же экранов память продолжает расти. Утечки легко не заметить во время обычной разработки.

Большинство этих утечек возникает из-за повторяющихся ошибок. Если понимать, где они обычно появляются, и соблюдать несколько простых правил, риск возникновения утечек сильно снижается.

Что вообще вызывает утечки во Flutter

Утечка памяти — это ситуация, при которой объект уже не используется
в логике приложения, но остается доступным через одну или несколько ссылок. Пока такая связь сохраняется, сборщик мусора не может освободить занимаемую им память. Во Flutter подобные ситуации чаще всего возникают не из-за самих виджетов, а из-за связанных с ними объектов: контроллеров, подписок, таймеров, слушателей и кэшей.

Контроллеры без “dispose”

Один из самых частых примеров — забытый ”dispose()”. 

Если экран создает ”TextEditingController”, ”AnimationController”, ”ScrollController”, ”PageController”, ”TabController”, ”FocusNode” или похожий объект, его обычно нужно освободить в методе ”dispose”. Иначе объект может продолжить жить после закрытия экрана.

Например, проблема может выглядеть так:
class ProfilePageState extends State<ProfilePage> {
  final controller = TextEditingController();
  @override
  Widget build(BuildContext context) {
    return TextField(controller: controller);
  }
}
Здесь контроллер создается, но не освобождается. Если пользователь несколько раз откроет и закроет такой экран, в памяти могут остаться лишние контроллеры. Правильнее добавить “dispose”:
class ProfilePageState extends State<ProfilePage> {
  final controller = TextEditingController();
  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }
  @override
  Widget build(BuildContext context) {
    return TextField(controller: controller);
  }
}
То же самое относится к ”ScrollController”. Например, если экран слушает скролл, но не освобождает контроллер, он может удерживать слушатель и состояние экрана:
class FeedPageState extends State<FeedPage> {
  final scrollController = ScrollController();
  @override
  void initState() {
    super.initState();
    scrollController.addListener(_onScroll);
  }
  void _onScroll() {
    if (scrollController.position.pixels > 300) {
      // Например, показываем кнопку "Наверх".
    }
  }
  @override
  void dispose() {
    scrollController.removeListener(_onScroll);
    scrollController.dispose();
    super.dispose();
  }
  @override
  Widget build(BuildContext context) {
    return ListView(
      controller: scrollController,
      children: const [],
    );
  }
}

Подписки на ”Stream”

Еще один распространенный источник проблем — подписки на потоки, события или изменения состояния. Если экран подписался на “Stream”, но не отменил подписку, поток может продолжить отправлять события в уже закрытый экран.

Проблемный пример:
class MessagesPageState extends State<MessagesPage> {
  @override
  void initState() {
    super.initState();
    chatRepository.messagesStream.listen((message) {
      setState(() {
        // Добавляем новое сообщение на экран.
      });
    });
  }
}
Здесь результат ”listen” нигде не сохраняется, поэтому подписку потом невозможно отменить. Лучше сохранить ”StreamSubscription” и вызвать ”cancel”:
class MessagesPageState extends State<MessagesPage> {
  late final StreamSubscription<Message> subscription;
  @override
  void initState() {
    super.initState();
    subscription = chatRepository.messagesStream.listen((message) {
      if (!mounted) return;
      setState(() {
        // Добавляем новое сообщение на экран.
      });
    });
  }
  @override
  void dispose() {
    subscription.cancel();
    super.dispose();
  }
}
В таком варианте жизненный цикл подписки становится понятным: она создается вместе с экраном и отменяется, когда экран больше не нужен.

Слушатели у ”ChangeNotifier” и ”ValueNotifier”

Похожая история бывает с ”ChangeNotifier”, ”ValueNotifier” и другими объектами, у которых есть ”addListener”. Если добавить слушатель и забыть удалить его, notifier продолжит хранить ссылку на callback. А callback часто связан с ”State” экрана.

Плохой вариант:
class CartPageState extends State<CartPage> {
  @override
  void initState() {
    super.initState();
    cartNotifier.addListener(_onCartChanged);
  }
  void _onCartChanged() {
    setState(() {});
  }
}
Правильнее удалить слушатель в ”dispose”:
class CartPageState extends State<CartPage> {
  @override
  void initState() {
    super.initState();
    cartNotifier.addListener(_onCartChanged);
  }
  void _onCartChanged() {
    if (!mounted) return;
    setState(() {});
  }
  @override
  void dispose() {
    cartNotifier.removeListener(_onCartChanged);
    super.dispose();
  }
}

BLoC и другие сущности, созданные как переменные

Отдельная ситуация — создание сущностей вроде BLoC, Cubit, контроллера экрана или собственного менеджера состояния как обычной переменной внутри ”State”. Само по себе это не ошибка. 

Проблема появляется тогда, когда у такой сущности есть внутренние подписки, потоки, контроллеры или другие ресурсы, но при закрытии экрана она не освобождается.

Например, BLoC можно создать прямо в состоянии экрана:
class OrdersPageState extends State<OrdersPage> {
  final ordersBloc = OrdersBloc();
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<OrdersBloc, OrdersState>(
      bloc: ordersBloc,
      builder: (context, state) {
        return OrdersList(orders: state.orders);
      },
    );
  }
}
На первый взгляд код выглядит нормально: экран создал BLoC и передал его в ”BlocBuilder”. Но если ”OrdersBloc” внутри слушает ”Stream”, хранит ”StreamController” или выполняет периодические операции, после закрытия экрана он может остаться в памяти. В таком варианте экран сам создал объект, значит он сам должен его закрыть:
class OrdersPageState extends State<OrdersPage> {
  final ordersBloc = OrdersBloc();
  @override
  void dispose() {
    ordersBloc.close();
    super.dispose();
  }
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<OrdersBloc, OrdersState>(
      bloc: ordersBloc,
      builder: (context, state) {
        return OrdersList(orders: state.orders);
      },
    );
  }
}
Часто удобнее доверить жизненный цикл виджету, который умеет закрывать BLoC автоматически. Например, при использовании ”flutter_bloc” можно создать BLoC через ”BlocProvider”:
class OrdersPage extends StatelessWidget {
  const OrdersPage({super.key});
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => OrdersBloc(),
      child: const OrdersView(),
    );
  }
}
В таком случае BLoC создается как часть дерева виджетов, и ”BlocProvider” закрывает его, когда соответствующая часть дерева удаляется. Это снижает риск забыть ”close”.

Важно не путать этот вариант с передачей уже созданного BLoC через ”BlocProvider.value”:
BlocProvider.value(
  value: existingOrdersBloc,
  child: const OrdersView(),
);
Такой способ не делает виджет владельцем BLoC. Если объект был создан где-то снаружи, закрывать его должен тот код, который его создал. Общее правило простое: кто создал сущность с жизненным циклом, тот отвечает
за ее освобождение.

Таймеры, которые продолжают работать

Похожая ситуация бывает с “Timer“. Если таймер запущен на экране и не отменен при уходе с него, он может продолжать работать. Особенно опасны периодические таймеры, потому что они вызывают код снова и снова, даже когда экран уже не нужен.

Например, экран подтверждения кода может запускать обратный отсчет:
class CodePageState extends State<CodePage> {
  Timer? timer;
  int secondsLeft = 60;
  @override
  void initState() {
    super.initState();
    timer = Timer.periodic(const Duration(seconds: 1), (_) {
      setState(() {
        secondsLeft--;
      });
    });
  }
}
Если пользователь уйдет с экрана, таймер все равно может продолжить тикать. Лучше отменить его:
class CodePageState extends State<CodePage> {
  Timer? timer;
  int secondsLeft = 60;
  @override
  void initState() {
    super.initState();
    timer = Timer.periodic(const Duration(seconds: 1), (_) {
      if (!mounted) return;
      setState(() {
        secondsLeft--;
      });
    });
  }
  @override
  void dispose() {
    timer?.cancel();
    super.dispose();
  }
}

Сохраненный “BuildContext”

Отдельно стоит помнить про “BuildContext”. Его не стоит сохранять
в сервисах, менеджерах навигации, singleton-объектах или долгоживущих callback. “BuildContext“ связан с конкретным местом в дереве виджетов.
Если сохранить его надолго, можно случайно удержать часть этого дерева
в памяти.

Например, так делать не стоит:
class DialogService {
  BuildContext? context;
  void saveContext(BuildContext value) {
    context = value;
  }
  void showError(String message) {
    showDialog(
      context: context!,
      builder: (_) => AlertDialog(content: Text(message)),
    );
  }
}
Лучше передавать “BuildContext” только в момент, когда он действительно нужен:
class DialogService {
  void showError(BuildContext context, String message) {
    showDialog(
      context: context,
      builder: (_) => AlertDialog(content: Text(message)),
    );
  }
}

Замыкания, которые держат лишние объекты

Иногда мы передаем функцию в обработчик, подписку или сервис, и эта функция неявно хранит ссылку на “BuildContext”, “State” или большой объект. Такая функция называется замыканием. Не обязательно глубоко разбирать этот термин: важно понимать, что callback может "захватить" переменные вокруг себя.

Например:
void openDetails(Product product) {
  analyticsService.onNextEvent = () {
    debugPrint('User opened ${product.title}');
    Navigator.of(context).pushNamed('/details');
  };
}
Если “analyticsService” живет долго, он будет хранить callback. А callback хранит `product` и “context”. Иногда это нормально, но если экран уже закрыт, такие ссылки становятся лишними. Лучше не хранить UI-callback
в долгоживущем сервисе, а передавать только данные:
void openDetails(Product product) {
  analyticsService.trackProductOpened(product.id);
  Navigator.of(context).pushNamed('/details');
}

Кэши без ограничений

К утечкам также могут приводить слишком большие кэши. Кэш сам по себе не является ошибкой, но если он растет без ограничений и никогда не очищается, приложение со временем начинает потреблять все больше памяти. Это может касаться изображений, загруженных данных, результатов вычислений или объектов, которые хранятся в singleton-сервисах.

Проблемный пример:
class ProductCache {
  final Map<String, Product> products = {};
  void save(Product product) {
    products[product.id] = product;
  }
}
Если товаров становится много, этот map будет только расти. В реальном приложении лучше заранее определить правило очистки: ограничить размер кэша, удалять старые элементы или очищать данные при выходе пользователя.

Простой вариант с ограничением размера:
class ProductCache {
  static const maxItems = 100;
  final Map<String, Product> products = {};
  void save(Product product) {
    if (products.length >= maxItems) {
      products.remove(products.keys.first);
    }
    products[product.id] = product;
  }
}
Это не универсальная реализация кэша, но она показывает главную идею:
у долгоживущего хранилища должен быть предел.

Как искать утечки памяти

Например приложение начинает потреблять все больше памяти при повторении одного и того же сценария. Например, вы открываете экран, возвращаетесь назад, снова открываете его, и так несколько раз. Если после каждого круга память растет и не возвращается примерно к прежнему уровню, стоит проверить этот участок внимательнее.

Для поиска таких проблем удобно использовать Flutter DevTools. В разделе Memory можно наблюдать, как меняется потребление памяти во время работы приложения. Не нужно сразу пытаться понять каждую цифру. Для начала достаточно смотреть на общий тренд: память растет постоянно или после закрытия экрана часть объектов освобождается.
Полезный подход — проверять конкретный сценарий:

1. Запустить приложение в profile или debug режиме.
2. Открыть DevTools и перейти в раздел Memory.
3. Выполнить один и тот же пользовательский сценарий несколько раз.
4. Посмотреть, возвращается ли память к стабильному уровню.
5. Если память растет, сузить сценарий до конкретного экрана или действия.

Также стоит обращать внимание на объекты, количество которых постоянно увеличивается. Это можно проверить через снимки памяти в Flutter DevTools. Идея простая: сделать снимок до повторения сценария, несколько раз открыть и закрыть экран, затем сделать второй снимок и сравнить, каких объектов стало больше.
Практически это можно делать так:

1. Открыть приложение и перейти в состояние, где нужный экран
еще не открыт.
2. В DevTools открыть раздел Memory.
3. По возможности нажать сборку мусора, чтобы убрать объекты, которые уже можно освободить.
4. Сделать первый heap snapshot.
5. Открыть и закрыть проверяемый экран несколько раз.
6. Снова запустить сборку мусора.
7. Сделать второй snapshot и сравнить его с первым.

После этого нужно смотреть не только на общий размер памяти, но и на список классов. Если после каждого повторения сценария растет количество “TextEditingController“, “ScrollController“, “FocusNode“, “OrdersBloc“, “ProfileViewModel“, “MessagesPageState“ или похожих объектов из вашего приложения, это сильный сигнал, что что-то удерживает экран или его зависимости в памяти.
С подписками ситуация немного сложнее. 

В списке объектов они не всегда будут называться очевидно, например просто “StreamSubscription“. Часто удобнее искать косвенные признаки: после закрытия экрана в памяти все еще остаются BLoC, ViewModel, “State“ экрана или контроллеры, внутри которых была подписка. Если вместе с ними растет количество “StreamController“, объектов событий или моделей данных, стоит проверить, вызывается ли “cancel“ у подписки.

Например, если после десяти открытий экрана заказов в памяти стало десять экземпляров “OrdersBloc“, хотя на экране больше ничего не открыто, проблема почти наверняка в жизненном цикле BLoC. Возможно, его создали как переменную, но забыли вызвать “close“, или подписка внутри BLoC продолжает удерживать объект.

Не менее важный способ поиска - обычная проверка кода. Когда на экране есть “initState“, подписки, контроллеры, таймеры или ручное добавление слушателей, рядом почти всегда должен быть понятный код очистки. 

Иногда утечка проявляется не как резкий рост памяти, а как странное поведение: старый экран получает события, запросы выполняются несколько раз, один и тот же callback вызывается после закрытия страницы. Такие симптомы тоже часто говорят о забытых подписках или слушателях.

Leak Tracker

Кроме ручной проверки в DevTools, можно использовать Leak Tracker. Пакет 

[`leak_tracker`](https://pub.dev/packages/leak_tracker) описывает себя как фреймворк для поиска проблем с памятью в Dart и Flutter-приложениях. Он помогает автоматически находить объекты, которые были созданы, но не были освобождены вовремя.

Инструмент особенно полезен для типичных Flutter-объектов с жизненным циклом: контроллеров, “FocusNode“, “AnimationController“, “ScrollController“, “StreamSubscription“ и похожих сущностей. 

Для экспериментов его можно подключить прямо в приложение и смотреть предупреждения во время запуска в debug-режиме.

Пример базовой настройки перед “runApp“:
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:leak_tracker/leak_tracker.dart';
void main() {
  FlutterMemoryAllocations.instance.addListener(
    (ObjectEvent event) {
      LeakTracking.dispatchObjectEvent(event.toMap());
    },
  );
  LeakTracking.start();
  runApp(const App());
}
После этого приложение можно запустить в debug-режиме и пройти подозрительный сценарий: открыть экран, закрыть его, повторить несколько раз. Если “leak_tracker“ обнаружит объекты, которые не были освобождены, в консоли могут появиться предупреждения примерно такого вида:
leak_tracker: 3 memory leak(s): not disposed: 3, not GCed: 0, GCed late: 0

Чтобы быстро проверить, что инструмент действительно работает, можно временно добавить явную ошибку. Например, создать “FocusNode“ в “build“ и не вызвать для него “dispose“:
class BadExample extends StatelessWidget {
  const BadExample({super.key});
  @override
  Widget build(BuildContext context) {
    FocusNode();
    return const SizedBox();
  }
}
Такой код не нужно оставлять в приложении. Он нужен только как демонстрация: создается объект с жизненным циклом, но никто его не освобождает. В реальном коде “FocusNode“ должен храниться в “State“ и освобождаться в “dispose“.

Важно понимать ограничение: Leak Tracker показывает подозрительные объекты, но не всегда сразу объясняет причину. Если инструмент сообщает об утечке, дальше все равно нужно посмотреть, кто создает объект и кто должен отвечать за его освобождение.

Практические правила профилактики

Главное правило: все, что вы создаете и что имеет жизненный цикл, должно иметь понятное место для очистки. Если объект создается внутри “State“, чаще всего он должен освобождаться в “dispose“.

Контроллеры, которые обычно требуют `dispose`:

- “TextEditingController“
- “ScrollController“
- “PageController“
- “TabController“
- “AnimationController“
- “FocusNode“

Если вы добавляете слушатель через “addListener“, заранее подумайте, где будет “removeListener“. Хорошая практика — располагать эти операции симметрично: подписка в “initState“, отписка в “dispose“. Так код проще читать и проверять.

С таймерами правило такое же: если создаете “Timer“ или “Timer.periodic“, сохраните ссылку на него и отмените через “cancel“ в “dispose“. Не стоит рассчитывать, что таймер сам "не помешает", особенно если он обращается к состоянию экрана.

С асинхронными операциями важно помнить, что экран может закрыться раньше, чем завершится запрос. Перед вызовом “setState“ после “await“ проверяйте “mounted“:
Future<void> loadData() async {
  final result = await repository.loadProfile();
  if (!mounted) return;
  setState(() {
    profile = result;
  });
}
Это не решает все проблемы с памятью, но помогает избежать ситуаций, когда завершившаяся операция пытается обновить уже закрытый экран.

Не храните “BuildContext“ в сервисах, singleton-объектах и долгоживущих классах. “BuildContext“ связан с деревом виджетов, и если сохранить
его надолго, можно случайно удержать в памяти часть интерфейса.
Обычно лучше передавать только нужные данные или вызывать навигацию
и показ диалогов из слоя UI.

Осторожнее с глобальными списками, map-объектами и кэшами.
Если данные должны храниться долго, задайте правила: сколько объектов можно держать, когда их нужно обновлять и когда очищать. Кэш без ограничений со временем легко превращается в утечку.

Для изображений используйте разумные размеры. Часто приложение загружает картинку намного больше, чем нужно для отображения на экране. Это не классическая утечка, но эффект похожий: памяти расходуется больше, чем необходимо. Если возможно, загружайте изображения подходящего размера и не храните лишние копии.
В командах полезно договориться о простом чек-листе для ревью:

- Если есть “initState“, проверить, нужен ли “dispose“.
- Если есть “addListener“, найти соответствующий “removeListener“.
- Если есть “StreamSubscription“, убедиться, что вызывается “cancel“.
- Если есть “Timer“, убедиться, что он отменяется.
- Если есть кэш, понять, ограничен ли его размер.
- Если после “await“ вызывается “setState“, проверить “mounted“.

Эти правила не требуют глубокого знания внутреннего устройства Dart VM. Они просто помогают держать жизненный цикл объектов под контролем.

Заключение

Утечки памяти во Flutter - это не редкая экзотика, а обычная инженерная проблема, которая появляется там, где объект живет дольше, чем должен. Чаще всего причина проста: забыли освободить контроллер, не отменили подписку, оставили таймер, сохранили лишнюю ссылку или позволили кэшу расти без ограничений.

Проверяйте жизненный цикл объектов во время разработки, используйте Flutter DevTools для подозрительных сценариев и относитесь к “dispose” как к нормальной части работы с экраном, а не как к формальности.

Если в проекте постепенно появляется привычка задавать вопрос "кто владеет этим объектом и когда он освобождается?", большинство утечек удается предотвратить еще до того, как они попадут в релиз.
Оценить материал
Остальные статьи по flutter