2.18. Project: обработка ошибок

В предыдущем параграфе мы разобрали, что такое логирование, и как настроить его на разных уровнях приложения — для событий разного уровня критичности.

В этом мы сфокусируемся на обработке ошибок. Они могут возникать как при сборке приложения, так и в рантайме — то есть в моменте, когда пользователь взаимодействует с приложением и «что-то идёт не так». Например, это может быть проблема с сетью, некорректно введённые данные, «сломавшийся» виджет и многое другое, что нам даже сложно представить.

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

А всё вместе это повышает надёжность приложения и улучшает UX. То есть в конечном счёте повышает счастье пользователей.

Мы разберём:

  • Применение блогов try/catch.
  • Как обрабатывать ошибки на уровне Flutter.
  • Как обрабатывать ошибки на уровне Dart.

Блок try/catch

Один из распространённых способов обработки ошибок — использование try/catch блоков. Этот подход удобен, когда вы хотите перехватывать ошибки и обрабатывать их локально.

1try {
2  // Код который может выбросить Exception (исключение)
3} catch (error, stackTrace) {
4  // Обработка исключения
5  print('An error occurred: $error, stack trace: $stackTrace');
6}

В этом примере код внутри try-блока может вызвать ошибку. Если возникает ошибка, запускается выполнение обработчика внутри блока catch. В нём мы можем работать как с ошибкой, — это первая объявленная переменная в скобках после ключевого слова catch, — так и со стек-трейсом возникшего исключения.

Объект ошибки обычно полезен для написания логики, связанной с обработкой конкретных типов ошибок. Ошибки наследуются от базового класса Error, и для доступа к свойствам специфичной ошибки, которую вы ожидаете в конкретном месте, можно использовать чуть более тонкий синтаксис.

Рассмотрим на примере с DioException из популярной библиотеки для работы с сетевыми запросами Dio.

1try {
2  final response = await dioClient.get(Constants.myEndpoint);
3  return response;
4} on DioException catch (error, stackTrace) {
5 if (error.response?.statusCode == 401) {
6    print('User is not authorized. Full error: $error, stack trace: $stackTrace');    
7  return MyCustomError.notAuthorized();
8  }
9  rethrow; // пробрасываем ошибку наружу
10}

Стек-трейс позволяет отследить цепочку вызовов. Это полезно, например, когда нужно настроить логирование/репортинг исключений из рантайма в сервис для сборки аналитических событий — к примеру, AppMetrica или Firebase.

Отдельно хочется рассмотреть работу try/catch в случае с асинхронными блоками кода:

1Future<void> myAsyncMethod() async {
2  try {
3    await myThrowingMethod().then((res) => print(res));
4  } catch (error, stackTrace) {
5    print('An error occurred: $error, stack trace: $stackTrace');
6  }
7}
8
9Future<void> myThrowingMethod() {
10  throw Exception('oops!');
11}

Ключевое слово await в вызове myThrowingMethod всё меняет: теперь catch остаётся в том же скоупе, что и вызов выбрасывающего ошибку метода myThrowingMethod, а вы увидите ожидаемый вызов print с деталями ошибки.

Если вы хотите обработать ошибку, которая выбрасывается асинхронным методом, и при этом не использовать try/catch, то следует использовать API класса Future —метод catchError.

Рассмотрим пример с Dio:

1dioClient.get(Constants.myEndpoint)
2  .then((response) {
3    return response;
4  })
5  .catchError((error) {
6    if (error.response?.statusCode == 401) {
7      print('User is not authorized. Full error: $error, stack trace: $stackTrace');    
8    return MyCustomError.notAuthorized();
9    }
10    rethrow; // пробрасываем ошибку наружу
11  });

Обработка ошибок на уровне Flutter

Для ошибок, которые возникают на уровне Flutter, вы можете объявить глобальный обработчик:

1void main() {
2  FlutterError.onError = (details) {
3    FlutterError.presentError(details); // отображение ошибки средствами фреймворка
4    // if (someLogic) someAction();
5  }
6  runApp(const MyApp());
7}

Таким образом в одном месте можно обрабатывать все возникающие на уровне фреймворка ошибки.

Также вы можете столкнуться с ошибками на этапе построения виджетов — в build-phase. По умолчанию в debug-сессиях они вызывают показ красного экрана с логом и стак-трейсом ошибки, в release-сессиях просто выкрашивают «сломавшийся» виджет в серый цвет — совершенно непонятную и разочаровывающую пользователя картинку.

Для глобальной обработки подобных ошибок, а также для показа более «осмысленных» экранов, сообщающих о том, что что-то пошло не так, вы можете использовать MaterialApp.builder:

1class MyApp extends StatelessWidget {
2  const MyApp({super.key});
3
4  @override
5  Widget build(BuildContext context) {
6    return MaterialApp(
7      builder: (context, widget) {
8        // кастомный виджет, отображающий ошибку
9        Widget error = const Text('Произошла ошибка! :('); 
10        if (widget is Scaffold || widget is Navigator) {
11          error = Scaffold(body: Center(child: error));
12        }
13        ErrorWidget.builder = (errorDetails) => error;
14
15        return widget!;
16      },
17    );
18  }
19}

Обработка ошибок на уровне Dart

Важно разделять ошибки, которые происходят на уровне Flutter, и ошибки, которые происходят на уровне Dart. Рассмотрим два примера.

1@override
2Widget build() {
3  return Column(
4    children: [
5      ListView(),
6    ],
7  );
8}

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

А в примере ниже “delayed exception” — это обыкновенный Exception на уровне асинхронного Dart-кода.

1Future<void> main() async {
2  runApp(const MyApp());
3  await Future.delayed(Duration(seconds: 3), () {
4    throw Exception('delayed exception');
5  });
6}

Подобные ошибки не отлавливаются Flutter, но могут обрабатываться try/catch либо централизованно через интерфейс PlatformDispatcher:

1Future<void> main() async {
2  PlatformDispatcher.instance.onError = (error, st) {
3    print('Error, caught by PlatformDispatcher: $error');
4    return true;
5  };
6  runApp(const MyApp());
7  await Future.delayed(Duration(seconds: 3), () {
8    throw Exception('delayed exception');
9  });
10}

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

Но важно помнить две вещи:

Не стоит обращаться к синглтону через инстанс PlatformDispatcher.

Такой подход не позволяет подменять экземпляр PlatformDispatcher фейками или моками в тестах. Вместо этого стоит использовать экземпляр, поставляемый синглтоном WidgetsBinding, склеивающим слой UI и движка Flutter: WidgetsBinding.instance.platformDispatcher.

Использовать синглтон PlatformDispatcher.instance стоит, только если обращение к PlatformDispatcher необходимо до вызова runApp или WidgetsFlutterBinding.ensureInitialized().

Таким образом, пример выше будет правильно переписать, например, следующим образом:

```dart
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  WidgetsBinding.instance.platformDispatcher.onError = ((e, st) {
      print('FE: ${e.toString()}');
      return true;
    }
  );
  runApp(const MyApp());
  await Future.delayed(Duration(seconds: 3), () {
    throw Exception('delayed exception');
  });
}
```
Стоит возвращать true из колбека в onError при успешно отработанной ошибке

В противном случае нужно возвращать false, тогда сработает механизм-фоллбэк — например, вывод ошибки в консоль. Конкретная механика фоллбэка определяется платформенным эмбеддером и может разниться от платформы к платформе.

Кроме того, перехватывать ошибки можно в рамках конкретной Zone — окружения, в контексте которого происходит выполнение вашего кода. По умолчанию весь код, вызывающийся из main.dart, выполняется в дефолтной Zone.root, однако мы можем обернуть его в кастомную зону.

Тема зон заслуживает отдельного рассмотрения, поэтому пока что ограничимся конкретным примером с обработкой ошибок:

```dart
Future<void> main() async {
  runZonedGuarded(
    () {
      runApp(const MyApp());
      Future.delayed(Duration(seconds: 3), () {
        throw Exception('zone delayed exception');
      });      
    }, 
    (err, st) => print('Zone Catched: $err'),
  );
  await Future.delayed(Duration(seconds: 3), () {
    throw Exception('main delayed exception');
  });
}
```

Запустив приложение с подобным main.dart, мы увидим, что только Exception('zone delayed exception') будет перехвачен обработчиком onError, который мы реализовали в runZonedGuarded — методе, позволяющем выполнять Dart-код в отдельной зоне, в который мы передали наш runApp и Future.delayed с выбросом исключения.

При этом Exception('main delayed exception') выбрасывается как необработанное исключение, поскольку код, в котором оно происходит, выполняется за пределами зоны runZonedGuarded.

Таким образом, при помощи переопределения параметра onError в runZonedGuarded и при использовании других конструкторов для создания зоны и переопределении handleUncaughtError в ZoneSpecification мы можем реализовывать кастомную логику обработки ошибок в зонах.

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

Из-за этого гораздо чаще вы увидите блоки try/catch и другие «местные» обработчики ошибок вместо глобальных. И, скорее всего, решите сами их использовать.

На этом всё: советуем пройти квиз, чтобы закрепить знания. А в следующем параграфе мы рассмотрим темизацию — способ влиять на внешний вид как всего приложения, так и отдельных виджетов.

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

Отмечайте параграфы как прочитанные, чтобы видеть свой прогресс обучения

Вступайте в сообщество хендбука

Здесь можно найти единомышленников, экспертов и просто интересных собеседников. А ещё — получить помощь или поделиться знаниями.
Вступить
Сообщить об ошибке
Предыдущий параграф2.17. Project: логирование
Следующий параграф2.19. Project: темизация