В 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 войдет в обиход компиляторов. Классы моделей станут более компактными и защищенными от реверс-инжениринга. При минификации (обфускации) кода не надо надеятся на "авось". Например использование кодогенерации для создания (де)сериализаторов моделей может нивелировать эффект от минификации их полей.