Start Debugging

Как защитить setState проверкой mounted после асинхронного разрыва во Flutter

После await виджет может быть уже уничтожен, и вызов setState бросает исключение. Защитите возобновление через if (!mounted) return; и, что лучше, отмените работу, которая его запускает. Полный паттерн для Flutter 3.44.

Правило укладывается в одну строку: внутри State никогда не вызывайте setState после await, не проверив сначала if (!mounted) return;. await возвращает управление циклу событий, пользователь может закрыть экран, пока выполняется ваша асинхронная работа, и когда ваш код возобновляется, виджет может быть уже уничтожен. Вызов setState на уничтоженном State бросает setState() called after dispose(). Геттер mounted сообщает, находится ли State ещё в дереве, поэтому защита возобновления с его помощью делает сбой невозможным. Ещё лучше: отмените таймер, подписку или запрос, которые вызвали бы setState, чтобы колбэк никогда не выполнялся на мёртвом виджете. В этом руководстве используются Flutter 3.44 (текущий стабильный, 2026) и Dart 3.x.

mounted — это геттер на State<T>. Он равен true с момента, когда фреймворк связывает ваш State с его BuildContext, ещё до того как выполняется initState, и остаётся true, пока не вызван dispose, после чего он false навсегда. Под капотом это не более чем проверка на null ссылки на элемент: bool get mounted => _element != null;. Вот и весь механизм. Когда элемент, поддерживающий ваш виджет, вырывается из дерева, _element становится null, mounted переключается на false, и любой setState, который вы попытаетесь вызвать с этого момента, — ошибка, которую фреймворк активно отвергает.

Почему именно на await это ломается

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

// Flutter 3.44, Dart 3.x -- the crash waiting to happen
class _ProfileState extends State<Profile> {
  bool _loading = false;
  User? _user;

  Future<void> _load() async {
    setState(() => _loading = true);      // fine: still on the same frame
    final user = await api.fetchUser();    // control leaves; frames run
    setState(() {                          // may run after dispose()
      _user = user;
      _loading = false;
    });
  }
}

Ничто в _load не выглядит неправильным, и это работает каждый раз, когда вы тестируете на экране, который не покидаете. Но уйдите с него, пока fetchUser в полёте, и второй setState приземлится на уничтоженный State. В отладке вы получите FlutterError: setState() called after dispose(): _ProfileState#1a2b3(lifecycle state: defunct, not mounted). Это та же структурная ошибка, описанная со стороны контекста в безопасном использовании BuildContext после await, но здесь жертва — мутация состояния, а не поиск унаследованного виджета.

Минимальная защита, шаг за шагом

Следуйте этим трём шагам для любого асинхронного метода в State, который вызывает setState после приостановки.

  1. Выполните асинхронную работу. Пусть await длится столько, сколько нужно. Вы пока не держите ничего хрупкого через него; mounted — живой геттер, а не значение, которое вы закешировали.
  2. Защитите возобновление. Сразу после await, перед любым setState, напишите if (!mounted) return;. Если виджет покинул дерево во время await, вы останавливаетесь здесь и никогда не касаетесь уничтоженного State.
  3. Вызывайте setState только после защиты. Всё, что мутирует состояние и перестраивает виджет, идёт после проверки, на том же синхронном тике, чтобы ничто не могло уничтожить вас между защитой и вызовом.

Применительно к сломанному примеру:

// Flutter 3.44, Dart 3.x -- guarded
Future<void> _load() async {
  setState(() => _loading = true);
  final user = await api.fetchUser();   // 1. async work

  if (!mounted) return;                 // 2. guard the resume

  setState(() {                         // 3. safe: State is still mounted
    _user = user;
    _loading = false;
  });
}

Это вся правка для распространённого случая. mounted равен false в тот момент, когда dispose выполнился, поэтому защита пропускает setState ровно тогда, когда он бросил бы исключение. Обратите внимание, что защита читает mounted заново после await; проверка, размещённая перед await, не говорит вам ничего, потому что разрыв, который вас волнует, — это сам await.

Почему проверка mounted — это пол, а не потолок

