Cómo llamar a código Kotlin desde Java

En este codelab, aprenderás a escribir o adaptar tu código Kotlin a fin de que se pueda llamar con mayor facilidad desde el código Java.

Qué aprenderás

  • Cómo usar @JvmField, @JvmStatic y otras anotaciones
  • Limitaciones del acceso a ciertas funciones del lenguaje Kotlin desde código Java

Información importante

Este codelab está escrito para programadores y supone conocimientos básicos de Java y Kotlin.

En este codelab, se simula la migración de una parte de un proyecto más grande escrito con el lenguaje de programación Java para incorporar un nuevo código Kotlin.

Para simplificar el proceso, habrá un único archivo .java llamado UseCase.java, que representará la base de código existente.

Imaginemos que acabamos de reemplazar algunas funciones escritas originalmente en Java por una nueva versión escrita en Kotlin y que es necesario terminar de integrarla.

Importa el proyecto

El código del proyecto se puede clonar desde el proyecto de GitHub aquí: GitHub

Como alternativa, puedes descargar y extraer el proyecto de un archivo ZIP que se encuentra aquí:

Download Zip

Si usas IntelliJ IDEA, selecciona Importar proyecto (Import Project).

Si usas Android Studio, selecciona "Import project (Gradle, Eclipse ADT, etc.)".

Abramos UseCase.java y comiencemos a corregir los errores que vemos.

La primera función con un problema es registerGuest:

public static User registerGuest(String name) {
   User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);
   Repository.addUser(guest);
   return guest;
}

Los errores para Repository.getNextGuestId() y Repository.addUser(...) son los mismos: no se puede acceder a una instancia no estática desde un contexto estático.

Ahora, veamos uno de los archivos Kotlin. Abre el archivo Repository.kt.

Vemos que nuestro repositorio es un singleton que se declara mediante el uso de la palabra clave del objeto. El problema es que Kotlin genera una instancia estática dentro de la clase, en lugar de exponerlas como propiedades y métodos estáticos.

Por ejemplo, se podría hacer referencia a Repository.getNextGuestId() mediante Repository.INSTANCE.getNextGuestId(), pero hay una mejor opción.

Podemos obtener Kotlin para generar métodos y propiedades estáticos mediante la anotación de las propiedades públicas y los métodos del repositorio con @JvmStatic:

object Repository {
   val BACKUP_PATH = "/backup/user.repo"

   private val _users = mutableListOf<User>()
   private var _nextGuestId = 1000

   @JvmStatic
   val users: List<User>
       get() = _users

   @JvmStatic
   val nextGuestId
       get() = _nextGuestId++

   init {
       _users.add(User(100, "josh", "Joshua Calvert", listOf("admin", "staff", "sys")))
       _users.add(User(101, "dahybi", "Dahybi Yadev", listOf("staff", "nodes")))
       _users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
       _users.add(User(103, "warlow", groups = listOf("staff", "inactive")))
   }

   @JvmStatic
   fun saveAs(path: String?):Boolean {
       val backupPath = path ?: return false

       val outputFile = File(backupPath)
       if (!outputFile.canWrite()) {
           throw FileNotFoundException("Could not write to file: $backupPath")
       }
       // Write data...
       return true
   }

   @JvmStatic
   fun addUser(user: User) {
       // Ensure the user isn't already in the collection.
       val existingUser = users.find { user.id == it.id }
       existingUser?.let { _users.remove(it) }
       // Add the user.
       _users.add(user)
   }
}

Agrega la anotación @JvmStatic a tu código usando tu IDE.

Si volvemos a UseCase.java, las propiedades y los métodos en Repository ya no causan errores, excepto Repository.BACKUP_PATH. Volveremos a hablar más adelante.

Por ahora, solucionemos el siguiente error en el método registerGuest().

Pensemos en la siguiente situación: tuvimos una clase StringUtils con varias funciones estáticas para operaciones de string. Cuando lo convirtiómos a Kotlin, convertimos los métodos en funciones de extensión. Java no tiene funciones de extensión, por lo que Kotlin compila estos métodos como funciones estáticas.

