В 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
Про 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
Рассмотрим простой Record на языке Java
public record User(String name, int age) { }
Скомпилируем его для Java 17, ниже приведен отрывок получившегося байт-кода.
User.class
public final class User extends java.lang.Record minor version: 0 major version: 61 flags: (0x0031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER this_class: #8 // User super_class: #2 // java/lang/Record interfaces: 0, fields: 2, methods: 6, attributes: 4 Constant pool: #10 = Utf8 User #11 = Utf8 name #15 = Utf8 age #53 = Utf8 name;age public final java.lang.String toString(); descriptor: ()Ljava/lang/String; flags: (0x0011) ACC_PUBLIC, ACC_FINAL Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokedynamic #17, 0 // InvokeDynamic #0:toString:(LUser;)Ljava/lang/String; 6: areturn LineNumberTable: line 2: 0 LocalVariableTable: Start Length Slot Name Signature 0 7 0 this LUser; BootstrapMethods: 0: #45 REF_invokeStatic java/lang/runtime/ObjectMethods.bootstrap:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object; Method arguments: #10 User #53 name;age #54 REF_getField User.name:Ljava/lang/String; #55 REF_getField User.age:I
Метод toString() не содержит прямых инструкций для создания строкового представления экземпляра класса User. Тело метода генерируется динамически с помощью инструкции invokedynamic. Так достигается небольшой размер байт-кода, ленивость и динамичность во время выполнения. В констант-пуле содержатся оригинальные названия полей класса что делает его уязвимым к реверс-инженирингу.
Скомпилируем класс с помощью R8, сохранив его название для простоты анализа. Из приведенного байт-кода видно что принципиально реализация не изменилось. Однако компилятор распознал константу name;age и подставил вместо нее другую, с сокращенными названием полей a;b. Нумерация в новом констант-пуле отличается от нумерации в исходном.
public final class User extends java.lang.Record minor version: 0 major version: 61 flags: (0x0011) ACC_PUBLIC, ACC_FINAL this_class: #5 // User super_class: #7 // java/lang/Record interfaces: 0, fields: 2, methods: 4, attributes: 3 Constant pool: #1 = Utf8 ~~R8{ ... } #4 = Utf8 User #8 = Utf8 a #9 = Utf8 Ljava/lang/String; #10 = Utf8 b #11 = Utf8 I #23 = Utf8 a;b
Так как мы проводим эксперимент в Gradle проекте, то можем запустить код с полученным классом под Java 17 и убедиться что результат toString() минифицирован.
val user = User("turlir", 29) println(user.toString()) // User[a=turlir, b=29]
Мы выполнили минификацию класса, однако его все еще нельзя использовать на старых версиях Android. В них нет базового класса java.lang.Record и bootstrap-метода ObjectMethods.bootstrap. Проведем desugaring обфусцированного кода, пустим результат работы R8 на вход D8. Последний перепишет байт-код так, чтобы сделать его совместимым с Android. Результат снова представим как .class файл.
User.class
public final class User extends com.android.tools.r8.RecordTag minor version: 0 major version: 51 flags: (0x0031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER this_class: #5 // User super_class: #7 // com/android/tools/r8/RecordTag interfaces: 0, fields: 2, methods: 6, attributes: 1 private java.lang.Object[] $record$getFieldsAsObjects(); descriptor: ()[Ljava/lang/Object; flags: (0x1002) ACC_PRIVATE, ACC_SYNTHETIC Code: stack=16, locals=16, args_size=1 0: iconst_2 1: anewarray #24 // class java/lang/Object 4: astore_1 5: aload_1 6: iconst_0 7: aload_0 8: getfield #18 // Field a:Ljava/lang/String; 11: aastore 12: aload_1 13: iconst_1 14: aload_0 15: getfield #20 // Field b:I 18: invokestatic #44 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer; 21: aastore 22: aload_1 23: areturn public final java.lang.String toString(); descriptor: ()Ljava/lang/String; flags: (0x0011) ACC_PUBLIC, ACC_FINAL Code: stack=3, locals=1, args_size=1 0: aload_0 1: invokespecial #32 // Method $record$getFieldsAsObjects:()[Ljava/lang/Object; 4: ldc #5 // class User 6: ldc #48 // String a;b 8: invokestatic #54 // Method User$$ExternalSyntheticRecord0.m:([Ljava/lang/Object;Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/String; 11: areturn
Родительским стал синтетический класс 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
Kotlin поддерживает создание Record классов с помощью аннотации @JvmRecord. При этом компилятор сгенерирует микс из идиоматичного data-класса с методами copy() и componentN(), и наследника java.lang.Record с методами-геттерами без префикса get.
@JvmRecord data class Person( val name: String, val age: Int )
Пометим аннотацией простой data-класс и посмотрим на фрагмент получившегося байт кода. Класс Person так же как User оказался уязвим для реверс-инжениринга, можно ожидать его минификация отработает аналогично.
Person.class
public final class Person extends java.lang.Record minor version: 0 major version: 61 flags: (0x0031) ACC_PUBLIC, ACC_FINAL, ACC_SUPER this_class: #2 // Person super_class: #4 // java/lang/Record interfaces: 0, fields: 2, methods: 10, attributes: 4 Constant pool: #8 = Utf8 name #22 = Utf8 age #42 = String #41 // Person(name=\u0001, age=\u0001) public java.lang.String toString(); descriptor: ()Ljava/lang/String; flags: (0x0001) ACC_PUBLIC Code: stack=2, locals=1, args_size=1 0: aload_0 1: getfield #21 // Field name:Ljava/lang/String; 4: aload_0 5: getfield #25 // Field age:I 8: invokedynamic #52, 0 // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;I)Ljava/lang/String; 13: areturn BootstrapMethods: 0: #49 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite; Method arguments: #42 Person(name=\u0001, age=\u0001)
На практике 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 не генерируются.
Призрак indify
Начиная с 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 и добавит необходимые таблицы. Примечательно что маркирующая класс аннотация не указана в документации, ее описание можно найти в исходниках.
User.dex
Class #2 header: class_idx : 3 access_flags : 17 (0x0011) superclass_idx : 9 interfaces_off : 0 (0x000000) source_file_idx : 31 annotations_off : 2640 (0x000a50) class_data_off : 2561 (0x000a01) static_fields_size : 0 instance_fields_size: 2 direct_methods_size : 1 virtual_methods_size: 3 Class #2 annotations: Annotations on class VISIBILITY_SYSTEM Ldalvik/annotation/Record; componentNames={ "a" "b" } componentTypes={ Ljava/lang/String; I } Class #2 - Class descriptor : 'LUser;' Access flags : 0x0011 (PUBLIC FINAL) Superclass : 'Ljava/lang/Record;' Interfaces - Static fields - Instance fields - #0 : (in LUser;) name : 'a' type : 'Ljava/lang/String;' access : 0x0011 (PUBLIC FINAL) #1 : (in LUser;) name : 'b' type : 'I' access : 0x0011 (PUBLIC FINAL) Direct methods - #0 : (in LUser;) name : 'init' type : '(Ljava/lang/String;I)V' access : 0x10001 (PUBLIC CONSTRUCTOR) method_idx : 5 code - registers : 3 ins : 3 outs : 1 insns size : 8 16-bit code units 0005c8: |[0005c8] User.init:(Ljava/lang/String;I)V 0005d8: 7010 0b00 0000 |0000: invoke-direct {v0}, Ljava/lang/Record;.<init>:()V // method@000b 0005de: 5b01 0200 |0003: iput-object v1, v0, LUser;.a:Ljava/lang/String; // field@0002 0005e2: 5902 0300 |0005: iput v2, v0, LUser;.b:I // field@0003 0005e6: 0e00 |0007: return-void catches : (none) positions : locals : Virtual methods - #0 : (in LUser;) name : 'equals' type : '(Ljava/lang/Object;)Z' access : 0x0011 (PUBLIC FINAL) method_idx : 6 code - registers : 2 ins : 2 outs : 2 insns size : 5 16-bit code units 000574: |[000574] User.equals:(Ljava/lang/Object;)Z 000584: fc20 0000 1000 |0000: invoke-custom {v0, v1}, call_site@0000 00058a: 0a01 |0003: move-result v1 00058c: 0f01 |0004: return v1 catches : (none) positions : locals : #1 : (in LUser;) name : 'hashCode' type : '()I' access : 0x0011 (PUBLIC FINAL) method_idx : 7 code - registers : 2 ins : 1 outs : 1 insns size : 5 16-bit code units 000590: |[000590] User.hashCode:()I 0005a0: fc10 0100 0100 |0000: invoke-custom {v1}, call_site@0001 0005a6: 0a00 |0003: move-result v0 0005a8: 0f00 |0004: return v0 catches : (none) positions : locals : #2 : (in LUser;) name : 'toString' type : '()Ljava/lang/String;' access : 0x0011 (PUBLIC FINAL) method_idx : 8 code - registers : 2 ins : 1 outs : 1 insns size : 5 16-bit code units 0005ac: |[0005ac] User.toString:()Ljava/lang/String; 0005bc: fc10 0200 0100 |0000: invoke-custom {v1}, call_site@0002 0005c2: 0c00 |0003: move-result-object v0 0005c4: 1100 |0004: return-object v0 catches : (none) positions : locals : source_file_idx : 31 (SourceFile) Method handle #0: type : get-instance target : LUser; a target_type : (LUser;java/lang/String; Method handle #1: type : get-instance target : LUser; b target_type : (LUser; Method handle #2: type : invoke-static target : Ljava/lang/runtime/ObjectMethods; bootstrap target_type : (Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object; Call site #0: // offset 2587 link_argument[0] : 2 (MethodHandle) link_argument[1] : equals (String) link_argument[2] : (LUser;Ljava/lang/Object;)Z (MethodType) link_argument[3] : LUser; (Class) link_argument[4] : name;age (String) link_argument[5] : 0 (MethodHandle) link_argument[6] : 1 (MethodHandle) Call site #1: // offset 2602 link_argument[0] : 2 (MethodHandle) link_argument[1] : hashCode (String) link_argument[2] : (LUser;)I (MethodType) link_argument[3] : LUser; (Class) link_argument[4] : name;age (String) link_argument[5] : 0 (MethodHandle) link_argument[6] : 1 (MethodHandle) Call site #2: // offset 2617 link_argument[0] : 2 (MethodHandle) link_argument[1] : toString (String) link_argument[2] : (LUser;)Ljava/lang/String; (MethodType) link_argument[3] : LUser; (Class) link_argument[4] : name;age (String) link_argument[5] : 0 (MethodHandle) link_argument[6] : 1 (MethodHandle)
При использовании опции 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 войдет в обиход компиляторов. Классы моделей станут более компактными и защищенными от реверс-инжениринга. При минификации (обфускации) кода не надо надеятся на "авось". Например использование кодогенерации для создания (де)сериализаторов моделей может нивелировать эффект от минификации их полей.
