Android Developers Blog
The latest Android and Google Play news for app and game developers.
🔍
Platform Android Studio Google Play Jetpack Kotlin Docs News

28 kesäkuuta 2023

Records in Android Studio Flamingo


Link copied to clipboard
Posted by Clément Béra, Senior software engineer

Records are a new Java feature for immutable data carrier classes introduced in Java 16 and Android 14. To use records in Android Studio Flamingo, you need an Android 14 (API level 34) SDK so the java.lang.Record class is in android.jar. This is available from the "Android UpsideDownCake Preview" SDK revision 4. Records are essentially classes with immutable properties and implicit hashCode, equals, and toString methods based on the underlying data fields. In that respect they are very similar to Kotlin data classes. To declare a Person record with the fields String name and int age to be compiled to a Java record, use the following code:

@JvmRecord
data class Person(val name: String, val age: Int)

The build.gradle file also needs to be extended to use the correct SDK and Java source and target. Currently the Android UpsideDownCake Preview is required, but when the Android 14 final SDK is released use "compileSdk 34" and "targetSdk 34" in place of the preview version.

android {
  compileSdkPreview "UpsideDownCake"

  defaultConfig {
    targetSdkPreview "UpsideDownCake"
  }

  compileOptions {
    sourceCompatibility JavaVersion.VERSION_17
    targetCompatibility JavaVersion.VERSION_17
  }
  kotlinOptions {
    jvmTarget = '17'
  }
}

Records don’t necessarily bring value compared to data classes in pure Kotlin programs, but they let Kotlin programs interact with Java libraries whose APIs include records. For Java programmers this allows Java code to use records. Use the following code to declare the same record in Java:

public record Person(String name, int age) {} 

Besides the record flags and attributes, the record Person is roughly equivalent to the following class described using Kotlin source:

class PersonEquivalent(val name: String, val age: Int) {

  override fun hashCode() : Int {
      return 31 
         * (31 * PersonEquivalent::class.hashCode() 
            + name.hashCode()) 
            + Integer.hashCode(age)
  }

  override fun equals(other: Any?) : Boolean {
    if (other == null || other !is PersonEquivalent) {
      return false
    }
    return name == other.name && age == other.age
  }

  override fun toString() : String {
    return String.format(
      PersonEquivalent::class.java.simpleName + "[name=%s, age=%s]",
      name,
      age.toString()
    )
  }
}

println(Person("John", 42).toString())
>>> Person[name=John, age=42]

It is possible in a record class to override the hashCode, equals, and toString methods, effectively replacing the JVM runtime generated methods. In this case, the behavior is user-defined for these methods.

Record desugaring

Since records are not supported on any Android device today, the D8/R8 desugaring engine needs to desugar records: it transforms the record code into code compatible with the Android VMs. Record desugaring involves transforming the record into a roughly equivalent class, without generating or compiling sources. The following Kotlin source shows an approximation of the generated code. For the application code size to remain small, records are desugared so that helper methods are shared in between records.

class PersonDesugared(val name: String, val age: Int) {
  fun getFieldsAsObjects(): Array<Any> {
    return arrayOf(name, age)
  }

  override fun hashCode(): Int {
    return SharedRecordHelper.hash(
      PersonDesugared::class.java,
      getFieldsAsObjects())
  }

  override fun equals(other: Any?): Boolean {
    if (other == null || other !is PersonDesugared) {
      return false
    }
    return getFieldsAsObjects().contentEquals(other.getFieldsAsObjects())
  }

  override fun toString(): String {
    return SharedRecordHelper.toString(
      getFieldsAsObjects(),
      PersonDesugared::class.java,
      "name;age")
  }

  // The SharedRecordHelper is present once in each app using records and its 
  // methods are shared in between all records.
  class SharedRecordHelper {
    companion object {
      fun hash(recordClass: Class<*>, fieldValues: Array<Any>): Int {
        return 31 * recordClass.hashCode() + fieldValues.contentHashCode()
      }

      fun toString(
        fieldValues: Array<Any>,
        recordClass: Class<*>,
        fieldNames: String
      ): String {
        val fieldNamesSplit: List<String> =
          if (fieldNames.isEmpty()) emptyList() else fieldNames.split(";")
        val builder: StringBuilder = StringBuilder()
        builder.append(recordClass.simpleName).append("[")
        for (i in fieldNamesSplit.indices) {
          builder
            .append(fieldNamesSplit[i])
            .append("=")
            .append(fieldValues[i])
          if (i != fieldNamesSplit.size - 1) {
            builder.append(", ")
          }
        }
        builder.append("]")
        return builder.toString()
      }
    }
  }
  }

