从 Java 调用 Kotlin 代码

在此 Codelab 中,您将学习如何编写或修改 Kotlin 代码,从而更加顺畅地在 Java 代码中调用 Kotlin 代码。

学习内容

  • 如何使用 @JvmField@JvmStatic 和其他注解。
  • 在通过 Java 代码访问某些 Kotlin 语言功能方面存在的限制。

学前须知

此 Codelab 是专为程序员编写的,因此我们假定您具备 Java 和 Kotlin 方面的基础知识。

此 Codelab 会模拟对一个使用 Java 编程语言编写的大型项目的部分内容进行迁移,以便与新的 Kotlin 代码整合的过程。

为简单起见,我们将使用一个名为 UseCase.java.java 文件,该文件将代表现有代码库。

我们将假定,我们刚刚将最初使用 Java 编写的部分功能替换为使用 Kotlin 编写的新版本,并且我们需要完成相关的集成。

导入项目

您可以访问以下网址以从 GitHub 项目复制项目代码:GitHub

或者,您也可以访问以下网址以下载 ZIP 归档文件并从中解压项目:

下载 Zip 文件

如果您使用的是 IntelliJ IDEA,请选择“Import Project”。

如果您使用的是 Android Studio,请选择“Import project (Gradle, Eclipse ADT, etc.)”。

请打开 UseCase.java,然后开始处理我们看到的错误。

第一个有问题的函数是 registerGuest

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

Repository.getNextGuestId()Repository.addUser(...) 的错误是相同的:“Non-static cannot be accessed from a static context”。

现在,我们要看一看其中一个 Kotlin 文件。打开文件 Repository.kt

可以看到,我们的代码库是通过使用对象关键字进行声明的单例。问题在于,Kotlin 会在类内部生成静态实例,而不是将其作为静态属性和方法公开。

例如,若要引用 Repository.getNextGuestId(),我们可以通过使用 Repository.INSTANCE.getNextGuestId() 来实现,但也可以采用更好的方式。

我们可以使用 @JvmStatic 为代码库的公共属性和方法添加注解,从而让 Kotlin 生成静态方法和属性:

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)
   }
}

使用 IDE 将 @JvmStatic 注解添加到代码中。

如果我们切换回 UseCase.javaRepository 上的属性和方法不会再导致错误,但 Repository.BACKUP_PATH 除外。我们稍后再介绍这一例外情况。

现在,我们要修正 registerGuest() 方法中的下一个错误。

让我们考虑以下场景:我们有一个 StringUtils 类,其中包含一些用于字符串操作的静态函数。将其转换为 Kotlin 时,我们将这些方法转换为了扩展函数。Java 没有扩展函数,因此 Kotlin 会将这些方法编译为静态函数。

遗憾的是,如果我们查看 UseCase.java 内的 registerGuest() 方法,就会发现情况不太对劲:

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

原因是 Kotlin 将这些“顶级”或软件包级函数放到一个类中,而这个类的名称是基于文件名的。在此示例中,由于该文件名为 StringUtils.kt,因此相应的类名为 StringUtilsKt

我们可以将所有对 StringUtils 的引用更改为 StringUtilsKt 并修改这个错误,但这并不是最理想的方式,原因在于:

  • 我们的代码中可能有很多地方都需要更新。
  • 名称本身就很别扭。

因此,我们不应该重构 Java 代码,而要将 Kotlin 代码更新为针对这些方法使用不同的名称。

打开 StringUtils.Kt,并找到以下软件包声明:

package com.google.example.javafriendlykotlin

我们可以使用 @file:JvmName 注解让 Kotlin 为软件包级方法使用不同的名称。我们要使用该注解为类 StringUtils 命名。

@file:JvmName("StringUtils")

package com.google.example.javafriendlykotlin

现在,如果重新查看 UseCase.java,就会发现 StringUtils.nameToLogin() 的错误已得到解决。

遗憾的是,随着这个问题得到解决,新的问题又冒出来了:传递到 User 的构造函数的参数出问题了。接下来,我们要执行下一步操作,并修正 UseCase.registerGuest() 中的最后一项错误。

Kotlin 支持参数的默认值。我们可以通过查看 Repository.ktinit 代码块的内容来了解默认值使用情况。

Repository.kt:

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

