Start Debugging

Как перехватывать необработанные асинхронные ошибки во Flutter с помощью runZonedGuarded и PlatformDispatcher.instance.onError

FlutterError.onError видит только ошибки, возникшие внутри колбэков фреймворка. Future без await, таймеры, потоки и код после await попадают в другое место. Здесь показано, какой обработчик какую ошибку ловит (замеры на Flutter 3.44.8), и настройка main(), которая перехватывает их все, в том числе в вебе.

Короткий ответ: во Flutter 3.44 нужны два обработчика, а не один. FlutterError.onError получает ошибки, возникшие внутри колбэков фреймворка (build, layout, paint, синхронный onPressed). PlatformDispatcher.instance.onError получает всё остальное, что никто не перехватил в корневом изоляте: Future без await, колбэк Timer, поток без onError и любой код, выполняющийся после await, включая тело async-обработчика кнопки. Задайте оба в начале main() и возвращайте true из колбэка диспетчера. runZonedGuarded - более старый способ поймать вторую группу, и он остаётся единственным вариантом во Flutter web, потому что веб-движок никогда не вызывает PlatformDispatcher.onError. Ошибки, возникшие в изоляте, который вы запускаете сами, не попадают ни в один из обработчиков, если не передать порт onError.

Все замеры ниже сделаны на Flutter 3.44.8 (stable) с Dart 3.12.2 на релизной сборке настольного приложения для macOS, которое вызывает каждый вид ошибки и записывает в журнал, какой обработчик её получил. Маршрутизация обработчиков одинакова на Android и iOS, поскольку она находится в общем коде движка и фреймворка.

Почему FlutterError.onError пропускает асинхронные ошибки

Большинство вопросов вида “мой краш-репортер не видит эту ошибку” возникает из предположения, что FlutterError.onError - универсальный глобальный перехватчик. Это не так. Фреймворк вызывает его из собственных блоков try/catch: когда build виджета выбрасывает исключение, когда RenderObject выбрасывает его при layout или paint, когда выбрасывает колбэк распознавателя жестов, колбэк планировщика или обработчик сообщений платформы. Каждое из этих мест оборачивает ошибку в FlutterErrorDetails со строкой context, например “while handling a gesture” или “during a platform message callback”, и передаёт её в FlutterError.reportError, который вызывает FlutterError.onError.

Ошибка, выброшенная тогда, когда в стеке вызовов нет ни одного кадра фреймворка, никогда не проходит через такие блоки try/catch. Рассмотрим эту кнопку:

// Flutter 3.44.8, Dart 3.12.2
ElevatedButton(
  onPressed: () async {
    await api.save();          // throws SocketException
    showSnackBar('Saved');
  },
  child: const Text('Save'),
)

Распознаватель жестов вызывает onPressed, замыкание выполняется до первого await, возвращает Future, и блок try распознавателя завершается без ошибок. Когда api.save() позднее падает, исключение завершает этот Future с ошибкой. Никто его не ожидает (распознаватель игнорирует возвращаемое значение), поэтому ошибка считается необработанной и поднимается вверх по цепочке зон. В корневой зоне движок Flutter направляет её в PlatformDispatcher.instance.onError. Если он не задан, ошибка выводится в журнал и игнорируется. Приложение продолжает работать, поэтому такие ошибки часто остаются незамеченными, пока пользователь не сообщит о кнопке, которая “ничего не делает”.

Матрица обработчиков по результатам замеров

Я собрал небольшое приложение для macOS на Flutter 3.44.8, которое после первого кадра вызывает один вид ошибки за запуск и записывает, какой обработчик её получил. Настройка была документированной: FlutterError.onError плюс PlatformDispatcher.instance.onError в main(), без зон.

