В Android Developers Blog выходила статья Records in Android Studio Flamingo про то как компиляторы R8/D8 преобразуют классы java.lang.Record. В статье рассказывается как добиться минификации компонентов toString() у Kotlin data-классов. Меня заинтересовала эта тема и я решил чуть более подробно в нее углубиться. В этом посте я подсвечу некоторые моменты, оставшиеся "между строк" в оригинальной статье. Благодаря чему R8 может переписать метод toString() у Record. В чем разница между Record в Java и Kotlin. Можно ли добиться от Record в Android такой же динамики как в "настольной" JVM. Стоит ли для описания моделей использовать Record'ы вместо data-классов. Про R8/D8 написано много статей. Я отмечу что оба компилятора упакованы в один jar-файл и могут обрабатывать байт-код самых современных версий Java (в исследуемой сборке вплоть до 20). У CLI парсеров R8 и D8 есть общий предок в котором перечислены общие аргументы. Так оба компилятора принимают опцию --classfile. Она позволяет создавать не .dex файл с android специфичным байт-кодом, а получать .class файлы. Это упрощает анализ результатов работы, позволяет избежать затратного по времени вызова R8 в android-проекте. Вызов R8/D8 можно обернуть в gradle таск или плагин. Получится своеобразная песочница для быстрых экспериментов, в которой исходный код на Java/Kotlin, конфигурация R8/D8 и результаты компиляции находятся в одном проекте. Свои изыскания я проводил в такой песочнице. Все примеры кода компилировались для последней версии Android SDK (Api level 34), использовался Kotlin 1.9 и R8 версии 8.2.2-dev. Исходный код  доступен в репозитории на GitHub. Рассмотрим простой Record на языке Java Скомпилируем его для Java 17, ниже приведен отрывок получившегося байт-кода.  Метод toString() не содержит прямых инструкций для создания строкового представления экземпляра класса User. Тело метода генерируется динамически с помощью инструкции invokedynamic. Так достигается небольшой размер байт-кода, ленивость и динамичность во время выполнения. В констант-пуле содержатся оригинальные названия полей класса что делает его уязвимым к реверс-инженирингу. Скомпилируем класс с помощью R8, сохранив его название для простоты анализа. Из приведенного байт-кода видно что принципиально реализация не изменилось. Однако компилятор  распознал константу name;age и подставил вместо нее другую, с сокращенными названием полей a;b. Нумерация в новом констант-пуле отличается от нумерации в исходном. Так как мы проводим эксперимент в Gradle проекте, то можем запустить код с полученным классом под Java 17 и убедиться что результат toString() минифицирован. Мы выполнили минификацию класса, однако его все еще нельзя использовать на старых версиях Android. В них нет базового класса java.lang.Record и bootstrap-метода ObjectMethods.bootstrap. Проведем desugaring обфусцированного кода, пустим  результат работы R8 на вход D8. Последний перепишет байт-код так, чтобы сделать его совместимым с  Android. Результат снова представим как .class файл.  Родительским стал синтетический класс com.android.tools.r8.RecordTag. Приватный метод $record$getFieldsAsObjects() возвращает массив значений  полей класса. Структура массива совпадает с маской a;b. В служебном классе ExternalSyntheticRecord0 выполняется формирование строки (с помощью StringBuilder) по маске из ранее созданного массива. Реализация hashCode делегирует к ExternalSyntheticRecord1 где выполняется сравнение двух массов.  Оба SyntheticRecord будут переиспользоваться для всех Record в артефакте. RecordTag объединяет все Record в рамках одного артефакта (приложения). R8 не позволит скомпилировать заранее созданный класс RecordTag. При попытке вручную создать такой класс в проекте мы получим ошибку компиляции Class content provided for type descriptor com.android.tools.r8.RecordTag actually defines class java.lang.Record] При необходимости создать jar/aar зависимость с Record классом его следует поставлять "как есть". RecordTag создается на этапе desugaring'а кода, в том числе полученного из зависимостей. Kotlin поддерживает создание Record классов с помощью аннотации @JvmRecord.  При этом компилятор сгенерирует микс из идиоматичного data-класса с методами copy() и componentN(), и наследника java.lang.Record с методами-геттерами без префикса get. Пометим аннотацией простой data-класс и посмотрим на фрагмент получившегося байт кода. Класс Person так же как User оказался уязвим для реверс-инжениринга, можно ожидать его минификация отработает аналогично.  На практике Person.toString() не будет минифицирован потому что в нем используется другой bootstrap-метод. При анализе кода R8 выполняет проверку какой именно bootstrap-метод используется. Если его название или сигнатура отличается от ожидаемого, то минификация пропускается. Сигнатура имеет значение так как R8 выполняет парсинг параметра ObjectMethods.bootstrap.names. В предыдущем случае этот параметр передавал маску со всеми названиями полей класса a;b. Маска для StringConcatFactory.makeConcatWithConstants с точки зрения R8 ничем не отличается от любой пользовательской строки в констант-пуле и поэтому не может быть изменена. После desugaring'а полученного класса вызов invokedynamic в методе toString() превратится в цепочку вызовов StringBuilder.append. Чем выше арность исходного типа тем более объемным получится class-файл. За создание метода $record$getFieldsAsObjects() в D8 отвечает отдельный генератор. Он срабатывает только если в Record был обнаружен ожидаемый invokedynamic. Для класса Person служебный метод и классы SyntheticRecord  не генерируются. Начиная с Android 8.0 (Api level 26) рантайм поддерживает инструкции invoke-polymorphic и invoke-custom, которые созданы заменить invokedynamic. В Android 14 (Api level 34) добавили не только java.lang.Record но и ObjectMethods.bootstrap. В процессе desugaring'а мы все это потеряли несмотря на то что компилировали код для последней версии платформы. Дело в том что D8 принимает решение об обработке Record классов без учета версии платформы. Иными словами desugaring работает всегда и в нем не предусмотрено ветки с использованием invokedynamic. Возможно это сделано для предотвращения дублирования классов, чтобы в classpath не попали одновременно java.lang.Record и com.android.tools.r8.RecordTag. В исходниках D8 я нашел внутреннюю опцию com.android.tools.r8.emitRecordAnnotationsInDex, которая вынуждает компилятор пропускать desugaring и генерировать полноценный Record класс. При ее передаче компилятор укажет над классом аннотацию dalvik.annotation.Record, инструкцию invokedynamic заменит на байткод invoke-custom и добавит необходимые таблицы. Примечательно что маркирующая класс аннотация не указана в документации, ее описание можно найти в исходниках.  При использовании опции emitRecordAnnotationsInDex ответственность за обратную совместимость ложится на плечи разработчика. Эта опция не документирована и рассчитана на то что в рантайме найдутся все нужные классы и инструкции. В этой статье я привел результаты своего мини-исследования о том как R8/D8 преобразуют Record классы при сборке Android приложения. Выяснилось что R8 может заменить константные части toString() благодаря анализу инструкции invokedynamic, которую генерирует javac. Kotlin создает немного другой байт-код. Kotlin Record классы не могут быть минифицированы в той же степени что Java. Для повышения обратной совместимости D8 заменяет динамическую реализацию toString(), equals() и hashCode() на обобщенную. В случае с Kotlin Record размер полученного байт-кода каждого из трех методов напрямую зависит от количества полей в классе. Для Java Record эта зависимость скрадывается за счет выделения специального метода-фабрики. Сейчас использование Record для описания моделей не несет значительных преимуществ. Может быть с распространением Android 14 и выше indify войдет в обиход компиляторов. Классы моделей станут более компактными и защищенными от реверс-инжениринга. При минификации (обфускации) кода не надо надеятся на "авось". Например использование кодогенерации для создания (де)сериализаторов моделей может нивелировать эффект от минификации их полей.  Android разработчик