Фреймворк Flutter прямо говорит об этом в самом тексте ошибки setState. Его рекомендуемая правка — не только проверить mounted, а отменить работу, которая вообще вызвала бы setState. Обоснование: защита mounted предотвращает исключение, но асинхронная работа всё равно выполнилась до конца и сожгла CPU, сеть и батарею, произведя результат, который вы затем отбрасываете. Если Timer срабатывает каждую секунду на экране, который пользователь покинул десять минут назад, защита каждого тика через mounted останавливает сбой, но оставляет таймер работать вечно.

Поэтому относитесь к if (!mounted) return; как к последней линии обороны и отменяйте источник в dispose как настоящую правку везде, где источник отменяем.

// Flutter 3.44, Dart 3.x -- cancel the source, then guard as backup
class _ClockState extends State<Clock> {
  Timer? _timer;
  DateTime _now = DateTime.now();

  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(const Duration(seconds: 1), (_) {
      if (!mounted) return;             // backstop
      setState(() => _now = DateTime.now());
    });
  }

  @override
  void dispose() {
    _timer?.cancel();                   // the real fix: stop the source
    super.dispose();
  }
}

С cancel() в dispose колбэк перестаёт срабатывать в тот момент, когда виджет уходит, поэтому защита mounted почти никогда не срабатывает. Держать защиту всё равно не стоит ничего и покрывает узкую гонку, где тик уже поставлен в очередь, когда выполняется dispose. Это та же дисциплина, что вы применяете, когда освобождаете контроллеры, чтобы избежать утечек памяти: владеть жизненным циклом всего, что может вызвать обратно ваш виджет.

Подписки на Stream — классический нарушитель

StreamSubscription, чей onData вызывает setState, будет продолжать доставлять события после того, как виджет ушёл, если вы её не отмените. Сохраните подписку и отмените её в dispose.

// Flutter 3.44, Dart 3.x
class _FeedState extends State<Feed> {
  StreamSubscription<Item>? _sub;
  final _items = <Item>[];

  @override
  void initState() {
    super.initState();
    _sub = repository.itemStream.listen((item) {
      if (!mounted) return;
      setState(() => _items.add(item));
    });
  }

  @override
  void dispose() {
    _sub?.cancel();
    super.dispose();
  }
}

Забыть cancel() здесь — один из самых частых способов увидеть setState() called after dispose() в продакшен-журналах: поток переживает виджет, и каждое событие, приходящее после уничтожения, — это сбой, который защита mounted тихо поглощает. Отмените подписку, и события остановятся у источника. Отменять в dispose каждый слушатель, который вы открываете, стоит сделать таким же автоматическим, как освобождение контроллеров, предотвращающее утечки.

mounted на State против mounted на BuildContext

Есть два геттера mounted, и выбор правильного важен. State.mounted — тот, о котором это всё руководство: он живёт в вашем классе состояния, следует за жизненным циклом виджета, которым вы владеете, и это то, что вы проверяете перед setState. Он существует с самых ранних версий Flutter.

BuildContext.mounted появился во Flutter 3.7 для кода, у которого есть только контекст, а не State: вспомогательные функции, колбэки StatelessWidget, методы расширения. Он отвечает на тот же вопрос “этот элемент ещё в дереве?”, но вы тянетесь к нему, когда в области видимости нет State.

// Flutter 3.44, Dart 3.x
// Inside a State subclass, before setState:
if (!mounted) return;          // State.mounted

// In a helper that only has a BuildContext:
if (!context.mounted) return;  // BuildContext.mounted

Внутри State предпочитайте mounted, а не context.mounted. Обычно они совпадают, но State.mounted читает жизненный цикл именно того объекта, на котором вы вот-вот вызовете setState, что и есть именно то, что может быть недействительным. Оставьте context.mounted для ситуаций, рассмотренных в безопасном использовании BuildContext после await, где полезная нагрузка — поиск Navigator или ScaffoldMessenger, а не мутация состояния.

Несколько await означают несколько защит

Каждая точка приостановки заново открывает окно. Если метод выполняет await дважды и вызывает setState после каждого, ему нужна защита после каждого await, а не только после первого.

