在本程式碼研究室中,您將瞭解如何編寫或調整 Kotlin 程式碼,以便更順暢地透過 Java 程式碼呼叫。
課程內容
- 如何運用
@JvmField
、@JvmStatic
和其他註解。 - 從 Java 程式碼存取特定 Kotlin 語言功能時的限制。
必備知識
本程式碼研究室適用於程式設計師,並假設您具備 Java 和 Kotlin 的基本知識。
本程式碼研究室會模擬將以 Java 程式設計語言編寫的大型專案部分內容遷移至 Kotlin,並納入新的 Kotlin 程式碼。
為求簡單,我們將使用名為 UseCase.java
的單一 .java
檔案,代表現有程式碼集。
假設我們剛以 Kotlin 編寫的新版本取代了原本以 Java 編寫的某些功能,現在需要完成整合。
匯入專案
您可以從 GitHub 專案複製專案程式碼:GitHub
或者,您也可以從這個位置下載並解壓縮 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.java
,Repository
上的屬性和方法就不會再導致錯誤,但 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.kt
的 init
區塊,瞭解這些函式的使用方式。
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);
}
我們可以使用兩個參數 (id
和 username
) 建構 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 中,只有原始型別 (例如 int
、float
和 String
) 可以是 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()
時,是否需要使用 try
和 catch
區塊。
不對!請注意,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
}