Краши и ANR в мобильной игре: как ловить то, что у тебя не воспроизводится
Самое неприятное в крашах — не сам краш. Неприятнее всего фраза «у меня не воспроизводится». Смотришь на стек из прода, пытаешься повторить — и ничего. Потому что краши почти никогда не живут на твоём девайсе. Они живут на чужом: где мало памяти, рвётся сеть, старая ОС, десять приложений в фоне и батарея на трёх процентах.
Дальше — про то, как ловить то, что у тебя не падает. И, что важнее, как сначала убедиться, что ты вообще получаешь отчёты. Потому что в моей практике чаще беда не в том, что краши «не ловятся», а в том, что репортер настроен так, что половина из них до тебя просто не доезжает — а команда уверена, что «крашлитика стоит».
Сначала проверь сам репортер
Звучит банально, но именно здесь обычно и течёт. Расскажу на живом примере — недавно разбирал лог запуска одной игры на iOS.
Картина такая. Грузится Crashlytics — и ровно в этот же момент в логе ошибки FirebaseCore: No app has been configured yet и Add FirebaseApp.configure(). То есть к Crashlytics обращаются раньше, чем вообще сконфигурировали Firebase. Дальше прилетает тестовое исключение — его специально кидают, чтобы проверить, ловится ли краш. А ещё через секунду в логе всплывает строчка: обработчик неперехваченных исключений (NSUncaughtExceptionHandler) принадлежит GADRegisterExceptionHandler — то есть его забрал себе рекламный SDK (Google Mobile Ads), а не Crashlytics.
Сложи это вместе. Тестовый краш случился раньше, чем Crashlytics успел подняться, и при этом обработчиком исключений владел чужой SDK. Такой краш с большой вероятностью просто не доехал в консоль. И вот это — первый баг, который должен ловить QA: не сам краш, а то, что краш не зарепортился.
Что проверять в самом репортере:
- Порядок инициализации: Firebase/Crashlytics поднимается до всего остального, и до рекламных и аналитических SDK, которые тоже любят вешать свой обработчик исключений.
- Намеренный тест-краш появляется в консоли — у Firebase есть готовый способ это проверить (см. test your implementation). Помни: отчёт обычно уходит не в момент краша, а при следующем запуске — проверяй после рестарта.
- Кто в итоге владеет uncaught-handler’ом: если несколько SDK дерутся за него, кто-то один проиграет, и часть крашей пропадёт.
Пока ты не уверен, что тестовый краш долетает, гоняться за реальными бессмысленно. Сначала чиним трубу, потом ищем, что по ней течёт. Базовая настройка — в гайде Crashlytics.
Краш и ANR — это разное
Краш — приложение упало. ANR (Application Not Responding) — оно живо, но повисло: главный поток заблокирован дольше примерно пяти секунд, и система предлагает «закрыть». Для пользователя ANR нередко хуже краша. Краш быстрый и однозначный, а зависание — это когда ты тапаешь, тапаешь, и ничего не происходит; ощущение «сломалось» сильнее.
И ловится ANR иначе — не через исключение, а мониторингом главного потока (на Android это видно в Android Vitals → ANR). Причина почти всегда одна: тяжёлая работа на main thread — сеть, диск, база, разбор большого JSON. В играх к этому добавляется загрузка ассетов прямо в кадре и долгие операции в главном цикле. Если у тебя «иногда подвисает на пару секунд» — это не мелочь, это будущий ANR в статистике стора.
Стек без символов — это мусор
Отдельная боль — нечитаемый отчёт. Если вместо имён функций ты видишь голые адреса, значит не подгружены символы: dSYM на iOS, mapping/symbols на Android, для Unity — свои символьные файлы. Проверь, что они заливаются в CI на каждый билд. Иначе ситуация дурацкая: краши у тебя есть, а где они — неизвестно. Про чтение крашей на iOS — у Apple про crash reports, про Android-краши — в Android Vitals.
Голого стека мало — нужен контекст
По одному стеку часто не понять, что произошло. Что реально докручивает картину: breadcrumbs (последние экраны и действия перед крашем), custom keys (уровень, баланс, онлайн/оффлайн, A/B-группа и фича-флаги), версия, билд, девайс, ОС, свободная память. В игре «упало на уровне 37 в оффлайне с почти пустой памятью» — это уже гипотеза, а «упало» — нет.
В Unity крашей два сорта
Если игра на Unity, помни, что краши бывают managed (исключение в C#) и native (краш самого движка или нативного плагина). Они ловятся и символицируются по-разному, и легко получить ситуацию, когда у тебя аккуратно приходят C#-исключения, а нативные краши — мимо. Проверь, что ловятся оба (см. Crashlytics для Unity).
Как воспроизвести то, что не падает у тебя
Теперь к той самой фразе «у меня не воспроизводится». Краши с прода завязаны на условия, которых нет на твоём столе, — значит, эти условия надо создать намеренно, а не ждать удачи:
- Зажать память — набить устройство приложениями или включить тестовый low-memory режим: ловишь OOM-киллы и краши при нехватке памяти.
- Дать плохую и рвущуюся сеть — таймауты и обрывы посреди запроса вскрывают необработанные ошибки.
- Убить приложение в фоне и вернуться — система выгрузила процесс, а код ждёт, что состояние на месте.
- Спамить тапами и быстро тыкать по кнопкам — двойные нажатия и гонки.
- Прогнать на старой ОС и слабом железе из своей матрицы фрагментации, а не только на топовом девайсе.
OOM по памяти, гонки при возврате из фона и двойные нажатия — в моём опыте самые частые источники того, что «не воспроизводится» у тестировщика, но стабильно валит часть живых пользователей.
Если коротко
Краши — это не про то, чтобы дождаться, пока что-то упадёт. Это про две вещи. Первая: гарантированно получать отчёты — а это отдельная штука, которую надо тестировать, мы это видели на примере с чужим exception-handler’ом и недоинициализированным Crashlytics. Вторая: воссоздавать чужие условия, а не надеяться повторить на своём идеальном девайсе. «У меня не воспроизводится» — это не вывод, это начало работы.
Источники: Firebase Crashlytics — Get started, Crashlytics — Test your implementation, Android Vitals — ANR, Android Vitals — Crashes, Apple — Crash reports.