Lamentablemente, si analizamos el método registerGuest() dentro de UseCase.java, podemos ver que algo no es correcto:

User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);

Esto se debe a que Kotlin ubica estas funciones de nivel superior o de paquete dentro de una clase cuyo nombre se basa en el nombre del archivo. En este caso, debido a que el archivo se llama StringUtils.kt, la clase correspondiente se llama StringUtilsKt.

Podríamos cambiar todas nuestras referencias de StringUtils a StringUtilsKt y corregir este error, pero no es lo ideal porque:

  • Es posible que deban actualizarse muchos lugares en nuestro código.
  • El nombre en sí es raro.

Por lo tanto, en lugar de refactorizar nuestro código Java, actualizaremos nuestro código Kotlin a fin de usar un nombre diferente para estos métodos.

Abre StringUtils.Kt y busca la siguiente declaración del paquete:

package com.google.example.javafriendlykotlin

Podemos indicarle a Kotlin que use un nombre diferente para los métodos a nivel del paquete mediante la anotación @file:JvmName. Usemos esta anotación para asignarle el nombre StringUtils a la clase.

@file:JvmName("StringUtils")

package com.google.example.javafriendlykotlin

Ahora, si observamos los UseCase.java, podemos ver que se resolvió el error de StringUtils.nameToLogin().

Este error se reemplazó por uno nuevo sobre los parámetros que se pasan al constructor de User. Continuemos con el siguiente paso y corrijamos este último error en UseCase.registerGuest().

Kotlin admite valores predeterminados para parámetros. Podemos ver cómo se usan mirando dentro del bloque init de Repository.kt.

Repository.kt:

_users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
_users.add(User(103, "warlow", groups = listOf("staff", "inactive")))

Podemos ver que, para el usuario, podemos omitir la entrada de un valor para displayName porque existe un valor predeterminado especificado en User.kt.

User.kt:

data class User(
   val id: Int,
   val username: String,
   val displayName: String = username.toTitleCase(),
   val groups: List<String> = listOf("guest")
)

Desafortunadamente, esto no funciona igual cuando se llama al método desde Java.

UseCase.java:

User guest = new User(Repository.getNextGuestId(), StringUtils.nameToLogin(name), name);

Los valores predeterminados no son compatibles con el lenguaje de programación Java. Para solucionar este problema, hagamos que Kotlin genere sobrecargas para nuestro constructor con la anotación @JvmOverloads.

Primero, debemos hacer una pequeña actualización en User.kt.

Como la clase User solo tiene un constructor principal, y el constructor no incluye ninguna anotación, se omitió la palabra clave constructor. Sin embargo, ahora que queremos anotarla, debes incluir la palabra clave constructor:

data class User constructor(
    val id: Int,
    val username: String,
    val displayName: String = username.toTitleCase(),
    val groups: List<String> = listOf("guest")
)

Con la palabra clave constructor presente, podemos agregar la anotación @JvmOverloads:

data class User @JvmOverloads constructor(
    val id: Int,
    val username: String,
    val displayName: String = username.toTitleCase(),
    val groups: List<String> = listOf("guest")
)

Si volvemos a cambiar a UseCase.java, podemos ver que no hay más errores en la función registerGuest.

El próximo paso es corregir la llamada interrumpida a user.hasSystemAccess() en UseCase.getSystemUsers(). Continúa con el siguiente paso para continuar o continúa leyendo para obtener más detalles sobre lo que @JvmOverloads hizo a fin de corregir el error.

@JvmOverloads

Para comprender mejor lo que hace @JvmOverloads, creemos un método de prueba en UseCase.java:

private void testJvmOverloads() {
   User syrinx = new User(1001, "syrinx");
   User ione = new User(1002, "ione", "Ione Saldana");

   List<String> groups = new ArrayList<>();
   groups.add("staff");
   User beaulieu = new User(1002, "beaulieu", groups);
}

Podemos construir un objeto User con solo dos parámetros, id y username:

User syrinx = new User(1001, "syrinx");

También podemos crear un elemento User si incluyes un tercer parámetro para displayName, a la vez que usamos el valor predeterminado para groups:

User ione = new User(1002, "ione", "Ione Saldana");

