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

Комментарии (0)