Kotlin - 正確使用Kotlin註解,兼容Java代碼

正確使用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端的兩個關鍵字volatiletransient,前者主要用於解決多線程髒數據問題,後者用於標記序列化對象中不參與序列化的屬性。

這兩個註解比較簡單,就不舉例說明了。在遇到類似需要與Java互通的場景時,只需要將其關鍵字替換爲該註解即可。

以上就是我們日常開發過程中能夠遇到的所有註解了,在Kotlin 1.3版本中,還增加了一個新的註解@JvmDefault用於在接口中處理默認實現的方法。接口中允許有默認實現是從JDK 1.8版本開始的,爲了兼容低版本JDK,Kotlin語言新增了該註解用於生成兼容性字節碼,但該註解目前仍處於實驗階段,名稱或行爲均可能發生改變,建議大家先不要使用,推薦大家始終使用JDK 1.8及其以上版本。

最佳實踐

如果在工程中必須存在部分Java代碼,爲了實現完美調用,一定要謹慎並正確地使用上述註解。要充分理解Kotlin編譯器與Java編譯器生成的字節碼差異。

如果是由於現存Java庫僅兼容Java字節碼,導致部分框架在遇到Kotlin語言生成的字節碼時會出現解析錯誤,不能正常使用。這個時候要嘗試檢查是否需要通過上述註解矯正字節碼的生成,使Java庫能夠正常使用。

如果是新工程,建議大家全部使用Kotlin代碼,避免出現上述註解,減少閱讀上的困難。目前,Kotlin版本已經非常穩定了,請大家放心使用。

最後

一點題外話:

最近面試被懟了?缺面試題刷提升自己嗎?

點擊:

Android學習PDF+架構視頻+面試文檔+源碼筆記

來獲取學習資料提升自己去挑戰一下BAT面試難關吧

當程序員容易,當一個優秀的程序員是需要不斷學習的,從初級程序員到高級程序員,從初級架構師到資深架構師,或者走向管理,從技術經理到技術總監,每個階段都需要掌握不同的能力。早早確定自己的職業方向,才能在工作和能力提升中甩開同齡人。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章