正確使用Kotlin註解,兼容Java代碼
大多數情況下,你不需要關注這個問題。但是,如果你的代碼中包含了部分Java代碼,理解這些註解將幫助你解決很多棘手問題。
產生這個問題的根本原因在於:Kotlin語言與Java語言的設計思路不同,部分特性屬於Java語言獨有,例如靜態變量。部分特性屬於Kotlin語言獨有,例如逆變和協變。
爲了抹平這些差異,Kotlin語言提供了一個絕佳的思路,通過添加註解可以改變Kotlin編譯器生成的Java字節碼,使之按照Java語言可以理解的方向進行,從而實現兼容。
問題答疑:Kotlin語言與Java字節碼有什麼關係?爲什麼Kotlin編譯器會生成Java字節碼?
不管是Kotlin語言還是Java語言都是建立在JVM平臺上面的編程語言,其最終都需要編譯成JVM可以識別的Java字節碼才能被正確執行。這也是爲什麼Kotlin語言與Java可以完全互通的原因之一,不要將Java與Java平臺混爲一談。
接下來我們先來看第一個註解,也是最常用到的一個註解:
@JvmField
Kotlin編譯器默認會將類中聲明的成員變量編譯成私有變量,Java語言要訪問該變量必須通過其生成的getter方法。而使用上面的註解可以向Java暴露該變量,即使其訪問變爲公開(修飾符變爲public)。
我們來做一個實驗:
1)新建Person.kt
,添加如下代碼:
class Person {
@JvmField
var name: String? = null
}
2)新建Client.java
,添加如下代碼,嘗試訪問Person
類中的變量name
:
public final class Person {
private String name;
public final String getName() {
return this.name;
}
public final void setName(@Nullable String var1) {
this.name = var1;
}
}
在添加@JvmField
屬性前我們試圖通過p.name
的方式進行訪問,編譯器出現報錯。因爲,默認生成的成員變量name
是私有的。而添加該註解之後我們居然可以正常訪問了。
由此可見,@JvmField
註解的確使生成的字節碼發生了變化,我們將字節碼用Java代碼來表示,具體發生的變化類似下面代碼發生的變化:
添加註解之前
public class Client {
public static void main(String[] args) {
Person p = new Person();
// 在添加@JvmField註解之前,這樣訪問會報錯
// 只能通過p.getName()的方式進行訪問
String name = p.name;
}
}
添加註解之後
public final class Person {
public String name;
}
以上場景是將@JvmField
註解添加到普通變量上方,如果添加到伴隨對象的成員變量上方,會發生什麼呢?我們來試試看:
class Person {
var name: String? = null
companion object {
@JvmField
val GENDER_MALE = 1
}
}
public static void main(String[] args) {
// 未添加之前
// int gender = Person.Companion.getGENDER_MALE();
// 添加之後,可直接訪問
int gender = Person.GENDER_MALE;
System.out.println(gender);
}
同樣地,添加註解之後我們可以通過點語法直接對其進行訪問。
由此可見,@JvmField
註解會使伴隨對象在伴生類中生成靜態成員變量,通過伴生類可直接對其進行訪問。
結論
@JvmField
註解可改變字節碼的生成,其作用的目標是類成員變量或伴隨對象成員變量。作用在類成員中可使該變量對外暴露,通過點語法直接訪問。即將私有成員變量公有化(public),並去掉setter/getter方法。作用在伴隨對象成員變量中,可以使該伴隨對象中的變量生成在伴生對象中,成爲伴生對象的公有靜態成員變量,通過伴生類可直接訪問。
那麼問題來了,如果該註解作用在私有成員變量上方會發生什麼呢?請大家自行驗證。
@JvmStatic
這個註解與@JvmField
非常容易出現混淆,兩者都可以作用在伴隨對象成員變量上方,我們來試試看,如果同樣作用在伴隨對象成員變量中,會出現什麼情況。
添加@JvmField
註解的效果,上面我們已經看到了,我們直接將註解修改爲@JvmStatic
試試看:
class Person {
var name: String? = null
companion object {
@JvmStatic
val GENDER_MALE = 1
}
}
public static void main(String[] args) {
// 1) 這樣訪問報錯
int gender = Person.GENDER_MALE;
// 2) 這樣訪問正常
int gender = Person.Companion.getGENDER_MALE();
// 3) 這樣訪問也正常
int gender = Person.getGENDER_MALE();
System.out.println(gender);
}
切換到Java代碼,你可以看到,我一共提供了三種訪問方式。第一種訪問方式是通過點語法直接訪問,編譯器報錯,由此可見,@JvmStatic
註解並沒有在伴生類中生成靜態的公有成員變量。第三種方式可以正常訪問,證明該註解在伴生類中生成了靜態的公有getter方法。第二種方式可以正常訪問,證明該註解不會破壞伴隨對象中原有成員的訪問方式。
由此,我們可以大膽猜測,@JvmStatic
註解的作用應該是生成靜態的setter/getter方法,而不會改變屬性(成員變量)的訪問權限。
爲了進一步驗證我們的猜想,我們將val
修改爲var
試試看。
public static void main(String[] args) {
// 1) 這樣訪問報錯
int gender = Person.GENDER_MALE;
// 2) 這樣訪問正常
int gender = Person.Companion.getGENDER_MALE();
// 3) 這樣訪問也正常
int gender = Person.getGENDER_MALE();
// 4) 以下訪問正常
Person.setGENDER_MALE(1);
System.out.println(gender);
}
第四種方式調用正常,證明我們的猜測沒有錯,@JvmStatic
僅會改變伴隨對象或對象(object)中setter/getter方法的生成方式,而不會改變屬性訪問權限,這是與註解@JvmField
的本質區別。
注意:由於@JvmField
不僅會改變屬性的訪問權限,同時也會改變setter/getter方法的生成,細心的同學應該已經注意到了。一旦添加了@JvmField
註解,setter/getter方法也消失了(變量可以通過點語法直接訪問,setter/getter方法也就沒必要存在了)。而@JvmStatic
僅僅是使setter/getter方法變爲靜態方法,同時生成位置放置到伴生類中。這與@JvmField
的處理方式有些衝突(@JvmField
會直接刪除掉setter/getter方法)。爲了避免衝突,Kotlin語言禁止將這兩個註解混淆使用。
以上是將@JvmStatic
與@JvmField
作用在伴隨對象成員變量上的區別。實際上,@JvmStatic
不僅可以修飾屬性(成員變量),還可以修飾方法,修飾方法的作用與修飾屬性的作用一致,都是將方法變成靜態類型。
爲了更直觀地表示兩種的區別,我們用一個表格完整展示兩個註解的區別:
註解 | 作用位置 | 作用 |
---|---|---|
@JvmField |
類屬性或對象屬性 | 使屬性修飾符成爲public |
@JvmStatic |
對象方法(包括伴生對象) | 使用方法成爲靜態類型,如果作用在伴生對象方法中,其方法會成爲伴生類的靜態方法 |
@JvmName
這個註解可以改變字節碼中生成的類名或方法名稱,如果作用在頂級作用域(文件中),則會改變生成對應Java類的名稱。如果作用在方法上,則會改變生成對應Java方法的名稱。
Test.kt
@file:JvmName("FooKt")
@JvmName("foo1")
fun foo() {
println("Hello, Jvm...")
}
在Kotlin語言中,foo
是一個全局方法,爲了兼容Java字節碼,實際會根據文件名生成對應的Java類TestKt.java
,這是Kotlin編譯器的一個隱藏規則。
而添加了上述註解之後,生成的類名與方法名均發生了變化,具體產生的變化相當於下面這段Java代碼:
// 相當於下面的Java代碼
public final class FooKt {
public static final void foo1() {
String var0 = "Hello, Jvm...";
System.out.println(var0);
}
}
可以看到第一個註解@file:JvmName("FooKt")
的作用是使生成的類名變爲FooKt
,第二個註解的作用是使生成的方法名稱變爲foo1
。
注意:該註解不能改變類中生成的屬性(成員變量)的名稱。
這裏的註解中,我們看到了一個特殊的前綴@file:
,這個註解前綴是Kotlin語言特有的一種標識,其作用是標記該註解最終會作用在生成的字節碼的具體位置(屬性、setter、getter等),關於這個部分,大家可以先跳過,下一篇文章將給大家詳細講解。
@JvmMultifileClass
說完了上面這個註解,就不得不提到@JvmMultifileClass
這個註解,這個註解通常是與@JvmName
結合使用的。其使用場景比較單一,看下面的例子:
新建文件Util1.kt,添加如下代碼:
@file:JvmName("Utils")
fun isEmpty(str: String?): Boolean {
return null == str || str.length <= 0
}
新建文件Util2.kt,添加如下代碼:
@file:JvmName("Utils")
fun isPhoneNumber(str: String): Boolean {
return str.startsWith("1") && str.length == 11
}
編譯以上代碼,Kotlin編譯器會提示錯誤Error:(1, 1) Kotlin: Duplicate JVM class name 'Utils' generated from: package-fragment, package-fragment
,即生成的類名出現了重複。可是,如果我們就是希望聲明使用多個文件,但方法生成到同一個類中呢?@JvmMultifileClass
就是爲解決這個問題而生的。
我們在上面代碼的基礎上分別添加註解@JvmMultifileClass
試試看:
@file:JvmName("Utils")
@file:JvmMultifileClass
fun isEmpty(str: String?): Boolean {
return null == str || str.length <= 0
}
@file:JvmName("Utils")
@file:JvmMultifileClass
fun isPhoneNumber(str: String): Boolean {
return str.startsWith("1") && str.length == 11
}
添加註解@JvmMultifileClass
之後,報錯消失了,反編譯生成的字節碼,我們發生兩個不同文件中的方法合併到了同一個類Utils
中:
// 生成的代碼相當於下面這段Java代碼
public final class Utils {
public static final boolean isEmpty(@Nullable String str) {
return Utils__A1Kt.isEmpty(str);
}
public static final boolean isPhoneNumber(@NotNull String str) {
return Utils__A2Kt.isPhoneNumber(str);
}
}
這個註解在處理多個文件聲明,合併到一個類的場景中發揮着舉足輕重的作用。如果你有這樣的需求,一定要謹記這個註解。
@JvmOverloads
由於Kotlin語言支持方法參數默認值,而實現類似功能Java需要使用方法重載來實現,這個註解就是爲解決這個問題而生的,添加這個註解會自動生成重載方法。我們來試一下:
@JvmOverloads
fun foo(x: Int, y: Int = 0, z: Int = 0): Int {
return x + y + z
}
// 生成的代碼相當於下面這段Java代碼
public static final int foo(int x, int y, int z) {
return x + y + z;
}
public static final int foo(int x, int y) {
return foo(x, y, 0);
}
public static final int foo(int x) {
return foo(x, 0, 0);
}
由此可見,通過這個註解可以影響帶有參數默認值方法的生成,添加該註解將自動生成帶有默認值參數數量的重載方法。這是一個非常有用的特性,方便Java端可以更高效地調用Kotlin端代碼。
@Throws
由於Kotlin語言不支持CE(Checked Exception),所謂CE,即方法可能拋出的異常是已知的。Java語言通過throws
關鍵字在方法上聲明CE。爲了兼容這種寫法,Kotlin語言新增了@Throws
註解,該註解的接收一個可變參數,參數類型是多個異常的KClass實例。Kotlin編譯器通過讀取註解參數,在生成的字節碼中自動添加CE聲明。
爲了便於理解,看一個簡單的例子:
@Throws(IllegalArgumentException::class)
fun div(x: Int, y: Int): Float {
return x.toFloat() / y
}
// 生成的代碼相當於下面這段Java代碼
public static final float div(int x, int y) throws IllegalArgumentException {
return (float)x / (float)y;
}
可以看到,添加了@Throws(IllegalArgumentException::class)
註解後,在生成的方法簽名上自動添加了可能拋出的異常聲明(throws IllegalArgumentException),即CE。
這個註解在保證邏輯的嚴謹性方面非常有用,但如果你的工程中僅使用Kotlin代碼,可以不用理會該註解。在Kotlin語言的設計哲學裏面,CE被認爲是一個錯誤的設計。
@Synchronized
這個註解很容易理解,顧名思義,主要用於產生同步方法。Kotlin語言不支持synchronized
關鍵字,處理類似Java語言的併發問題,Kotlin語言建議使用同步方法進行處理。
Kotlin團隊認爲同步的邏輯應該交給代碼處理,而不應該在語言層面處理:
image.png
但爲了兼容Java,Kotlin語言支持使用該註解讓編譯器自動生成同步方法:
@Synchronized
fun start() {
println("Start do something...")
}
// 生成的代碼相當於下面這段Java代碼
public static final synchronized void start() {
String var0 = "Start do something...";
System.out.println(var0);
}
@JvmWildcard
這個註解主要用於處理泛型參數,這涉及到兩個新的知識點:逆變與協變。由於Java語言不支持協變,爲了保證安全地相互調用,可以通過在泛型參數聲明的位置添加該註解使用Kotlin編譯器生成通配符形式的泛型參數(?extends ...
)。
看下面這段代碼:
class Box<out T>(val value: T)
interface Base
class Derived : Base
fun boxDerived(value: Derived): Box<Derived> = Box(value)
fun unboxBase(box: Box<Base>): Base = box.value
按照正常思維,下面的兩個方法轉換到Java代碼應該是這樣:
Box<Derived> boxDerived(Derived value) { …… }
Base unboxBase(Box<Base> box) { …… }
但問題是,Kotlin泛型支持型變,在Kotlin中,我們可以這樣寫unboxBase(Box(Derived()))
,而在Java語言中,泛型參數類型是不可變的,按照上面的寫法顯然已經做不到了。
正確轉換到Java代碼應該是這樣:
Base unboxBase(Box<? extends Base> box) { …… }
爲了使這樣的轉換正確生成,我們需要在泛型參數的位置添加上面的註解:
fun unboxBase(box: Box<@JvmWildcard Base>): Base = box.value
@JvmSuppressWildcards
這個註解的作用與@JvmWildcard
恰恰相反,它是用來抑制通配符泛型參數的生成,即在不需要型變泛型參數的情況下,我們可以通過添加這個註解來避免生成型變泛型參數。
fun unboxBase(box: Box<@JvmSuppressWildcards Base>): Base = box.value
// 生成的代碼相當於下面這段Java代碼
Base unboxBase(Box<Base> box) { …… }
正確使用上述註解,可以抹平Kotlin與Java泛型處理的差異,避免出現安全轉換問題。
@Volatile @Transient
這兩個註解恰好對應Java端的兩個關鍵字volatile
與transient
,前者主要用於解決多線程髒數據問題,後者用於標記序列化對象中不參與序列化的屬性。
這兩個註解比較簡單,就不舉例說明了。在遇到類似需要與Java互通的場景時,只需要將其關鍵字替換爲該註解即可。
以上就是我們日常開發過程中能夠遇到的所有註解了,在Kotlin 1.3版本中,還增加了一個新的註解@JvmDefault
用於在接口中處理默認實現的方法。接口中允許有默認實現是從JDK 1.8版本開始的,爲了兼容低版本JDK,Kotlin語言新增了該註解用於生成兼容性字節碼,但該註解目前仍處於實驗階段,名稱或行爲均可能發生改變,建議大家先不要使用,推薦大家始終使用JDK 1.8及其以上版本。
最佳實踐
如果在工程中必須存在部分Java代碼,爲了實現完美調用,一定要謹慎並正確地使用上述註解。要充分理解Kotlin編譯器與Java編譯器生成的字節碼差異。
如果是由於現存Java庫僅兼容Java字節碼,導致部分框架在遇到Kotlin語言生成的字節碼時會出現解析錯誤,不能正常使用。這個時候要嘗試檢查是否需要通過上述註解矯正字節碼的生成,使Java庫能夠正常使用。
如果是新工程,建議大家全部使用Kotlin代碼,避免出現上述註解,減少閱讀上的困難。目前,Kotlin版本已經非常穩定了,請大家放心使用。
最後
一點題外話:
最近面試被懟了?缺面試題刷提升自己嗎?
點擊:
來獲取學習資料提升自己去挑戰一下BAT面試難關吧
當程序員容易,當一個優秀的程序員是需要不斷學習的,從初級程序員到高級程序員,從初級架構師到資深架構師,或者走向管理,從技術經理到技術總監,每個階段都需要掌握不同的能力。早早確定自己的職業方向,才能在工作和能力提升中甩開同齡人。