Як перетворити Flutter-застосунок з «чорної скриньки» на прозорий механізм

💡 Усі статті, обговорення, новини про тестування — в одному місці. Приєднуйтесь до QA спільноти!

Мобільні застосунки часто залишаються «чорними скриньками», насамперед для QA, але інколи й для самих розробників. На відміну від вебу, де можна відкрити DevTools і миттєво отримати доступ до логів, мережевих запитів та помилок, у Flutter такої можливості з коробки немає.

На практиці це призводить до ситуацій, коли для пошуку проблеми потрібно розставляти брейкпойнти в підозрілих місцях або намагатися відновити картину подій зі скриншотів та описів від QA. У великих проєктах це займає непропорційно багато часу.

Саме тому я вирішив зібрати повноцінну систему логування та обробки помилок, використовуючи наявні бібліотеки. Мета — побудувати інструмент з мінімальною кількістю коду, який реально використовується в щоденній роботі команди й залишається корисним у production.

Про мене

Мене звати Богдан, я Flutter-розробник із п’ятьма роками комерційного досвіду. За цей час я встиг попрацювати над кількома великими продуктами й стартапами, пройти повний цикл — від старту розробки до релізу в сторі. Паралельно створював і власні проєкти, зокрема опублікував дві ігри на Flutter.

Мій досвід у мобільній розробці почався ще у 2017 році з Xamarin Forms. З того часу я встиг попрацювати з найрізноманітнішими підходами й інструментами, зокрема з логуванням та ефективним відстежування помилок в production.

Описану в цій статті систему я впроваджував на двох великих комерційних проєктах. В обох випадках вона була позитивно сприйнята командою і поширювалась на інші Flutter-проєкти всередині компаній.

Репозиторій

Для демонстрації підходу я підготував простий погодний застосунок, який використовує безплатний API. У ньому застосовано стандартний для Flutter-екосистеми стек: Bloc/Cubit для управління станом, GoRouter для навігації та Dio для HTTP-запитів.

Репозиторій з демо-застосунком доступний тут:

github.com/booooohdan/log_garden

У коді всі ключові етапи імплементації системи логування позначені коментарями TODO та додані окремим комітом. Це дозволяє швидко зорієнтуватися в рішенні та використати його без детального занурення в кожен файл.

Демонстрація можливостей

Використані бібліотеки

Система складається з наступних бібліотек:

  • talker_flutter — основний логер і UI для перегляду логів;
  • talker_bloc_logger — автоматичне логування BLoC lifecycle;
  • talker_dio_logger — логування HTTP-запитів і відповідей;
  • device_info_plus — інформація про пристрій;
  • package_info_plus — версія застосунку та build number;
  • shake — тригер для відкриття логера;
  • inspector — інструмент перевірки UI;
  • sentry_flutter — збір помилок і метрик продуктивності у production.

Глобальні та локальні обробники помилок

Локальне явне логування помилок реалізується через стандартні try-catch блоки з передачею exception і stack trace в Talker через метод handle.

try {
  final response = await _apiService.getCurrentWeather(
    apiKey: EnvConfig.weatherApiKey,
  );
  return Success(response);
} catch (error, stackTrace) {
  getIt<Talker>().handle(error, stackTrace);
  return Error(Exception('Unexpected error: $error'));
}

Проте цього недостатньо, оскільки частина помилок виникає поза очікуваними сценаріями або не перехоплюється явно. Щоб покрити такі випадки, необхідно runApp обгорнути в runZonedGuarded, що дозволяє перехоплювати всі необроблені помилки на рівні застосунку.

Future<void> main() async {
  await runZonedGuarded(
    () async {
      runApp(const MainApp());
    },
    (error, stack) async {
      getIt<Talker>().handle(error, stack);
    },
  ),
}

Починаючи з версії 3.3 у Flutter з’явилось нове API для глобальної обробки помилок PlatformDispatcher.instance.onError, але zones залишаються стабільним і перевіреним в production environment рішенням.

Автоматичне логування BLoC, Dio та навігатора

Bloc.observer дозволяє автоматично логувати створення блоків, події, транзакції та їх закриття. Це значно спрощує аналіз проблем зі станом без необхідності додавати логування в кожен BLoC вручну.