Что выбросило ошибкуОбработчик, который её получил
Синхронный throw в onPressedFlutterError.onError (context: “while handling a gesture”)
Throw в buildFlutterError.onError
Throw после await в async-обработчике onPressedPlatformDispatcher.instance.onError
Future.delayed(..., () => throw ...) без awaitPlatformDispatcher.instance.onError
Колбэк TimerPlatformDispatcher.instance.onError
Stream.error(...).listen((_) {}) без onErrorPlatformDispatcher.instance.onError
MethodChannel.invokeMethod без await для отсутствующего каналаPlatformDispatcher.instance.onError (MissingPluginException)
Isolate.run(() => throw ...) без awaitPlatformDispatcher.instance.onError
Точка входа Isolate.spawn, которая выбрасывает ошибку, порта onError нетНи один. Движок вывел Unhandled exception из dart_isolate.cc
То же, но с onError: port.sendPortСлушатель RawReceivePort

Isolate.run попадает в диспетчер, потому что повторно выбрасывает ошибку дочернего изолята в возвращаемый Future в корневом изоляте, а этот Future не ожидался. Обычный Isolate.spawn так не делает: ошибка умирает вместе с дочерним изолятом. Собственный комментарий документации движка к onError говорит об этом прямо: он “is not directly invoked by errors in child isolates of the root isolate.”

Рекомендуемый main() по шагам

Эта настройка перехватывает каждую строку таблицы на Android, iOS, macOS, Windows и Linux.

  1. Вызовите WidgetsFlutterBinding.ensureInitialized() первым, если перед runApp выполняется любая асинхронная работа (инициализация краш-репортера, чтение настроек).
  2. Назначьте FlutterError.onError. Вызовите внутри него FlutterError.presentError(details), чтобы красный блок ошибки и вывод в консоль по-прежнему появлялись в отладочных сборках, затем передайте details.exception и details.stack вашему репортеру.
  3. Назначьте PlatformDispatcher.instance.onError. Передайте ошибку и трассировку стека и верните true.
  4. Вызовите runApp.
  5. Для каждого изолята, созданного через Isolate.spawn, передайте порт onError и пересылайте всё, что в него приходит, тому же репортеру.
// Flutter 3.44.8, Dart 3.12.2
import 'dart:isolate';
import 'dart:ui';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await ErrorReporter.instance.initialize();

  FlutterError.onError = (FlutterErrorDetails details) {
    FlutterError.presentError(details); // keep the debug console output
    ErrorReporter.instance.report(
      details.exception,
      details.stack ?? StackTrace.empty,
      fatal: false,
      context: details.context?.toDescription(),
    );
  };

  PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
    ErrorReporter.instance.report(error, stack, fatal: true);
    return true; // handled: do not print the default "Unhandled Exception"
  };

  runApp(const MyApp());
}

// For isolates you spawn yourself.
Future<Isolate> spawnReported<T>(void Function(T) entry, T message) {
  final errors = RawReceivePort((Object? pair) {
    final list = pair! as List<Object?>;
    ErrorReporter.instance.report(
      list[0]!,
      StackTrace.fromString(list[1]! as String),
      fatal: false,
    );
  });
  return Isolate.spawn(entry, message, onError: errors.sendPort);
}

Порт onError получает список из двух элементов: результат toString() ошибки и трассировку стека в виде строки. На границе изолята исходный тип исключения теряется, поэтому записывайте строку как есть, а не пытайтесь проверять её через is.

Если вы используете Firebase Crashlytics, его документированная настройка устроена так же: FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterFatalError, а PlatformDispatcher.instance.onError вызывает recordError(error, stack, fatal: true) и возвращает true. Другие краш-SDK, включая Sentry, устанавливают аналогичные обработчики при собственной инициализации, поэтому не перезаписывайте их позже, не вызвав предыдущий обработчик по цепочке.

Что делает возвращаемое значение PlatformDispatcher.onError

Тип возвращаемого значения колбэка - bool, и это важно. Движок документирует его так: верните true, если вы обработали ошибку, иначе верните false, и “a fallback mechanism such as printing to stderr will be used”. Я запустил случай с Future без await, когда колбэк возвращал false. Колбэк отработал, после чего движок дополнительно вывел [ERROR:flutter/runtime/dart_vm_initializer.cc(40)] Unhandled Exception: Bad state: unawaited Future с полной трассировкой стека. При true сработал только мой обработчик.

