Start Debugging

Исправление: A TextEditingController was used after being disposed во Flutter

Этот сбой означает, что код обратился к контроллеру после вызова dispose(). Защищайте асинхронные колбэки проверкой mounted и никогда не освобождайте контроллер, который вам не принадлежит.

Что-то прочитало из TextEditingController или записало в него после того, как его dispose() уже отработал. Обычный виновник — асинхронный колбэк (Future.then, await, Timer или слушатель stream), который завершается после того, как пользователь покинул экран и State был уничтожен. Защитите код после await через if (!mounted) return; до обращения к контроллеру. Другая частая причина — путаница с владением: дочерний виджет освободил контроллер, который ему передали, но который ему не принадлежит. В этом руководстве используется Flutter 3.44 (стабильный, май 2026) и Dart 3.x.

Ошибка не специфична для TextEditingController. То же сообщение появляется для любого ChangeNotifier (ScrollController, FocusNode, AnimationController, ValueNotifier, модель Provider), потому что проверка находится в самом ChangeNotifier. Тип времени выполнения в сообщении лишь говорит вам, к какому из них вы обратились слишком поздно.

Ошибка в контексте

Полное сообщение, которое выбрасывает Flutter, выглядит так:

A TextEditingController was used after being disposed.
Once you have called dispose() on a TextEditingController, it can no longer be used.

When the exception was thrown, this was the stack:
#0      ChangeNotifier._debugAssertNotDisposed.<anonymous closure> (package:flutter/src/foundation/change_notifier.dart)
#1      ChangeNotifier._debugAssertNotDisposed (package:flutter/src/foundation/change_notifier.dart)
#2      ChangeNotifier.addListener (package:flutter/src/foundation/change_notifier.dart)
#3      TextEditingController.text= (package:flutter/src/widgets/editable_text.dart)
...

Кадр стека прямо под кадрами ChangeNotifier — это строка в вашем коде. Он называет операцию, которая обратилась к мёртвому контроллеру: text=, .text, addListener, clear() или .selection. Этот кадр — то место, где вы исправляете ошибку, но причина её срабатывания лежит раньше во времени, когда dispose() отработал до этой строки.

Почему это происходит

Есть четыре причины, примерно в порядке частоты.

Асинхронный колбэк пережил виджет. Вы запустили await, Future.then, Timer или stream.listen, пока экран был жив, пользователь ушёл с него (что освобождает State и контроллер), а затем колбэк завершился и обратился к контроллеру. Это самая частая причина, потому что сбой случается только при совпадении тайминга: всё работает, когда ответ быстрый, и падает, когда пользователь шустрый или сеть медленная.

Дочерний элемент освободил контроллер, который ему не принадлежит. Родитель создал контроллер и передал его вниз; дочерний элемент вызвал dispose() на нём в своём собственном dispose(). Теперь родитель (или сосед, или следующая перестройка) использует контроллер, который дочерний элемент уже убил. Владение — это обратная сторона проблемы утечки: освободите контроллер, который вам не принадлежит, и получите этот сбой; забудьте освободить тот, что принадлежит вам, и получите утечку памяти.

Слой управления состоянием освободил его. Если контроллер живёт в провайдере autoDispose Riverpod, в контроллере GetX или в ChangeNotifier, который фреймворк уничтожил, виджет, всё ещё держащий ссылку, наткнётся на освобождённый экземпляр. autoDispose в Riverpod — частый триггер: провайдер пересчитывается или освобождается, когда за ним больше никто не наблюдает, забирая с собой контроллер, в то время как устаревшее замыкание всё ещё указывает на старый.

didUpdateWidget освободил старый слишком рано. Когда контроллер зависит от свойства widget, при обновлении вы освобождаете старый контроллер и создаёте новый. Если ожидающий колбэк захватил старый контроллер, он теперь обращается к освобождённому экземпляру.

Базовый контракт, согласно документации API ChangeNotifier во Flutter: после вызова dispose() объект становится непригодным, и любое дальнейшее использование выбрасывает исключение в отладочных сборках. Проверка удаляется из сборок release, поэтому в release тот же код не падает, а читает устаревшее или null-состояние. Вот почему вы исправляете причину, а не заглушаете проверку.