Bloc.observer = TalkerBlocObserver(
  talker: getIt<Talker>(),
  settings: const TalkerBlocLoggerSettings(
    printChanges: true,
    printCreations: true,
    printClosings: true,
  ),
);

Dio interceptor логує всі HTTP-запити та відповіді через інтерцептор TalkerDioLogger. У поєднанні з інформацією з менеджера стану це дає цілісну картину того, що відбувалося в застосунку під час конкретної сесії користувача.

dio.interceptors.add(
  TalkerDioLogger(
    talker: getIt<Talker>(),
    settings: const TalkerDioLoggerSettings(printResponseRedirects: true),
  ),
);

Події навігації логуються через TalkerRouteObserver, який фіксує переходи між екранами та передані параметри.

final router = GoRouter(
  observers: [TalkerRouteObserver(getIt<Talker>())],
);

Sentry для production

Для збору помилок у production використовується Sentry. Він надає детальну інформацію про stack trace, системні події та дії користувача перед помилкою або крешем. Це дозволяє знаходити й аналізувати проблеми, які не завжди помітні під час ручного тестування. У більшості випадків Sentry додає до звітів output з інформацією логера, але це працює не завжди.

У проєктах, де вже використовується Firebase, можна застосувати Crashlytics, однак Sentry, на мою думку, пропонує набагато глибшу аналітику та зручніші інструменти для аналізу помилок.

UI Inspector

UI Inspector дозволяє перевіряти кольори, шрифти, розміри, відступи та ієрархію віджетів безпосередньо в запущеному застосунку. Це особливо корисно під час фінального полірування інтерфейсу, коли різниця в кілька пікселів або неправильний font weight можуть бути помітні лише в реальному середовищі, а не на скриншотах з Figma. Обов’язковий інструмент для pixel-perfect дизайну.

Shake-to-open

Механізм shake-to-open вирішує типову проблему доступу до логера або QA-інструментів з будь-якого місця застосунку, незалежно від поточного екрана чи навігаційного стека. Цей підхід зручний тим, що налаштовується один раз і не потребує додаткових кнопок або прихованих жестів у UI. У develop-середовищі він використовується постійно, а в production може бути безпечно залишений за умови використання feature flags або доступу лише для internal тестування. Таким чином, інструменти для діагностики завжди під рукою, але не впливають на користувацький досвід звичайних користувачів.

QA Helper screen

QA Helper screen фактично виступає внутрішньою панеллю керування для тестування. На ньому зібрана базова технічна інформація про застосунок: версія, build number, параметри пристрою, обсяг доступної пам’яті, характеристики дисплею. Це дозволяє QA одразу бачити контекст, у якому виникла проблема, без додаткових уточнень або логів ззовні.

На цьому ж екрані можна увімкнути UI Inspector, що особливо зручно під час перевірки дизайну. Крім цього, QA Helper screen легко розширюється під потреби конкретного проєкту. У реальних проєктах сюди часто додаються кнопки для тестових дій: перевірка доступності API, примусове оновлення та копіювання токена, відправка тестового push-повідомлення або перемикання feature flags.

На практиці цей екран швидко стає «точкою входу» для тестування. Замість того щоб пояснювати розробнику, в якій конфігурації та на якому білді виникла проблема, вся ця інформація вже є під рукою.

In-app Logger

Talker надає окремий екран для перегляду логів з позначкою часу, рівнем важливості та джерелом події. Логи можна фільтрувати, шукати за ключовими словами, згортати довгі повідомлення та експортувати у файл для подальшого аналізу або передачі команді розробників.

Це особливо зручно для QA, оскільки замість серії скриншотів або описів у чаті можна одним кліком поділитися логом усієї сесії. Такий підхід значно зменшує кількість непорозумінь і пришвидшує фіксинг багів.

Варто зазначити, що всі логи також відображаються у стандартному output-вікні VSCode. Проте, наприклад, в Android Studio вони відображаються одним кольором, що ускладнює читання та візуальне відокремлення типів подій.

Error Snackbar

Окремою корисною деталлю є snackbar, який автоматично з’являється при будь-якому exception. Він одразу сигналізує про помилку, навіть якщо вона не має явного прояву в інтерфейсі користувача і не призводить до крешу застосунку.

Під час тестування це дозволяє миттєво помітити проблемну поведінку, не перевіряючи логер після кожного сценарію. QA бачить, що щось пішло не так, у момент виникнення помилки, а не постфактум. На практиці це значно скорочує час на виловлювання «тихих» помилок, які інакше могли б залишитися непоміченими.

