Логирование — это процесс формирования логов, сообщений программы разработчику о том, что происходит на том или ином этапе выполнения кода.
Организация полного и удобного логирования — важная составляющая разработки любой программы, поскольку это позволяет разработчикам отслеживать поведение своего кода и устранять неполадки.
В этой главе мы рассмотрим, какие способы выведения логов бывают в 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 позволит вам выводить удобно отформатированные сообщения, включать в них стек-трейс, управлять многими другими параметрами итогового сообщения.
Пример организации логов с пакетом 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");
//...
}
Выводимые сообщения при этом будут выглядеть следующим образом:
И простой фильтр сообщений в консоли позволит быстро находить именно нужные вам логи.
Что следует помнить при организации системы логирования:
- Стоит сразу начинать использовать продвинутые инструменты логирования, а не откладывать на потом — это позволит сэкономить кучу времени, а также нервов при дебаге, который неизбежен в работе над любым приложением.
- Внимательно подходите к выбору уровня критичности сообщения — это позволит быстро ориентироваться в потоке сообщений и фильтровать только необходимые, игнорируя ненужные.
- Не логируйте персональные данные пользователей. Это неэтично и зачастую противозаконно.
- В случае работы с многомодульным проектом не пренебрегайте тегами или настройкой дерева логгеров, чтобы легко понимать, какой именно модуль залогировал что-то в консоль.
Обработка ошибок
Обработка ошибок — важный аспект любого приложения, и 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 вещи:
-
Обращения к синглтону через
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'); }); }
-
Колбэк, передаваемый в
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 обработчики ошибок вместо глобальных. И, скорее всего, решите сами их использовать.