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

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

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

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

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

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

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

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

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

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

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

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

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

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

import 'dart:developer';

void main() {
  log('This is a verbose message', name: 'MyApp', level: 200);
  log('This is a debug message', name: 'MyApp', level: 300);
  log('This is an info message', name: 'MyApp', level: 400);
  log('This is a warning message', name: 'MyApp', level: 500);
  log('This is an error message', name: 'MyApp', level: 1000);
  log('This is a critical/fatal message', name: 'MyApp', level: 2000);
}

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

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

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

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

V/MyApp(1234): This is a verbose message
D/MyApp(1234): This is a debug message
I/MyApp(1234): This is an info message
W/MyApp(1234): This is a warning message
E/MyApp(1234): This is an error message
F/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:

import 'package:logger/logger.dart';

void main() {
	final logger = Logger();
	
  logger.v('Verbose message');
  logger.d('Debug message');
  logger.i("Info message");
  logger.w("Warning message");
  logger.e("Error message");
  logger.wtf("What a terrible failure message");
  
  //...
}

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

Untitled

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

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

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

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

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

Блок try/catch

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

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

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

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

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

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

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

Future<void> myAsyncMethod() async {
  try {
    await myThrowingMethod().then((res) => print(res));
  } catch (error, stackTrace) {
    print('An error occurred: $error, stack trace: $stackTrace');
  }
}

Future<void> myThrowingMethod() {
  throw Exception('oops!');
}

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

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

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

Ошибки, относящиеся к фреймворку Flutter

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

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

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

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

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      builder: (context, widget) {
        // кастомный виджет, отображающий ошибку
        Widget error = const Text('Произошла ошибка! :('); 
        if (widget is Scaffold || widget is Navigator) {
          error = Scaffold(body: Center(child: error));
        }
        ErrorWidget.builder = (errorDetails) => error;

        return widget!;
      },
    );
  }
}

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

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

@override
Widget build() {
  return Column(
    children: [
      ListView(),
    ],
  );
}

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

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

А в этом примере “delayed exception” является обыкновенным Exception на уровне асинхронного Dart-кода. Подобные ошибки не отлавливаются фреймворком Flutter, но могут обрабатываться try/catch либо централизованно через интерфейс PlatformDispatcher:

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

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

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

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

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

    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');
      });
    }
    
  2. Колбэк, передаваемый в onError, должен возвращать true, если ошибка была обработана. В противном случае нужно возвращать false, тогда сработает механизм-фоллбэк — например, вывод ошибки в консоль. Конкретная механика фоллбэка определяется платформенным эмбеддером и может разниться от платформы к платформе.

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

    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 и другие in-place обработчики ошибок вместо глобальных. И, скорее всего, решите сами их использовать.

Источники

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

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

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