В предыдущем параграфе мы разобрали, что такое логирование, и как настроить его на разных уровнях приложения — для событий разного уровня критичности.
В этом мы сфокусируемся на обработке ошибок. Они могут возникать как при сборке приложения, так и в рантайме — то есть в моменте, когда пользователь взаимодействует с приложением и «что-то идёт не так». Например, это может быть проблема с сетью, некорректно введённые данные, «сломавшийся» виджет и многое другое, что нам даже сложно представить.
Управляя исключениями, мы может предотвращать краши, корректно уведомлять пользователя о проблемах (за что потом команда техподдержки мысленно скажет вам спасибо) и вообще упрощать отладку приложения.
А всё вместе это повышает надёжность приложения и улучшает 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
и другие «местные» обработчики ошибок вместо глобальных. И, скорее всего, решите сами их использовать.
На этом всё: советуем пройти квиз, чтобы закрепить знания. А в следующем параграфе мы рассмотрим темизацию — способ влиять на внешний вид как всего приложения, так и отдельных виджетов.