從 Java 呼叫 Kotlin 程式碼

在這個程式碼研究室中,您將學習如何編寫或調整 Kotlin 程式碼,讓這些程式碼能透過 Java 程式碼順利呼叫。

您將會瞭解的內容

  • 如何使用 @JvmField@JvmStatic 和其他註解。
  • 從 Java 程式碼存取特定 Kotlin 語言功能的限制。

注意事項

這個程式碼研究室是專為程式設計人員編寫,並具備基本的 Java 和 Kotlin 知識。

這個程式碼研究室會模擬遷移採用 Java 程式設計語言撰寫的大型專案,以納入新的 Kotlin 程式碼。

為了簡化作業,我們會建立一個名為 UseCase.java 的單一 .java 檔案,代表現有的程式碼集。

我們以 Kotlin 編寫的新版本來重新編寫了原本以 Java 編寫的某些功能,而必須整合所有功能。

匯入專案

您可以從這裡的 GitHub 專案複製專案的程式碼:GitHub

或者,您也可以從這裡的 ZIP 封存檔下載及擷取專案:

下載 Zip

如果您使用的是 IntelliJ IDEA,請選取 [匯入專案]。

如果您使用的是 Android Studio,請選取 [匯入專案 (Gradle、Eclipse ADT 等)]。

讓我們開啟「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(...) 的錯誤訊息都相同:「&tt 無法從靜態內容存取。」

現在讓我們來看看其中一個 Kotlin 檔案。開啟檔案 Repository.kt

我們發現,我們使用物件關鍵字來宣告我們的存放區。這個問題的原因是 Kotlin 是在我們的類別中產生靜態執行個體,而不是以靜態屬性和方法的形式來公開。

舉例來說,您可以透過 Repository.INSTANCE.getNextGuestId() 參照 Repository.getNextGuestId(),不過這樣是更妥善的方式。

我們可透過 Kotlin 產生靜態方法和屬性,方法是使用 @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)
   }
}

使用 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")))

可以看到,針對使用者「暖」,我們可以跳過 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 的值,無需編寫額外的程式碼:

因此,請刪除該行,或在前面加上「//&#39」符號,即可將其加上註解。

在 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 的值。如要驗證這一點,請前往選單中的 [工具 > Kotlin > [顯示 Kotlin Bytecode],然後按一下 [解譯] 按鈕:

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 會為已加註的項目產生具有指定名稱的位元組碼,而非預設的註解。

這個做法與設定者相同,其產生的名稱一律為 set + 屬性名稱。例如,請執行下列類別:

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

我們假設你想將成名者名稱從「setRed()」改成「updateRed()」,但只保留客戶開發者名稱。我們可以使用 @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 而非 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.kt 中發現 Repository.saveAs 的程式碼,發現該程式碼確實符合例外狀況。這是怎麼回事?

Java 的概念為「檢查例外狀況」。以下列舉幾種可能可以還原的例外情況,例如使用者輸入錯誤的檔案名稱,或是網路暫時無法使用。系統完成檢查後,開發人員可為使用者提供意見回饋,並提供修正問題的方法。

由於已勾選例外狀況會在編譯時間檢查,因此您必須在方法的簽名中宣告這些例外狀況:

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

而 Kotlin 並沒有檢查例外狀況,這就是問題發生的原因。

解決方法是要求 Kotlin 將可能導入至 Repository.saveAs() 簽名的 IOException,讓 JVM 位元組程式碼納入檢查中做為檢查的例外情況。

我們會透過 Kotlin @Throws 註解執行此作業,因為該註解有助於 Java/Kotlin 互通性。在 Kotlin 中,例外情況與 Java 類似,但 Java 與 Java 不同,只不過沒有任何例外。因此,如果您要通知 Java 程式碼,Kotlin 函式會擲回例外狀況,您需要使用 @Throws 註解至 Kotlin 函式簽名 切換至 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 不會強制您處理這些錯誤。

在本程式碼研究室中,我們說明瞭撰寫 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
}