Скрытый враг в вашем Flutter приложении
FlutterPulseЭта статья переведена специально для канала FlutterPulse. В этом канале вы найдёте много интересных вещей, связанных с Flutter. Не забывайте подписываться! 🚀

Пока приложение не вылетит… Останавливайте утечки, пока пользователи их не заметили
Вам не нужен краш в продакшене, чтобы понять, что у вас проблема с памятью.
Возможно, ваше приложение постепенно становится тормозным через несколько часов, или пользователи жалуются, что оно «тяжелое» после долгих сессий. Это тихие признаки утечек — крошечные, упрямые ссылки, которые отказываются быть собранными. Если их не остановить, они превращаются в большие ночные инциденты.
Это прагматичный, senior-level playbook для поиска и исправления утечек памяти в Flutter-приложениях. Без теоретической лекции — только паттерны, инструменты и готовые фиксы, которые позволят вам воспроизвести, локализовать и закрыть утечки быстро.
Почему это важно (кратко и по делу)
Утечки памяти вредят UX и удержанию пользователей. Они вызывают:
- растущее потребление памяти → лаги → пропущенные кадры → отток
- в конце концов краши на устройствах с малым объемом памяти → плохие отзывы
- сложные баги, которые проявляются только после часов использования
Исправление утечек дешевле, чем отладка по жалобам пользователей. Рассматривайте безопасность памяти как любой другой качественный метрик.
Как к этому подойти — рабочий процесс исправления
- Воспроизведите стабильно. Найдите пользовательские шаги или тесты, которые заставляют память расти.
- Быстро профилируйте. Запустите приложение в profile-режиме и откройте DevTools → Memory.
- Сделайте heap snapshots (до/после сценария) и сравните удерживаемые объекты.
- Найдите путь удержания (что держит эти объекты в живых).
- Исправьте и проверьте — добавьте правильную очистку, перезапустите снэпшоты и добавьте регрессионные тесты.
Если вы пройдете этот цикл хотя бы раз, вы закроете большинство утечек за один вечер.
Распространенные источники утечек в Flutter (и как они удерживают ссылки)
Неотмененные подписки
- Streams,
StreamControllerслушатели,EventChannelили сторонние стримы. - Симптом: объекты удерживаются до тех пор, пока существует подписка.
- Фикс: вызовите
.cancel()вdispose()или используйте scoped subscription helpers.
AnimationControllers и Focus/Text контроллеры
AnimationController,TextEditingController,FocusNode.- Фикс:
controller.dispose()вState.dispose().
Таймеры и Futures
Timer.periodicили долгоживущиеFutureколбеки, которые замыкают большие объекты.- Фикс: отменяйте таймеры и оборачивайте колбеки проверкой
mounted.
Singletons и статические кеши
- Большие кеши или мапы в статических полях никогда не очищаются.
- Фикс: лимитируйте размер кеша, используйте LRU или привяжите хуки жизненного цикла для очистки при низком объеме памяти.
Память изображений
Image.memory,decodeImageFromListили множество больших изображений без надлежащего кеширования.- Исправление: используйте сжатые/уменьшенные изображения,
cached_network_imageс ограничениями иimageCache.clear()с осторожностью.
Удержание BuildContext или Widgets в долгоживущих объектах
- Хранение
BuildContextв синглтоне или глобально приводит к сохранению всей поддерева в памяти. - Исправление: избегайте хранения контекстов; передавайте легковесные данные или используйте слабые ссылки (колбэки).
Isolate не закрыты
- Долгоживущие изоляты, оставленные в живых, сохраняют память до очистки.
- Исправление: вызовите
isolate.kill()и освободите порты после завершения.
Слушатели и addPostFrameCallback
- Добавление слушателя и никогда его не удаляя; использование
WidgetsBinding.instance.addPostFrameCallbackв циклах. - Исправление: удаляйте слушатели; избегайте повторной регистрации внутри
build().
Быстрое обнаружение с помощью Flutter DevTools (практические шаги)
- Запустите приложение в профильном режиме:
flutter run --profile - Откройте DevTools (обычно по адресу http://127.0.0.1:9100) или запустите:
flutter pub global activate devtools flutter pub global run devtools - В DevTools → Memory:
Сделайте базовый снимок.
Выполните сценарий (перейдите на экран X, откройте Y, повторите).
Сделайте второй снимок.
Сравните: найдите объекты, которые растут и никогда не уменьшаются (например, множество_MyModelэкземпляров, слушатели, большиеUint8List). - Используйте Allocation Profile для просмотра горячих точек выделения памяти и пути удержания.
- Используйте Heap Snapshot и нажмите на объект → Retaining Path (показывает, что удерживает его в памяти).
DevTools дает вам точную цепочку ссылок; следуйте ей к исходному коду и обычно найдете пропущенный dispose() или зависший список.
Паттерны, которые можно внедрить в проект
Пример A — Утечка подписки на Stream (плохо → исправлено)
Утекающий код
class _ChatViewState extends State<ChatView> {
StreamSubscription<Message>? _sub;
@override
void initState() {
super.initState();
_sub = messageStream.listen((m) {
// handle message
});
}
// forgot to cancel
}Исправление
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
Пример B — Утечка Timer (защита и отмена)
Утекающий
Timer.periodic(Duration(seconds: 1), (_) {
// updates state even after leaving screen
setState(() { /* ... */ });
});Исправление
Timer? _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() { /* ... */ });
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
Пример C — примесь автоматической очистки (шаблон DRY)
Добавьте эту примесь в экраны, чтобы не забывать об очистке:
mixin AutoDispose on State<StatefulWidget> {
final List<VoidCallback> _disposers = [];
void addDisposer(VoidCallback disposer) => _disposers.add(disposer);
@override
void dispose() {
for (final d in _disposers) {
try { d(); } catch (_) {}
}
_disposers.clear();
super.dispose();
}
}Использование:
class _FooState extends State<Foo> with AutoDispose {
late final StreamSubscription _s;
@override
void initState() {
super.initState();
_s = stream.listen(...);
addDisposer(() => _s.cancel());
}
}Этот паттерн централизует очистку и снижает вероятность человеческой ошибки.
Когда не удается воспроизвести локально — логирование и легковесная инструментация
- Добавьте счетчики выделения памяти: увеличивайте метрику при создании тяжелого объекта и уменьшайте при его уничтожении. Отправляйте в телеметрию. Если счетчики растут, есть утечка.
- Логируйте создание долгоживущих объектов (например, стек-трейс создания, если количество экземпляров > N).
- Используйте отладочные страницы в приложении, показывающие
imageCache.currentSizeBytes, активные isolates и количество живых слушателей.
Простая идея счетчика:
class LeakCounter {
static final Map<String,int> _counts = {};
static void inc(String tag) => _counts[tag] = (_counts[tag] ?? 0) + 1;
static void dec(String tag) => _counts[tag] = max(0, (_counts[tag] ?? 1) - 1);
static Map<String,int> snapshot() => Map.from(_counts);
}Вызывайте LeakCounter.inc('LargeModel') в конструкторе и dec в соответствующем методе очистки.
Регрессионные тесты и CI — не позволяйте утечкам вернуться
- Добавьте дымовой тест, который выполняет сценарий и сообщает дельту памяти (возможно, интеграционный тест + автоматизация DevTools).
- Используйте фикстуры, выполняющие тяжелый пользовательский сценарий N раз и проверяющие, что память стабилизируется или увеличивается менее чем на X MB.
- Запускайте тесты в профильном режиме CI периодически.
Даже простая nightly-задача, выполняющая пользовательский сценарий + снимок кучи, является огромной системой раннего предупреждения.
Сложные случаи и продвинутые советы
- Widgets holding onto ImageProviders: убедитесь, что
ImageProviderпотоки уничтожаются при неиспользовании. - Большие кэши: используйте
LruMapс ограничениями размера, а не неограниченные карты. - Плагины сторонних разработчиков: если плагин утекает, изолируйте использование в обертке и освобождайте при unmount; откройте issue в upstream.
- Утечки на нативном уровне: иногда retain'ы находятся на стороне платформы (Android/iOS). Используйте платформенные профайлеры (Android Studio Profiler, Xcode Instruments) и убедитесь, что вы обнуляете JNI/local references.
Чек-лист перед отправкой изменения, чувствительного к памяти
- Воспроизвели проблему локально или на промежуточном устройстве.
- Сделали снимки кучи до и после.
- Проверили путь удержания объекта в DevTools.
- Добавили
dispose()где нужно (или использовали AutoDispose). - Проверили исправление новым снимком кучи.
- Добавили небольшой интеграционный тест или метрику работоспособности, которая поймала бы регрессию.
- Задокументировали корневую причину в PR.
3-минутный walkthrough
- Воспроизвести → запустить в
--profile. - Открыть DevTools → Memory → сделать снимок.
- Найти путь удержания → определить отсутствующий
dispose()или статическую ссылку. - Исправить (отменить подписки, удалить контроллеры, убить изоляты), затем сделать повторный снимок.
- Добавить регрессионный тест или метрику.
Отправляйте стабильность, а не сюрпризы
Проблемы с памятью обманчиво малы, пока не становятся большими. Отличие команд: относитесь к безопасности памяти как к фичер-работе — воспроизведите, протестируйте, отправьте и отслеживайте. Небольшие привычные практики (миксины авто-удаления, централизованные менеджеры подписок, лимиты кеша) спасут вам ночи и заставят пользователей любить ваше приложение.
Читать больше подобных историй:
Создавайте сложный Flutter UI без изображенийОтправляйте красивый UI без PNG
medium.com
Секреты роутинга для FlutterDeep Links, вложенные навигаторы и сохранение состояния
medium.com
Управление состоянием: выбирайте оружие с умомМаленькие решения, которые определяют долгосрочное обслуживание — научитесь их замечать.
medium.com