2.13. Project: логи, обработка ошибок

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

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

В этой главе мы рассмотрим, какие способы выведения логов бывают в Dart и Flutter и как их эффективно использовать.

Первое использование

По умолчанию вы можете использовать функцию print, которая является встроенной функцией в Dart и выводит сообщения в консоль. Но хотя функция print может быть полезной для простых целей отладки, она плохо подходит для ситуаций, когда может быть нужно разделять логи на разные уровни, дополнять их понятным стек-трейсом и в целом удобно, а главное читаемо, форматировать.

Более того, логи, выводимые при помощи print, сохраняются в релизных сборках. Это не только неэффективно, но и небезопасно, поскольку порой в них может оказаться чувствительная информация, а стек-трейсы могут помочь злоумышленникам в реверс-инжиниринге программы.

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

Совсем отказываться от print не стоит — просто эта функция не даёт той тонкости настройки, которая делает отладку приложения более эффективной и безопасной. Какие инструменты вам в этом помогут — расскажем ниже.

Логирование по уровням

Наиболее частым примером организации логирования является разделение логов по уровням:

  • verbose — малозначительные логи, условно «техническая» информация, требующаяся для глубокого погружения в то, что происходило до интересующего вас при дебаге момента;
  • debug — чуть более важный уровень «технических» логов, часто выводящийся в консоль по умолчанию, в отличие от verbose;
  • info — информационные сообщения, отражающие наступление каких-то существенных событий, но исключительно в рамках ожидаемого поведения;
  • warning — что-то уже пошло не так, однако всё ещё не портит пользовательский опыт;
  • error — произошла ошибка, которая сломала сценарий взаимодействия пользователя с программой;
  • critical/fatal — критическая ошибка, разрушающая пользовательский опыт, например крэш.

В Flutter вы можете использовать функцию log из библиотеки dart:developer для логирования таких гранулярных сообщений. Эта функция принимает три параметра: сообщение, уровень критичности и имя. Имя используется для указания имени приложения или компонента, который выполняет логирование. Уровень критичности определяет тип сообщения, которое логируется.

Вот пример использования функции log во Flutter:

1import 'dart:developer';
2
3void main() {
4  log('This is a verbose message', name: 'MyApp', level: 200);
5  log('This is a debug message', name: 'MyApp', level: 300);
6  log('This is an info message', name: 'MyApp', level: 400);
7  log('This is a warning message', name: 'MyApp', level: 500);
8  log('This is an error message', name: 'MyApp', level: 1000);
9  log('This is a critical/fatal message', name: 'MyApp', level: 2000);
10}

Здесь мы используем функцию log для логирования сообщений с различными уровнями критичности. Мы указываем название приложения в качестве параметра name.

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

Чтобы увидеть логи в консоли Flutter, вы можете запустить ваше приложение в режиме debug и просмотреть вывод в консоли или открыть вкладку “Debug Console” в редакторе кода.

Вот так будет выглядеть вывод консоли:

1V/MyApp(1234): This is a verbose message
2D/MyApp(1234): This is a debug message
3I/MyApp(1234): This is an info message
4W/MyApp(1234): This is a warning message
5E/MyApp(1234): This is an error message
6F/MyApp(1234): This is a critical/fatal message

Есть ещё один способ вывода в консоль. Возможно, вы уже где-то видели функцию debugPrint. Она является обёрткой над функцией print и существует для того, чтобы обойти специфичную для Android проблему обрезания логов системой, которое может происходить в случаях, если сообщения слишком длинные. Важно помнить, что, несмотря на название, сообщения, выводимые debugPrint, также печатаются в релизных сборках. Таким образом, использовать debugPrint стоит аналогично обычному print и актуально оно, если выводимые сообщения содержат большой объём текста.

Логирование в вашем приложении

Хотя использование print/debugPrint и более продвинутого log позволяет успешно сообщать о происходящем в программе в консоль, это далеко не самые удобные инструменты.

print и debugPrint совсем примитивны. Над log вам потребуется написать собственную обёртку, чтобы не забывать, какое именно значение — 200, 300, 400 или 500 — соответствует искомому уровню критичности.

Что насчёт кастомизации стиля выводимых сообщений? А насчёт настройки отдельных хранилищ для сообщений на случай, если вы захотите дать пользователю возможность поделиться логом при отправке сообщения разработчикам или вовсе программно отправлять всю историю логов за последние X секунд при возникновении ошибки? Всё это невозможно из коробки реализовать при использовании log. Что же делать?

Например, использовать сторонние библиотеки. Это популярный подход к организации логирования. Мы рекомендуем две:

  • logger — самая популярная библиотека;
  • logging — чуть более простая, но от разработчиков Dart.

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

Пример организации логов с пакетом logger:

1import 'package:logger/logger.dart';
2
3void main() {
4	final logger = Logger();
5	
6  logger.v('Verbose message');
7  logger.d('Debug message');
8  logger.i("Info message");
9  logger.w("Warning message");
10  logger.e("Error message");
11  logger.wtf("What a terrible failure message");
12  
13  //...
14}

Выводимые сообщения при этом будут выглядеть следующим образом:

Untitled

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

Что следует помнить при организации системы логирования:

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

Обработка ошибок

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

Блок 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}

Ошибки, не отлавливаемые Flutter

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

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

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

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

А в этом примере “delayed exception” является обыкновенным Exception на уровне асинхронного Dart-кода. Подобные ошибки не отлавливаются фреймворком 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}

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

Важно помнить 2 вещи:

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

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

    1Future<void> main() async {
    2  WidgetsFlutterBinding.ensureInitialized();
    3  WidgetsBinding.instance.platformDispatcher.onError = ((e, st) {
    4      print('FE: ${e.toString()}');
    5      return true;
    6    }
    7  );
    8  runApp(const MyApp());
    9  await Future.delayed(Duration(seconds: 3), () {
    10    throw Exception('delayed exception');
    11  });
    12}
    
  2. Колбэк, передаваемый в onError, должен возвращать true, если ошибка была обработана. В противном случае нужно возвращать false, тогда сработает механизм-фоллбэк — например, вывод ошибки в консоль. Конкретная механика фоллбэка определяется платформенным эмбеддером и может разниться от платформы к платформе.

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

    1Future<void> main() async {
    2  runZonedGuarded(
    3    () {
    4      runApp(const MyApp());
    5      Future.delayed(Duration(seconds: 3), () {
    6        throw Exception('zone delayed exception');
    7      });      
    8    }, 
    9    (err, st) => print('Zone Catched: $err'),
    10  );
    11  await Future.delayed(Duration(seconds: 3), () {
    12    throw Exception('main delayed exception');
    13  });
    14}
    

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

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

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

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

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

Источники

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

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

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