惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cisco Talos Blog
Cisco Talos Blog
V
V2EX
C
Check Point Blog
GbyAI
GbyAI
D
Docker
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog RSS Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
N
Netflix TechBlog - Medium
T
Troy Hunt's Blog
博客园 - Franky
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Microsoft Security Blog
Microsoft Security Blog
P
Privacy & Cybersecurity Law Blog
WordPress大学
WordPress大学
The Cloudflare Blog
S
SegmentFault 最新的问题
Latest news
Latest news
Microsoft Azure Blog
Microsoft Azure Blog
P
Proofpoint News Feed
I
InfoQ
博客园 - 【当耐特】
NISL@THU
NISL@THU
A
About on SuperTechFans
T
Tailwind CSS Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
The Hacker News
The Hacker News
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Scott Helme
Scott Helme
雷峰网
雷峰网
C
CXSECURITY Database RSS Feed - CXSecurity.com
Security Latest
Security Latest
V
Vulnerabilities – Threatpost
Security Archives - TechRepublic
Security Archives - TechRepublic
A
Arctic Wolf
Hacker News: Ask HN
Hacker News: Ask HN
N
News and Events Feed by Topic
IT之家
IT之家
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
aimingoo的专栏
aimingoo的专栏
T
Threat Research - Cisco Blogs
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
阮一峰的网络日志
阮一峰的网络日志
SecWiki News
SecWiki News
大猫的无限游戏
大猫的无限游戏
S
Security Affairs
The Register - Security
The Register - Security
www.infosecurity-magazine.com
www.infosecurity-magazine.com
L
LINUX DO - 热门话题
T
Tor Project blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как избежать утечек памяти во Flutter
flaton · 2026-05-15 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение13 мин

Охват и читатели709

Туториал

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

В данной статье рассмотрим, какие ошибки чаще всего приводят к утечкам памяти во 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” как к нормальной части работы с экраном, а не как к формальности.

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