// Flutter 3.44, Dart 3.x
Future<void> _submit() async {
  setState(() => _status = 'validating');
  final valid = await validate(form);
  if (!mounted) return;                 // guard after await #1

  setState(() => _status = valid ? 'saving' : 'invalid');
  if (!valid) return;

  await repository.save(form);
  if (!mounted) return;                 // guard after await #2

  setState(() => _status = 'done');
}

Защита покрывает только те await, которые ей предшествуют. Добавьте новый await между существующей защитой и setState, и вы тихо снова открыли разрыв. Когда у метода появляется более двух таких защит, это обычно сигнал, что асинхронная работа принадлежит контроллеру, AsyncNotifier или потоку, который ваш виджет рендерит декларативно, а не куче императивных вызовов setState. Моделирование её как состояний загрузки и ошибки с AsyncValue передаёт учёт жизненного цикла фреймворку и полностью убирает большинство ручных защит.

Линтер эту не ловит

Стоит быть ясным насчёт того, что инструментарий делает и чего не делает здесь. Линтер use_build_context_synchronously из flutter_lints помечает BuildContext, использованный после асинхронного разрыва без защиты. Он не помечает голый setState после await, потому что setState не принимает контекст, и линтер его не ищет. Нет правила анализа, которое подчеркнёт незащищённый setState в первом примере этого поста. Это делает привычку целиком вашей заботой: компилятор молчит, анализатор молчит, и единственный сигнал — сбой в журнале после того, как пользователь ушёл с экрана в неподходящий момент.

Практическое следствие в том, что вы не можете опереться на красную волнистую линию, чтобы их найти. Проверяйте любой метод State, который одновременно выполняет await и вызывает setState, и делайте защиту рефлексом так же, как вы уже относитесь к super.dispose().

Подводные камни и двойники

Это другой сбой, чем setState во время сборки. Сбой setState() called after dispose() и сбой setState() or markNeedsBuild() called during build не связаны, несмотря на похожий текст. Сбой dispose — про вызов setState слишком поздно, после того как виджет ушёл; сбой build — про вызов его слишком рано, синхронно внутри прохода сборки. Правки разные, и защита для setState во время сборки покрывает второй. Если ваша трассировка стека говорит “during build”, проверка mounted — не ваша правка.

Релизные сборки всё равно падают, просто позже. Ошибка setState() called after dispose() — это реально брошенный FlutterError, а не отладочное утверждение, поэтому в отличие от поисков уничтоженного контекста она действительно появляется в релизе. Но поскольку она срабатывает из асинхронного колбэка, трассировка стека часто указывает на код фреймворка вдали от вашего метода _load, что упрощает неправильную атрибуцию. Сбой, который появляется только после навигации, изнутри Future.then или обработчика потока, — почти всегда незащищённый setState после await.

FutureBuilder и StreamBuilder обходят защиту. Если вы передадите асинхронную работу FutureBuilder или StreamBuilder вместо ручного вызова setState, билдер следит за жизненным циклом виджета за вас и никогда не вызывает setState на мёртвом State. Обращение к FutureBuilder или StreamBuilder вместо ручного setState убирает целую категорию этих сбоев ценой перестройки асинхронного потока вокруг билдера.

Эквивалент в Riverpod. Если вы держите WidgetRef или Ref нотификатора вместо State, соответствующий сбой — ошибка уничтоженного ref, а не ошибка уничтоженного State, и защита — ref.mounted, а не mounted. Та же первопричина, та же форма правки, рассмотрено в проверке Ref.mounted после асинхронного разрыва в Riverpod 3.0.

Привычка, которая отправляет сбой на пенсию

Читайте State как действительный только с начала синхронного прогона до следующего await. После возобновления проверьте mounted, прежде чем вызывать setState. И везде, где то, что запускает setState, — таймер, подписка или любой долгоживущий источник, отмените его в dispose, чтобы колбэк никогда не срабатывал на виджете, который уже ушёл. Делайте это механически, и setState() called after dispose() перестанет появляться в ваших журналах, потому что вы убрали и сбой, и напрасную работу за ним.

Источники

Comments

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

< Назад