從 Java 呼叫 Kotlin 程式碼

在本程式碼研究室中,您將瞭解如何編寫或調整 Kotlin 程式碼,以便更順暢地透過 Java 程式碼呼叫。

課程內容

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

必備知識

本程式碼研究室適用於程式設計師,並假設您具備 Java 和 Kotlin 的基本知識。

本程式碼研究室會模擬將以 Java 程式設計語言編寫的大型專案部分內容遷移至 Kotlin,並納入新的 Kotlin 程式碼。

為求簡單,我們將使用名為 UseCase.java 的單一 .java 檔案,代表現有程式碼集。

假設我們剛以 Kotlin 編寫的新版本取代了原本以 Java 編寫的某些功能,現在需要完成整合。

匯入專案

您可以從 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 檔案。

我們發現 Repository 是以物件關鍵字宣告的單例模式。問題在於 Kotlin 會在類別內產生靜態執行個體,而不是將這些執行個體公開為靜態屬性和方法。

舉例來說,Repository.getNextGuestId() 可以使用 Repository.INSTANCE.getNextGuestId() 參照,但有更好的做法。

我們可以透過 @JvmStatic 註解 Repository 的公開屬性和方法,讓 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");

我們也可以在為 displayName 加入第三個參數時,建構 User,同時仍使用 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

如果屬性使用預設的隱含定義擷取器,這個替代方法就特別實用。例如:

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

這樣一來,您不必明確定義 getter,就能變更 getter 的名稱。

儘管有這項區別,您還是可以選擇自己覺得比較好的方式。這兩種做法都會導致 Kotlin 建立名為 hasSystemAccess() 的 Getter。

如果我們切換回 UseCase.java,即可確認 getSystemUsers() 現在沒有錯誤!

下一個錯誤位於 formatUser() 中,但如要進一步瞭解 Kotlin 擷取器命名慣例,請繼續閱讀本文,再進行下一個步驟。

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

設定器也是如此,產生的名稱一律為 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 中,只有原始型別 (例如 intfloatString) 可以是 const。在本例中,由於 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.kt 中的 Repository.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 函式會擲回例外狀況,您必須使用 @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
}