Привет, мой дорогой читатель, сегодня я поведаю тебе очень занимательную историю о том, как краш на андроиде довел меня до первых седин. И какие необычные особенности есть у андроида при работе с dex файлами.
Я делаю приложение Альфа Мобайл для физических лиц. Однажды с утра пораньше мне прилетел тикет, в котором была описана проблема с лагающим UI. Этот баг воспроизводился только на 21 api. Собственно как делали наши предки для начала я попытался воспроизвести этот баг. Запускаю билд и сразу вижу вот такое:

Но погоди ка...
Как тестер смог дойти до нужного экрана если на актуальной ветке приложение крашится сразу при входе ?
Я отправился в путешествие до тестировщика, и действительно у него приложение открывалось как нужно. И тут я вспомнил, что тестеры берут apk с binary, куда они загружаются нашим CI и самый важный момент в том, что там уже включен R8, а на локальных сборках R8 не включается.
Ну что ж, вот оно различие и проблема может быть в R8 ведь так ?

Кто такой этот R8 если ты не знаешь - https://developer.android.com/studio/build/shrink-code
Но как окажется в будущем, проблема была вовсе не в R8, R8 просто был ключом к решению данной загадки. И как правило разработчики сталкиваются с обратной ситуацией, когда из-за включенного R8 приложение крашится. Усугубляло проблему еще и то, что краш происходил только на 21 api, версии выше функционировали штатно.
Давай остановимся на минутку и ты подумаешь в чем же может быть дело ?

А теперь продолжим !
На том этапе я оставался в ситуации полной неопределенности, так что постарался изучить проблему поглубже и расчехлил дебаггер:

Давай присмотримся к скриншоту. Проблема материализуется при создании сущности TextViewModel(), а она соответственно обращается к TextViewStyle.H1 и на этом все, мы ловим NoClassDefFoundError. Еще раз прошу тебя обратить внимание на то что проблема возникает только на 21 api и только на сборке без R8.
У меня все также не было идей, поэтому я решил воспользоваться дедовским способом и удалил эту строчку:

Сразу после этого я получил такой результат:

"Ну хотя бы другая ошибка, уже лучше, чем ничего" - подумал я. Но не тут то было, стоило поскролить стектрейс и:

Проблема ClassNotFoundException осталась. И она и не могла уйти от такого, загвоздка лежала глубже чем я думал. Но на этот раз я прочитал лог внимательнее и понял, что проблема где-то в dex файлах. Еще один важный момент, проблема появилась спонтанно, в одной из версий нашего приложения, причем с момента ее появления уже прошло несколько релизов, но она не была обнаружена, так как возникает только на локальных сборках именно под 5 андроид. Ну что дружище пора погрузиться на дно, в святую святых - в исходники андроида. Причем нас интересует именно 21 версия api андроида. Я заглянул в файлик под названием runtime/dex_file.cc, а конкретно метод bool DexFile::OpenFromZip
Если не знаешь, что такое dex файл рекомендую сходить сюда, если совсем просто и коротко то в него упаковывается исходный код вашего приложения - https://source.android.com/docs/core/runtime/dex-format?hl=en
while (i < 100) {
std::string name = StringPrintf("classes%zu.dex", i);
std::string fake_location = location + kMultiDexSeparator + name;
std::unique_ptr<const DexFile> next_dex_file(Open(zip_archive, name.c_str(), fake_location,
error_msg, &error_code));
if (next_dex_file.get() == nullptr) {
if (error_code != ZipOpenErrorCode::kEntryNotFound) {
LOG(WARNING) << error_msg;
}
break;
} else {
dex_files->push_back(next_dex_file.release());
}
i++;
}
Что же я там увидел. Тут даже не нужно быть знатоком C или C++. Ограничение в 100 dex файлов. Если их будет больше то они просто отбросятся и мы как раз получим ту самую NoClassDefFoundError, просто потому что класс был в dex файле, который отбросили. И в тот момент я подумал - "Не может ведь быть, чтобы у нас на локальной сборке было больше 100 dex файлов, бред какой-то." Но реальность полна разочарований, как говорил Танос:

Не нужно быть Евклидом или Пуанкаре, чтобы понять количество dex файлов, которые представлены на скриншоте. Даже я понял, что наша апка точно переваливает за лимит в 100 файлов. Как ты понимаешь с включенным R8 dex файлов уже гораздо меньше чем 100. Мне стало интересно почему приложение не крашится на версиях api выше 21. И я заглянул в тот же runtime/dex_file.cc только уже для 6 андроида:
for (size_t i = 1; ; ++i) {
std::string name = GetMultiDexClassesDexName(i);
std::string fake_location = GetMultiDexLocation(i, location.c_str());
std::unique_ptr<const DexFile> next_dex_file(Open(zip_archive, name.c_str(), fake_location,
error_msg, &error_code));
if (next_dex_file.get() == nullptr) {
if (error_code != ZipOpenErrorCode::kEntryNotFound) {
LOG(WARNING) << error_msg;
}
break;
} else {
dex_files->push_back(std::move(next_dex_file));
}
if (i == kWarnOnManyDexFilesThreshold) {
LOG(WARNING) << location << " has in excess of " << kWarnOnManyDexFilesThreshold
<< " dex files. Please consider coalescing and shrinking the number to "
" avoid runtime overhead.";
}
if (i == std::numeric_limits<size_t>::max()) {
LOG(ERROR) << "Overflow in number of dex files!";
break;
}
}
Ограничения уже нет и будут обработаны все dex файлы. Но есть забавный комментарий:
// Technically we do not have a limitation with respect to the number of dex files that can be in a
// multidex APK. However, it's bad practice, as each dex file requires its own tables for symbols
// (types, classes, methods, ...) and dex caches. So warn the user that we open a zip with what
// seems an excessive number.
static constexpr size_t kWarnOnManyDexFilesThreshold = 100;
Для 6 андроида большое количество dex файлов все также плохо, но он хотя бы будет работать, ограничение тут работает как warning. На этом мое расследование подошло к своему логичному концу, причину краша удалось установить, правда седых волос на моей голове прибавилось.

P.S.
Может возникнуть вопрос, "а как же мы решили проблему с крашем ?". Об этом я расскажу в следующей статье-расследовании.
Прикладываю ссылки на исходники, в которые я залезал:
https://android.googlesource.com/platform/art/+/android-6.0.0_r26/runtime/dex_file.cc
https://android.googlesource.com/platform/art/+/lollipop-release/runtime/dex_file.cc
Автор: Абакар Магомедов