flutterprincess

BuildContext, которого уже нет

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

Как это выглядит

Future<void> _save() async {
  await repository.save(form.value);
  Navigator.of(context).pop();
}

Между await и следующей строкой проходит время. За это время пользователь мог нажать «назад», система могла закрыть экран, родитель мог перестроиться и выбросить этот виджет. Когда управление возвращается, context указывает на элемент, которого в дереве больше нет.

Линтер это ловит, правило называется use_build_context_synchronously. Включите, если ещё нет:

linter:
  rules:
    - use_build_context_synchronously

Почему mounted недостаточно

Стандартный ответ — проверить mounted:

Future<void> _save() async {
  await repository.save(form.value);
  if (!mounted) return;
  Navigator.of(context).pop();
}

Это правильно и в большинстве случаев достаточно. Но есть нюанс, о котором вспоминают редко: проверка обязана стоять после каждого await, а не один раз в начале. Вот так уже сломано:

if (!mounted) return;
await repository.save(form.value);
Navigator.of(context).pop();  // mounted проверен до await

И второй нюанс: в StatelessWidget никакого mounted нет. Там придётся либо тащить состояние выше, либо не держать контекст в асинхронном коде вообще.

Способ, который мне нравится больше

Не передавать контекст в асинхронный код. Захватите всё, что нужно, до await — а после используйте уже захваченные объекты:

Future<void> _save() async {
  final navigator = Navigator.of(context);
  final messenger = ScaffoldMessenger.of(context);

  try {
    await repository.save(form.value);
    navigator.pop();
  } on SaveFailure catch (e) {
    messenger.showSnackBar(SnackBar(content: Text(e.message)));
  }
}

NavigatorState и ScaffoldMessengerState переживают исчезновение вашего виджета — они принадлежат экранам выше по дереву. Поэтому вызов на них безопасен даже тогда, когда текущий экран уже уехал.

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

Отдельно про диалоги

Закрытие диалога чужим контекстом — источник особенно неприятных багов, потому что вместо краша вы получаете закрытие не того экрана. У showDialog билдер даёт собственный контекст, и для pop нужен именно он:

showDialog<void>(
  context: context,
  builder: (dialogContext) => AlertDialog(
    content: const Text('Удалить черновик?'),
    actions: [
      TextButton(
        onPressed: () => Navigator.of(dialogContext).pop(),
        child: const Text('Отмена'),
      ),
    ],
  ),
);

Назовите параметр dialogContext, а не context. Тогда он не затенит внешний, и на ревью сразу видно, какой именно контекст используется.