可以看到,对于用户“warlow”,我们可以跳过为 displayName 输入值的步骤,因为 User.kt 中为其指定了默认值。

User.kt:

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

遗憾的是,从 Java 调用该方法时,其工作原理有所不同。

UseCase.java:

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

Java 编程语言不支持默认值。为解决这个问题,我们要借助 @JvmOverloads 注解让 Kotlin 针对构造函数生成重载。

首先,我们必须对 User.kt 稍作更新。

由于 User 类只有一个主要构造函数,并且该构造函数不包含任何注解,因此系统忽略了 constructor 关键字。不过,既然我们要为其添加注解,就必须包含 constructor 关键字:

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

在存在 constructor 关键字的情况下,我们可以添加 @JvmOverloads 注解:

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

如果切换回 UseCase.java,就会发现 registerGuest 函数中已经没有任何错误了!

下一步要修复 UseCase.getSystemUsers() 中对 user.hasSystemAccess() 的异常调用。您可以继续执行下一步操作以实现该目的,也可以继续阅读以深入了解 @JvmOverloads 为修正错误而执行的操作。

@JvmOverloads

为了更好地了解 @JvmOverloads 的作用,我们要在 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);
}

我们只需使用 idusername 这两个参数即可构造一个 User

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

我们还可以通过另一种方法来构造 User,即包含针对 displayName 的第三个参数,同时仍然使用 groups 的默认值:

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

但是,要想跳过 displayName,只为 groups 提供一个值,而不编写其他代码,那是不可能的:

因此,我们要删除这一行代码,或在其前面添加“//”以将其注解掉。

在 Kotlin 中,如果要合并默认参数和非默认参数,我们需要使用命名参数。

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

这是因为,Kotlin 会为函数(包括构造函数)生成重载,但它只会为每个参数创建一个具有默认值的重载。

接下来,我们要回顾一下 UseCase.java,并解决下一个问题:在 UseCase.getSystemUsers() 方法中调用 user.hasSystemAccess()

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;
}

这个错误很有意思!如果您对类 User 使用 IDE 自动补全功能,就会发现 hasSystemAccess() 已重命名为 getHasSystemAccess()

为了解决这个问题,我们要让 Kotlin 为 val 属性 hasSystemAccess 生成另一个名称。为此,我们可以使用 @JvmName 注解。让我们切换回 User.kt,看看应该将这个注解应用到什么地方。

我们可以通过两种方式来应用这个注解。第一种是直接将其应用于 get() 方法,如下所示:

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

这会指示 Kotlin 将显式定义 getter 的签名更改为所提供的名称。

或者,您也可以使用 get: 前缀将其应用于相应属性,如下所示:

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

备用方法对于使用默认的隐式定义 getter 的属性特别有用。例如:

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

这样,您无需显式定义 getter,即可更改 getter 的名称。

虽然有上述区别,但您可以视需要使用任一方式。这两种方式都能让 Kotlin 创建名为 hasSystemAccess() 的 getter。

如果切换回 UseCase.java,即可验证 getSystemUsers() 现在已不存在任何错误!

下一个错误位于 formatUser() 中,但如果您想深入了解 Kotlin getter 命名惯例,请继续阅读本部分,而暂时不要执行下一步操作。

getter 和 setter 的命名

编写 Kotlin 代码时,很容易忽略的一点是,如果我们编写如下所示的代码:

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

这实际上会调用函数来获取 displayName 的值。若要验证这一点,我们可以在菜单中依次转到 Tools > Kotlin > Show Kotlin Bytecode,然后点击 Decompile 按钮:

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

如果要从 Java 访问这些元素,我们需要显式写出相应 getter 的名称。

在大多数情况下,Kotlin 属性的 getter 的 Java 名称就是 get + 属性名称,如 User.getHasSystemAccess()User.getDisplayName() 所示。唯一的例外情况是名称以“is”开头的属性。在此示例中,getter 的 Java 名称是 Kotlin 属性的名称。

例如,User 上的属性,例如:

val isAdmin get() = //...

可使用以下代码从 Java 进行访问:

boolean userIsAnAdmin = user.isAdmin();

通过使用 @JvmName 注解,Kotlin 会为带注解的项生成具有指定名称(而非默认名称)的字节码。

对于 setter 也是如此,其生成的名称始终是 set + 属性名称。例如,采用以下类:

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