Sin embargo, no es posible omitir displayName y solo debes proporcionar un valor para groups sin escribir código adicional:

Por lo tanto, borremos esa línea o la anteponga "//' para incluir un comentario.

En Kotlin, si queremos combinar parámetros predeterminados y no predeterminados, debemos usar parámetros con nombre.

// This doesn't work...
User(104, "warlow", listOf("staff", "inactive"))
// But using named parameters, it does...
User(104, "warlow", groups = listOf("staff", "inactive"))

El motivo es que Kotlin generará sobrecargas para las funciones, incluidos los constructores, pero solo creará una sobrecarga por parámetro con un valor predeterminado.

Analicemos UseCase.java y solucionemos nuestro siguiente problema: la llamada a user.hasSystemAccess() en el método UseCase.getSystemUsers():

public static List<User> getSystemUsers() {
   ArrayList<User> systemUsers = new ArrayList<>();
   for (User user : Repository.getUsers()) {
       if (user.hasSystemAccess()) {     // Now has an error!
           systemUsers.add(user);
       }
   }
   return systemUsers;
}

Este es un error interesante. Si usas la función Autocompletar de IDE en la clase User, notarás que hasSystemAccess() cambió el nombre a getHasSystemAccess().

A fin de solucionar el problema, queremos que Kotlin genere un nombre diferente para la propiedad val hasSystemAccess. Para hacerlo, podemos usar la anotación @JvmName. Volvamos a la User.kt y veamos dónde deberíamos aplicarla.

Hay dos formas de aplicar la anotación. La primera es aplicarla directamente al método get(), de la siguiente manera:

val hasSystemAccess
   @JvmName("hasSystemAccess")
   get() = "sys" in groups

Eso le indica a Kotlin que cambie la firma del método get definido de manera explícita por el nombre que se proporcionó.

Como alternativa, es posible aplicarla a la propiedad con un prefijo get: como el siguiente:

@get:JvmName("hasSystemAccess")
val hasSystemAccess
   get() = "sys" in groups

El método alternativo es particularmente útil para las propiedades que usan un método get predeterminado y definido de manera implícita. Por ejemplo:

@get:JvmName("isActive")
val active: Boolean

De esta manera, se puede cambiar el nombre del método get sin tener que definir explícitamente un método get.

A pesar de esta distinción, puedes usar la que te resulte más conveniente. Ambos harán que Kotlin cree un método get con el nombre hasSystemAccess().

Si volvemos a cambiar a UseCase.java, podemos verificar que getSystemUsers() ahora esté libre de errores.

El siguiente error se produce en el archivo formatUser(), pero si quieres obtener más información sobre la convención de nomenclatura de captadores de Kotlin, sigue leyendo antes de continuar con el siguiente paso.

Nombres de captadores y establecedores

Cuando se escribe Kotlin, es fácil olvidar ese código, como por ejemplo:

val myString = "Logged in as ${user.displayName}")

En realidad, se llama a una función para obtener el valor de displayName. Para verificarlo, ve a Tools > Kotlin > Show Kotlin Bytecode en el menú y haz clic en el botón Descompile:

String myString = "Logged in as " + user.getDisplayName();

Cuando queremos acceder a estos desde Java, debemos escribir explícitamente el nombre del método get.

En la mayoría de los casos, el nombre de Java de los captadores para las propiedades de Kotlin es simplemente get + el nombre de la propiedad, como se vio con User.getHasSystemAccess() y User.getDisplayName(). La única excepción son las propiedades cuyos nombres comienzan con &ist;is". En este caso, el nombre de Java para el método get es el nombre de la propiedad de Kotlin.

Por ejemplo, una propiedad en User, como la siguiente:

val isAdmin get() = //...

Se accedería desde Java con:

boolean userIsAnAdmin = user.isAdmin();

Cuando se usa la anotación @JvmName, Kotlin genera un código de bytes que tiene el nombre especificado en lugar del predeterminado para el elemento que se esté anotando.

Esto funciona igual para los métodos set, cuyos nombres generados siempre son set + nombre de la propiedad. Por ejemplo, toma la siguiente clase:

class Color {
   var red = 0f
   var green = 0f
   var blue = 0f
}

Imaginemos que queríamos cambiar el nombre del método set de setRed() a updateRed() y dejar solo los métodos get. Podríamos usar la versión @set:JvmName para hacerlo:

class Color {
   @set:JvmName("updateRed")
   var red = 0f
   @set:JvmName("updateGreen")
   var green = 0f
   @set:JvmName("updateBlue")
   var blue = 0f
}

Desde Java, luego se podrá escribir:

color.updateRed(0.8f);

UseCase.formatUser() usa el acceso directo a campos para obtener los valores de las propiedades de un objeto User.

En Kotlin, las propiedades suelen exponerse a través de métodos get y set. Esto incluye las propiedades de val.

Es posible cambiar este comportamiento con la anotación @JvmField. Cuando se aplica a una propiedad de una clase, Kotlin omite la generación de métodos get (y un método set para propiedades var), y se puede acceder directamente al campo de copia de seguridad.

Dado que los objetos User son inmutables, queremos exponer cada una de sus propiedades como campos, por lo que anotaremos cada uno de ellos con @JvmField:

data class User @JvmOverloads constructor(
   @JvmField val id: Int,
   @JvmField val username: String,
   @JvmField val displayName: String = username.toTitleCase(),
   @JvmField val groups: List<String> = listOf("guest")
) {
   @get:JvmName("hasSystemAccess")
   val hasSystemAccess
       get() = "sys" in groups
}

Si analizamos los elementos UseCase.formatUser() ahora, podemos ver que se corrigieron los errores.

@JvmField o const

Con eso, hay otro error similar en el archivo UseCase.java:

Repository.saveAs(Repository.BACKUP_PATH);

Si usamos la función de autocompletar aquí, podemos ver que hay una Repository.getBACKUP_PATH(), por lo que puede ser tentador cambiar la anotación de BACKUP_PATH de @JvmStatic a @JvmField.

Intentemos hacer esto. Vuelve a Repository.kt y actualiza la anotación:

object Repository {
   @JvmField
   val BACKUP_PATH = "/backup/user.repo"

Si observamos UseCase.java ahora, veremos que el error desapareció, pero también hay una nota en BACKUP_PATH:

En Kotlin, los únicos tipos que pueden ser const son primitivos, como int, float y String. En este caso, dado que BACKUP_PATH es una string, podemos obtener un mejor rendimiento si usamos const val en lugar de un val anotado con @JvmField, sin perder la capacidad de acceder al valor como un campo.

Cambiemos eso en Repository.kt:

object Repository {
   const val BACKUP_PATH = "/backup/user.repo"

Si observamos los datos de UseCase.java, podemos ver que queda solo un error.

El error final dice Exception: 'java.io.IOException' is never thrown in the corresponding try block.

Sin embargo, si observamos el código para Repository.saveAs en Repository.kt, vemos que arroja una excepción. ¿A qué se debe?

Java tiene el concepto de una "excepción comprobada". Son excepciones que se pueden recuperar, como que el usuario escriba un nombre de archivo por error o que la red no esté disponible temporalmente. Después de detectar una excepción verificada, el desarrollador puede proporcionar comentarios al usuario sobre cómo solucionar el problema.

Debido a que las excepciones verificadas se verifican en el momento de la compilación, las declaras en la firma del método:

public void openFile(File file) throws FileNotFoundException {
   // ...
}

Kotlin, por otro lado, no tiene excepciones verificadas, y eso es lo que genera el problema en este caso.

La solución es solicitarle a Kotlin que agregue el IOException que podría arrojarse a la firma de Repository.saveAs(), de modo que el código de bytes de JVM lo incluya como una excepción verificada.

Esto se hace con la anotación @Throws de Kotlin, que ayuda con la interoperabilidad de Java y Kotlin. En Kotlin, las excepciones se comportan de manera similar a Java, pero, a diferencia de Java, Kotlin solo tiene excepciones sin marcar. Por lo tanto, si quieres informar a tu código Java que una función de Kotlin arroja una excepción, debes usar la anotación @Throws a la firma de la función de Kotlin Cambiar a Repository.kt file y actualizar saveAs() para incluir la nueva anotación:

@JvmStatic
@Throws(IOException::class)
fun saveAs(path: String?) {
   val outputFile = File(path)
   if (!outputFile.canWrite()) {
       throw FileNotFoundException("Could not write to file: $path")
   }
   // Write data...
}

Con la anotación @Throws implementada, podemos ver que se corrigieron todos los errores del compilador en UseCase.java. ¡Hip, hip, hurra!

Es posible que te preguntes si tendrás que usar bloques try y catch cuando llames a saveAs() desde Kotlin ahora.

De ninguna manera. Recuerda que Kotlin no tiene excepciones verificadas y agregar @Throws a un método no cambia eso:

fun saveFromKotlin(path: String) {
   Repository.saveAs(path)
}

Igualmente resulta útil detectar excepciones cuando se pueden manejar, pero Kotlin no te obliga a manejarlas.

En este codelab, abarcamos los conceptos básicos para escribir código de Kotlin que también admite la escritura de código Java idiomático.

Hablamos sobre cómo podemos usar anotaciones para cambiar la forma en que Kotlin genera su código de bytes de JVM, por ejemplo:

  • @JvmStatic para generar miembros y métodos estáticos
  • @JvmOverloads a fin de generar métodos sobrecargados para las funciones que tienen valores predeterminados
  • @JvmName para cambiar el nombre de los métodos get y set.
  • @JvmField para exponer una propiedad directamente como un campo, en lugar de hacerlo a través de métodos get y set
  • @Throws para declarar excepciones verificadas.

El contenido final de nuestros archivos es el siguiente:

User.kt

data class User @JvmOverloads constructor(
   @JvmField val id: Int,
   @JvmField val username: String,
   @JvmField val displayName: String = username.toTitleCase(),
   @JvmField val groups: List<String> = listOf("guest")
) {
   val hasSystemAccess
       @JvmName("hasSystemAccess")
       get() = "sys" in groups
}

Repository.kt

object Repository {
   const val BACKUP_PATH = "/backup/user.repo"

   private val _users = mutableListOf<User>()
   private var _nextGuestId = 1000

   @JvmStatic
   val users: List<User>
       get() = _users

   @JvmStatic
   val nextGuestId
       get() = _nextGuestId++

   init {
       _users.add(User(100, "josh", "Joshua Calvert", listOf("admin", "staff", "sys")))
       _users.add(User(101, "dahybi", "Dahybi Yadev", listOf("staff", "nodes")))
       _users.add(User(102, "sarha", "Sarha Mitcham", listOf("admin", "staff", "sys")))
       _users.add(User(103, "warlow", groups = listOf("staff", "inactive")))
   }

   @JvmStatic
   @Throws(IOException::class)
   fun saveAs(path: String?):Boolean {
       val backupPath = path ?: return false

       val outputFile = File(backupPath)
       if (!outputFile.canWrite()) {
           throw FileNotFoundException("Could not write to file: $backupPath")
       }
       // Write data...
       return true
   }

   @JvmStatic
   fun addUser(user: User) {
       // Ensure the user isn't already in the collection.
       val existingUser = users.find { user.id == it.id }
       existingUser?.let { _users.remove(it) }
       // Add the user.
       _users.add(user)
   }
}

StringUtils.kt

@file:JvmName("StringUtils")

package com.google.example.javafriendlykotlin

fun String.toTitleCase(): String {
   if (isNullOrBlank()) {
       return this
   }

   return split(" ").map { word ->
       word.foldIndexed("") { index, working, char ->
           val nextChar = if (index == 0) char.toUpperCase() else char.toLowerCase()
           "$working$nextChar"
       }
   }.reduceIndexed { index, working, word ->
       if (index > 0) "$working $word" else word
   }
}

fun String.nameToLogin(): String {
   if (isNullOrBlank()) {
       return this
   }
   var working = ""
   toCharArray().forEach { char ->
       if (char.isLetterOrDigit()) {
           working += char.toLowerCase()
       } else if (char.isWhitespace() and !working.endsWith(".")) {
           working += "."
       }
   }
   return working
}