Record shrinking

R8 assumes that the default hashCode, equals, and toString methods generated by javac effectively represent the internal state of the record. Therefore, if a field is minified, the methods should reflect that; toString should print the minified name. If a field is removed, for example because it has a constant value across all instances, then the methods should reflect that; the field is ignored by the hashCode, equals, and toString methods. When R8 uses the record structure in the methods generated by javac, for example when it looks up fields in the record or inspects the printed record structure, it's using reflection. As is the case for any use of reflection, you must write keep rules to inform the shrinker of the reflective use so that it can preserve the structure.

In our example, assume that age is the constant 42 across the application while name isn’t constant across the application. Then toString returns different results depending on the rules you set:

Person("John", 42).toString();
// With D8 or R8 with -dontobfuscate -dontoptimize
>>> Person[name=John, age=42]
// With R8 and no keep rule.
>>> a[a=John]
// With R8 and -keep,allowshrinking,allowoptimization class Person
>>> Person[b=John]
// With R8 and -keepclassmembers,allowshrinking,allowoptimization class Person { <fields>; }
>>> a[name=John]
// With R8 and -keepclassmembers,allowobfuscation class Person { <fields>; }
>>> a[a=John, b=42]
// With R8 and -keep class Person { <fields>; }
>>> Person[name=John, age=42]
Reflective use cases

Preserve toString behavior

Say you have code that uses the exact printing of the record and expects it to be unchanged. For that you must keep the full content of the record fields with a rule such as:

-keep,allowshrinking class Person
-keepclassmembers,allowoptimization class Person { <fields>; }

This ensures that if the Person record is retained in the output, any toString callproduces the exact same string as it would in the original program. For example:

Person("John", 42).toString();
>>> Person[name=John, age=42]

However, if you only want to preserve the printing for the fields that are actually used, you can let the unused fields to be removed or shrunk with allowshrinking:

-keep,allowshrinking class Person
-keepclassmembers,allowshrinking,allowoptimization class Person { <fields>; }

With this rule, the compiler drops the age field:

Person("John", 42).toString();
>>> Person[name=John]

Preserve record members for reflective lookup

If you need to reflectively access a record member, you typically need to access its accessor method. For that you must keep the accessor method:

-keep,allowshrinking class Person
-keepclassmembers,allowoptimization class Person { java.lang.String name(); }

Now if instances of Person are in the residual program you can safely look up the existence of the accessor reflectively:

Person("John", 42)::class.java.getDeclaredMethod("name").invoke(obj);
>>> John

Notice that the previous code accesses the record field using the accessor. For direct field access, you need to keep the field itself:

-keep,allowshrinking class Person
-keepclassmembers,allowoptimization class Person { java.lang.String name; }

Build systems and the Record class

If you’re using another build system than AGP, using records may require you to adapt the build system. The java.lang.Record class is not present until Android 14, introduced in the SDK from "Android UpsideDownCake Preview" revision 4. D8/R8 introduces the com.android.tools.r8.RecordTag, an empty class, to indicate that a record subclass is a record. The RecordTag is used so that instructions referencing java.lang.Record can directly be rewritten by desugaring to reference RecordTag and still work (instanceof, method and field signatures, etc.).

This means that each build containing a reference to java.lang.Record generates a synthetic RecordTag class. In a situation where an application is split in shards, each shard being compiled to a dex file, and the dex files put together without merging in the Android application, this could lead to duplicate RecordTag class.

To avoid the issue, any D8 intermediate build generates the RecordTag class as a global synthetic, in a different output than the dex file. The dex merge step is then able to correctly merge global synthetics to avoid unexpected runtime behavior. Each build system using multiple compilation such as sharding or intermediate outputs is required to support global synthetics to work correctly. AGP fully supports records from version 8.1.