Ни в одном из случаев процесс не завершился. Необработанная ошибка Dart во Flutter не роняет приложение: цикл событий продолжает работу со следующим событием. Это хорошо для пользователей, но плохо для наблюдаемости, потому что единственный симптом - не доведённое до конца действие. Если вам нужна политика fail-fast, реализуйте её сами в обработчике (например, if (kReleaseMode) exit(1);, как показано в документации Flutter для FlutterError.onError) и хорошо подумайте, прежде чем выпускать это в продакшен.

runZonedGuarded: когда он всё ещё нужен

До Flutter 3.3 (август 2022), в котором появился PlatformDispatcher.onError, стандартным советом было оборачивать runApp в runZonedGuarded, чтобы каждая асинхронная ошибка из кода, работающего в этой зоне, попадала в его колбэк onError, а не в корневую зону. Это по-прежнему работает, и я проверил, что он ловит те же строки, что и диспетчер:

// Flutter 3.44.8, Dart 3.12.2
import 'dart:async';

import 'package:flutter/material.dart';

void main() {
  runZonedGuarded(() {
    WidgetsFlutterBinding.ensureInitialized();
    FlutterError.onError = (details) {
      FlutterError.presentError(details);
      ErrorReporter.instance.report(details.exception, details.stack ?? StackTrace.empty);
    };
    runApp(const MyApp());
  }, (Object error, StackTrace stack) {
    ErrorReporter.instance.report(error, stack, fatal: true);
  });
}

При такой настройке Future без await, throw после await в onPressed и Isolate.run без await дошли до колбэка зоны. Ошибка из build по-прежнему попала в FlutterError.onError, потому что фреймворк перехватывает её раньше, чем она успевает стать ошибкой зоны. Значит, зона заменяет диспетчер, а не FlutterError.onError.

Если задать оба (диспетчер назначен внутри охраняемой зоны), побеждает зона. В моём запуске для Future без await сработал только колбэк runZonedGuarded, а PlatformDispatcher.instance.onError не вызывался ни разу, потому что ошибка была обработана до того, как достигла корневой зоны. Это безвредно, но означает, что краш-SDK, который полагается на диспетчер, ничего не увидит для ошибок, возникших внутри вашей зоны. Выберите один механизм.

В 2026 году есть три причины продолжать использовать зону:

Для кроссплатформенного приложения, которое также выходит в веб, прагматичная настройка такова: назначьте обработчик диспетчера (он покрывает мобильные и настольные платформы) и оборачивайте тело main в runZonedGuarded только когда kIsWeb равно true. Пусть оба колбэка указывают на одного и того же репортера.

Предупреждение “Zone mismatch”

Начиная с Flutter 3.10, отладочные сборки проверяют, что биндинг был инициализирован в той же зоне, которая позже вызывает runApp. Вот типичный неверный вариант:

// Flutter 3.44.8: triggers "Zone mismatch" in debug builds
void main() {
  WidgetsFlutterBinding.ensureInitialized(); // root zone
  runZonedGuarded(() => runApp(const MyApp()), reportError); // child zone
}

Моя отладочная сборка сообщила об этом через FlutterError.onError с контекстом “during runApp”:

Zone mismatch.
The Flutter bindings were initialized in a different zone than is now being used.

Это предупреждение, а не сбой, и в моём запуске Future без await всё равно дошёл до колбэка зоны, потому что initState выполнялся в зоне runApp. Опасность в колбэках, которые биндинг зарегистрировал в движке, пока находился в корневой зоне: события указателя, сообщения платформы и колбэки кадров вызываются в той зоне, которая была текущей на момент регистрации. Ошибки из этих путей могут обойти вашу охраняемую зону, и именно эту несогласованность описывает предупреждение. Исправление: перенесите ensureInitialized() (и любой await, которому он нужен) внутрь тела runZonedGuarded, как в предыдущем разделе. Установите BindingBase.debugZoneErrorsAreFatal = true первой инструкцией main, если хотите, чтобы CI падал из-за этого.

Подводные камни, которые всё равно скрывают ошибки

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

Источники

Comments

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

< Назад