Минимальное воспроизведение

Этот виджет падает, когда вы покидаете экран до того, как fetch вернётся. Он компилируется и работает, и проходит, когда сеть быстрая.

// Flutter 3.44, Dart 3.x -- throws "A TextEditingController was used after being disposed".
import 'package:flutter/material.dart';

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

  @override
  State<SearchBox> createState() => _SearchBoxState();
}

class _SearchBoxState extends State<SearchBox> {
  final _controller = TextEditingController();

  @override
  void initState() {
    super.initState();
    _prefill();
  }

  Future<void> _prefill() async {
    // Pretend this hits the network and takes ~500ms.
    final lastQuery = await Future.delayed(
      const Duration(milliseconds: 500),
      () => 'flutter dispose error',
    );
    // If the user popped this screen during those 500ms, the State and the
    // controller are already disposed. This line then throws.
    _controller.text = lastQuery;
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(controller: _controller);
  }
}

Откройте этот экран, а затем закройте его в течение полусекунды. dispose() отрабатывает, _controller.dispose() убивает контроллер, Future завершается, и _controller.text = ... выбрасывает исключение. Сбой зависит от тайминга, и именно поэтому он переживает ревью кода и попадает в продакшен.

Исправление, подробно

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

1. Защищайте асинхронные колбэки проверкой mounted (рекомендуется)

В каждом месте, где за await (или любым отложенным колбэком) следует код, обращающийся к контроллеру или вызывающий setState, сначала проверяйте mounted. mounted равно false, как только State был освобождён, поэтому защита замыкается до обращения к контроллеру.

// Flutter 3.44, Dart 3.x -- correct: bail out if the widget is gone.
Future<void> _prefill() async {
  final lastQuery = await Future.delayed(
    const Duration(milliseconds: 500),
    () => 'flutter dispose error',
  );
  if (!mounted) return; // the State (and the controller) may be disposed
  _controller.text = lastQuery;
}

Правило: после каждого await в методе State строке, которая обращается к this, контроллеру или setState, должна предшествовать проверка mounted. Здесь один await, поэтому одна защита. Если в методе два await и контроллер используется после каждого, нужны две защиты. Линт use_build_context_synchronously анализатора Dart ловит версию этой ошибки с BuildContext; относитесь к контроллеру точно так же.

Для Timer или stream.listen защита помещается внутрь колбэка:

// Flutter 3.44, Dart 3.x
_sub = someStream.listen((value) {
  if (!mounted) return;
  _controller.text = value;
});

Ещё лучше — отменяйте подписку или таймер в dispose(), чтобы колбэк никогда не срабатывал после уничтожения. Отмена чище защиты, потому что защищённая, но не отменённая подписка всё равно просыпается, выделяет память и выполняет проверку на каждое событие для экрана, который пользователь уже покинул. Смотрите порядок освобождения в руководстве по освобождению контроллеров.

2. Исправьте владение: не освобождайте то, что вам не принадлежит

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

// Flutter 3.44, Dart 3.x
class ParentForm extends StatefulWidget {
  const ParentForm({super.key});
  @override
  State<ParentForm> createState() => _ParentFormState();
}

class _ParentFormState extends State<ParentForm> {
  final _email = TextEditingController(); // parent creates -> parent owns

  @override
  void dispose() {
    _email.dispose(); // owner disposes, exactly once
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => EmailField(controller: _email);
}

class EmailField extends StatelessWidget {
  final TextEditingController controller;
  const EmailField({super.key, required this.controller});