假定我们要将 setter 的名称从 setRed() 更改为 updateRed(),而不更改 getter。我们可以使用 @set:JvmName 版本来实现这一目的:

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

然后,在 Java 中,我们即可编写以下代码:

color.updateRed(0.8f);

UseCase.formatUser() 使用直接字段访问来获取 User 对象的属性值。

在 Kotlin 中,属性通常通过 getter 和 setter 公开。其中包括 val 属性。

若要更改这个行为,可以使用 @JvmField 注解。如果将该注解应用于某个类中的某个属性,Kotlin 就会跳过生成 getter 方法的步骤(对于 var 属性,则跳过生成 setter 的步骤),并让后备字段可供直接访问。

由于 User 对象是不可变的,因此我们要将其所有属性都公开为字段,因此,我们会为每个属性分别添加 @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
}

现在,如果重新查看 UseCase.formatUser(),就会发现错误已得到解决!

@JvmField 还是 const

接着,UseCase.java 文件中还有一个类似的错误:

Repository.saveAs(Repository.BACKUP_PATH);

此时,如果我们使用自动补全功能,就会发现有一个 Repository.getBACKUP_PATH(),因此,您可能会想将 BACKUP_PATH 上的注解从 @JvmStatic 更改为 @JvmField

试试看吧。切换回 Repository.kt,然后更新注解:

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

现在,如果查看 UseCase.java,就会发现错误已消失,但 BACKUP_PATH 上还有一个备注:

在 Kotlin 中,只有 const 类型可以是基元,例如 intfloatString。在此示例中,由于 BACKUP_PATH 是字符串,因此,我们可以使用 const val(而不是带有 @JvmField 注解的 val)来获得更好的性能,同时保留将值作为字段进行访问的能力。

现在,我们要在 Repository.kt 中进行相关更改:

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

如果重新查看 UseCase.java,就会发现只剩一项错误了。

最后一项错误的内容为:Exception: 'java.io.IOException' is never thrown in the corresponding try block.

不过,如果查看 Repository.ktRepository.saveAs 的相关代码,就会发现它确实会抛出异常。这是怎么回事?

Java 中有“受检异常”的概念。受检异常是指可以恢复的异常,例如用户错误输入文件名,或网络暂时不可用。捕获受检异常后,开发者可以为用户提供关于如何解决相应问题的反馈。

由于系统会在编译时检查受检异常,因此,您要在相应方法的签名中声明这些异常:

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

另一方面,Kotlin 没有受检异常,这就是导致出现上述问题的原因。

解决方案是让 Kotlin 将可能抛出的 IOException 添加到 Repository.saveAs() 的签名,以便让 JVM 字节码将其作为受检异常进行添加。

为此,我们要使用 Kotlin @Throws 注解,该注解有助于 Java/Kotlin 的互操作性。在 Kotlin 中,异常的行为与 Java 类似,但与 Java 不同的是,Kotlin 只有不受检异常。因此,如果您要指示 Java 代码 Kotlin 函数会抛出异常,您需要对 Kotlin 函数签名使用 @Throws 注解。切换到 Repository.kt file,然后更新 saveAs() 以包含新注解:

@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...
}

添加 @Throws 注解后,就会发现 UseCase.java 中的编译器错误全都得到修正了!太棒了!

您可能想知道,现在从 Kotlin 调用 saveAs() 时,您是否必须使用 trycatch 块。

不需要!请记住,Kotlin 没有受检异常,而且向方法添加 @Throws 并不会改变这种情况:

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

在异常能够得到处理时,捕获异常仍是有用的,但 Kotlin 并不强制您处理异常。

在此 Codelab 中,我们介绍了如何编写 Kotlin 代码,同时还要让 Kotlin 代码支持编写惯用的 Java 代码。

我们讨论了如何使用注解来更改 Kotlin 生成 JVM 字节码的方式,例如:

  • @JvmStatic 用于生成静态成员和方法。
  • @JvmOverloads 用于为包含默认值的函数生成重载方法。
  • @JvmName 用于更改 getter 和 setter 的名称。
  • @JvmField 用于将属性直接作为字段公开,而不是通过 getter 和 setter 公开。
  • @Throws 用于声明受检异常。

我们的文件的最终内容为:

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
}