Sentry, UI Inspector, Error snackbar та Shalke-to-open у main.dart

Future<void> main() async {
  await runZonedGuarded(
    () async {
      SentryWidgetsFlutterBinding.ensureInitialized();
      await configureDependencies();
      final isInspectorEnabled =
          getIt<SharedPreferences>().getBool('inspectorEnabled') ?? false;

      await SentryFlutter.init(
        (options) => options
          ..dsn = EnvConfig.sentryDsn
          ..enableAutoNativeBreadcrumbs = true,
        appRunner: () => runApp(
          MaterialApp.router(
            routerConfig: router,
            builder: (context, child) {
              ShakeDetector.autoStart(
                onPhoneShake: onShakeCallback,
              );

              return SentryWidget(
                child: Inspector(
                  isEnabled: isInspectorEnabled,
                  child: TalkerWrapper(
                    talker: getIt<Talker>(),
                    options: const TalkerWrapperOptions(
                      enableErrorAlerts: EnvConfig.isTestEnv,
                      enableExceptionAlerts: EnvConfig.isTestEnv,
                    ),
                    child: child!,
                  ),
                ),
              );
            },
          ),
        ),
      );
    },
    (error, stack) async {
      getIt<Talker>().handle(error, stack);
    },
  );
}

void onShakeCallback(ShakeEvent event) {
  final currentLocation = router.state.fullPath;
  if (currentLocation != Routes.qaHelper) {
    unawaited(router.pushNamed(Routes.qaHelper));
  }
}

Кастомні логи та розширення

Система логування не обмежується стандартними типами подій і легко розширюється під потреби конкретного проєкту. За необхідності можна створювати власні типи логів з окремими кольорами, назвами та рівнями важливості.

На одному з проєктів використовувався WebView із JavaScript-скриптами, події яких було важливо бачити окремо від решти логів. Для цього був створений кастомний JsLogger з окремим заголовком JS і жовтим кольором. У результаті події з WebView одразу візуально відокремлювались від мережевих запитів або оновлень з BLoC.

Такий підхід добре масштабується і дозволяє адаптувати систему логування під будь-які нестандартні сценарії без зміни її базової архітектури.

Висновки

Описана система логування помітно спрощує роботу як для розробників, так і для тестувальників. На її імплементацію зазвичай потрібно кілька годин, але ці витрати швидко окупаються. Приклад доступний у репозиторії: https://github.com/booooohdan/log_garden

Для QA це означає скорочення часу на тестування і регресію. Більшість проблем видно одразу в момент їх виникнення, а лог цілої сесії можна відправити розробнику буквально за кілька дій — один shake і кілька кліків.

Цей підхід добре масштабується, не прив’язаний до конкретної архітектури та може бути адаптований під різні типи проєктів — від невеликих pet-проєктів до production-застосунків з активним QA-процесом.

А як ви підходите до логування у своїх Flutter-проєктах? Плануєте використати подібну систему чи маєте власні напрацювання? Буду радий обговоренню в коментарях.

Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.

👍ПодобаєтьсяСподобалось4
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Богдане, автор пакету talker — активна русня з «Казахстану», що отримувала гранти від яндекса власне на цей пакет. Це гуглиться за 2 хвилини.

Сподіваюся ви це врахуєте і в подальшому візьмете за звичку робити мінімальний due diligence щоб не використовувати і тим більше не писати про продукти русні.

Дякую.

Цікава стаття. Talker сам використовую. На Inspector треба буде глянути.

Щодо package_info_plus для отримання версії застосунку та build number, то сам колись використовував. Але мені здалося оверхедом звертатися до платформи (ще й асинхронно), щоб отримати інформацію, яка і так відома на момент компіляції. Тому зараз використовую build_version (думаю не складно буде зробити аналогічно для отримання app_name та package).
Додав таку ціль в Makefile:

# Should be run after targets major, minor or patch
version_bump:
$(MAKE) gen
git add lib/version.dart
git commit —amend —no-edit

Цілі major, minor or patch за допомогою cider збільшують відповідну частину версії.

blockquote з’їм табуляції у фрагменті Makefile :)

Дякую, треба буде спробувати

Підписатись на коментарі