  // Receives the controller. Does NOT dispose it. No dispose() here at all.
  @override
  Widget build(BuildContext context) => TextField(controller: controller);
}

Если бы EmailField был StatefulWidget и освобождал widget.controller, последующее использование _email родителем выбросило бы именно эту ошибку. Исправление — удалить этот вызов dispose() из дочернего элемента. Зеркальная ошибка (родитель забывает освободить) — это утечка, рассмотренная в руководстве по освобождению выше.

3. Пусть слой управления состоянием владеет освобождением

Когда контроллер поднимается в провайдер Riverpod, контроллер GetX или любой объект вне виджета, освобождение перемещается вместе с ним. Виджет не должен освобождать контроллер, который он одолжил у провайдера, и onDispose (Riverpod) или onClose (GetX) провайдера — это место, где теперь живёт вызов dispose(). С autoDispose в Riverpod держите провайдер живым, пока экран в нём нуждается (используйте ref.keepAlive() или провайдер без autoDispose), чтобы он не пересчитывался из-под виджета, который всё ещё держит контроллер. Перенос владения жизненным циклом во время миграции управления состоянием — именно то место, где это тихо ломается; я описал миграцию приложения Flutter с GetX на Riverpod как осознанный, пошаговый переход именно по этой причине.

4. Аккуратно пересоздавайте в didUpdateWidget

Если контроллер зависит от свойства widget и вы заменяете его в didUpdateWidget, освободите старый и присвойте новый, и убедитесь, что ни один ожидающий колбэк по-прежнему не ссылается на старый экземпляр:

// Flutter 3.44, Dart 3.x
@override
void didUpdateWidget(covariant MyField oldWidget) {
  super.didUpdateWidget(oldWidget);
  if (oldWidget.initialText != widget.initialText) {
    _controller.dispose();
    _controller = TextEditingController(text: widget.initialText);
  }
}

Любая асинхронная работа, запущенная против старого контроллера, должна проверять mounted (и в идеале быть отменена) до своего завершения, иначе она запишет в освобождённый экземпляр, который вы только что заменили.

Подводные камни и варианты

setState() called after dispose(). Та же корневая причина, другая проверка. Асинхронный колбэк, вызывающий setState после уничтожения, выбрасывает это вместо сообщения о контроллере, потому что setState внутренне проверяет состояние, эквивалентное mounted. Исправление идентично: защищайте через if (!mounted) return;. Это часто идёт вместе со сбоем контроллера, поскольку один и тот же колбэк обычно и записывает в контроллер, и вызывает setState. Смотрите setState или markNeedsBuild called during build для родственника этого семейства из фазы build.

A ScrollController was used after being disposed, A FocusNode was used after being disposed, An AnimationController was used after being disposed. Та же проверка, те же исправления. Сообщение называет тот ChangeNotifier, к которому вы обратились; диагноз (асинхронность после dispose или неверный владелец) не меняется.

Сбой случается лишь иногда. Это сигнатура асинхронной причины, а не нестабильного фреймворка. Быстрая сеть его скрывает; медленная сеть или шустрый пользователь его обнажают. Не списывайте прерывистую версию этой ошибки на шум. Воспроизведите её, добавив искусственную задержку перед записью в контроллер и закрыв экран во время задержки.

Падает в тестах, но не в приложении. Виджет-тесты агрессивно уничтожают виджеты и детерминированно прокачивают кадры, поэтому отсутствующая защита mounted, которая прячется в реальном приложении, немедленно всплывает под testWidgets. Это тест делает свою работу. Команда Flutter отслеживает один из вариантов этого в issue 98965 flutter/flutter, где ожидающие таймеры в тестах обращаются к освобождённым контроллерам; исправление там тоже — отменить таймер в dispose().

Повторное использование одного контроллера двумя TextField на разных маршрутах. У контроллера один владелец. Если вы храните TextEditingController в долгоживущем синглтоне и передаёте его полю на экране A и другому на экране B, освобождение одного экрана освобождает контроллер из-под другого. Дайте каждому полю свой контроллер или перенесите текст в общее состояние и пусть каждое поле владеет локальным контроллером.

Единственная дисциплина, устраняющая весь этот класс багов: у контроллера ровно один владелец, этот владелец освобождает его ровно один раз, и каждый асинхронный путь, обращающийся к нему, защищён проверкой mounted или отменён до уничтожения. Внедрите это в свой рефлекс dispose(), и ошибка перестанет появляться. Если вы также хотите ловить обратный сбой (контроллеры, которые вообще не освобождаются), рабочий процесс leak_tracker в руководстве по освобождению заставит вас соблюдать обе стороны контракта, а моделирование асинхронных данных как состояния с аккуратными состояниями загрузки и ошибки полностью убирает опасные записи после await из контроллера.

Связанные материалы

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад