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

大多數(shù)情況下,你不需要關注這個問題。但是,如果你的代碼中包含了部分Java代碼,理解這些注解將幫助你解決很多棘手問題。

產(chǎn)生這個問題的根本原因在于:Kotlin語言與Java語言的設計思路不同,部分特性屬于Java語言獨有,例如靜態(tài)變量。部分特性屬于Kotlin語言獨有,例如逆變和協(xié)變。

為了抹平這些差異,Kotlin語言提供了一個絕佳的思路,通過添加注解可以改變Kotlin編譯器生成的Java字節(jié)碼,使之按照Java語言可以理解的方向進行,從而實現(xiàn)兼容。

問題答疑:Kotlin語言與Java字節(jié)碼有什么關系?為什么Kotlin編譯器會生成Java字節(jié)碼?

不管是Kotlin語言還是Java語言都是建立在JVM平臺上面的編程語言,其最終都需要編譯成JVM可以識別的Java字節(jié)碼才能被正確執(zhí)行。這也是為什么Kotlin語言與Java可以完全互通的原因之一,不要將Java與Java平臺混為一談。

接下來我們先來看第一個注解,也是最常用到的一個注解:

@JvmField

Kotlin編譯器默認會將類中聲明的成員變量編譯成私有變量,Java語言要訪問該變量必須通過其生成的getter方法。而使用上面的注解可以向Java暴露該變量,即使其訪問變?yōu)楣_(修飾符變?yōu)閜ublic)。

我們來做一個實驗:

1)新建Person.kt,添加如下代碼:

class Person {
    @JvmField
    var name: String? = null
}

2)新建Client.java,添加如下代碼,嘗試訪問Person類中的變量name

public class Client {

    public static void main(String[] args) {
        Person p = new Person();
        // 在添加@JvmField注解之前,這樣訪問會報錯
        // 只能通過p.getName()的方式進行訪問
        String name = p.name;
    }
}

在添加@JvmField屬性前我們試圖通過p.name的方式進行訪問,編譯器出現(xiàn)報錯。因為,默認生成的成員變量name是私有的。而添加該注解之后我們居然可以正常訪問了。

由此可見,@JvmField注解的確使生成的字節(jié)碼發(fā)生了變化,我們將字節(jié)碼用Java代碼來表示,具體發(fā)生的變化類似下面代碼發(fā)生的變化:

添加注解之前

public final class Person {
   private String name;

   public final String getName() {
      return this.name;
   }

   public final void setName(@Nullable String var1) {
      this.name = var1;
   }
}

添加注解之后

public final class Person {
   public String name;
}

以上場景是將@JvmField注解添加到普通變量上方,如果添加到伴隨對象的成員變量上方,會發(fā)生什么呢?我們來試試看:

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注解會使伴隨對象在伴生類中生成靜態(tài)成員變量,通過伴生類可直接對其進行訪問。

結論

@JvmField注解可改變字節(jié)碼的生成,其作用的目標是類成員變量或伴隨對象成員變量。作用在類成員中可使該變量對外暴露,通過點語法直接訪問。即將私有成員變量公有化(public),并去掉setter/getter方法。作用在伴隨對象成員變量中,可以使該伴隨對象中的變量生成在伴生對象中,成為伴生對象的公有靜態(tài)成員變量,通過伴生類可直接訪問。

那么問題來了,如果該注解作用在私有成員變量上方會發(fā)生什么呢?請大家自行驗證。

@JvmStatic

這個注解與@JvmField非常容易出現(xiàn)混淆,兩者都可以作用在伴隨對象成員變量上方,我們來試試看,如果同樣作用在伴隨對象成員變量中,會出現(xiàn)什么情況。

添加@JvmField注解的效果,上面我們已經(jīng)看到了,我們直接將注解修改為@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注解并沒有在伴生類中生成靜態(tài)的公有成員變量。第三種方式可以正常訪問,證明該注解在伴生類中生成了靜態(tài)的公有getter方法。第二種方式可以正常訪問,證明該注解不會破壞伴隨對象中原有成員的訪問方式。

由此,我們可以大膽猜測,@JvmStatic注解的作用應該是生成靜態(tài)的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的本質區(qū)別。

注意:由于@JvmField不僅會改變屬性的訪問權限,同時也會改變setter/getter方法的生成,細心的同學應該已經(jīng)注意到了。一旦添加了@JvmField注解,setter/getter方法也消失了(變量可以通過點語法直接訪問,setter/getter方法也就沒必要存在了)。而@JvmStatic僅僅是使setter/getter方法變?yōu)殪o態(tài)方法,同時生成位置放置到伴生類中。這與@JvmField的處理方式有些沖突(@JvmField會直接刪除掉setter/getter方法)。為了避免沖突,Kotlin語言禁止將這兩個注解混淆使用。

以上是將@JvmStatic@JvmField作用在伴隨對象成員變量上的區(qū)別。實際上,@JvmStatic不僅可以修飾屬性(成員變量),還可以修飾方法,修飾方法的作用與修飾屬性的作用一致,都是將方法變成靜態(tài)類型。

為了更直觀地表示兩種的區(qū)別,我們用一個表格完整展示兩個注解的區(qū)別:

注解 作用位置 作用
@JvmField 類屬性或對象屬性 使屬性修飾符成為public
@JvmStatic 對象方法(包括伴生對象) 使用方法成為靜態(tài)類型,如果作用在伴生對象方法中,其方法會成為伴生類的靜態(tài)方法

@JvmName

這個注解可以改變字節(jié)碼中生成的類名或方法名稱,如果作用在頂級作用域(文件中),則會改變生成對應Java類的名稱。如果作用在方法上,則會改變生成對應Java方法的名稱。

Test.kt

@file:JvmName("FooKt")

@JvmName("foo1")
fun foo() {
    println("Hello, Jvm...")
}

在Kotlin語言中,foo是一個全局方法,為了兼容Java字節(jié)碼,實際會根據(jù)文件名生成對應的Java類TestKt.java,這是Kotlin編譯器的一個隱藏規(guī)則。

而添加了上述注解之后,生成的類名與方法名均發(fā)生了變化,具體產(chǎn)生的變化相當于下面這段Java代碼:

// 相當于下面的Java代碼
public final class FooKt {
   public static final void foo1() {
      String var0 = "Hello, Jvm...";
      System.out.println(var0);
   }
}

可以看到第一個注解@file:JvmName("FooKt")的作用是使生成的類名變?yōu)?code>FooKt,第二個注解的作用是使生成的方法名稱變?yōu)?code>foo1。

注意:該注解不能改變類中生成的屬性(成員變量)的名稱。

這里的注解中,我們看到了一個特殊的前綴@file:,這個注解前綴是Kotlin語言特有的一種標識,其作用是標記該注解最終會作用在生成的字節(jié)碼的具體位置(屬性、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,即生成的類名出現(xiàn)了重復??墒牵绻覀兙褪窍M暶魇褂枚鄠€文件,但方法生成到同一個類中呢?@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之后,報錯消失了,反編譯生成的字節(jié)碼,我們發(fā)生兩個不同文件中的方法合并到了同一個類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);
   }
}

這個注解在處理多個文件聲明,合并到一個類的場景中發(fā)揮著舉足輕重的作用。如果你有這樣的需求,一定要謹記這個注解。

@JvmOverloads

由于Kotlin語言支持方法參數(shù)默認值,而實現(xiàn)類似功能Java需要使用方法重載來實現(xiàn),這個注解就是為解決這個問題而生的,添加這個注解會自動生成重載方法。我們來試一下:

@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);
}

由此可見,通過這個注解可以影響帶有參數(shù)默認值方法的生成,添加該注解將自動生成帶有默認值參數(shù)數(shù)量的重載方法。這是一個非常有用的特性,方便Java端可以更高效地調用Kotlin端代碼。

@Throws

由于Kotlin語言不支持CE(Checked Exception),所謂CE,即方法可能拋出的異常是已知的。Java語言通過throws關鍵字在方法上聲明CE。為了兼容這種寫法,Kotlin語言新增了@Throws注解,該注解的接收一個可變參數(shù),參數(shù)類型是多個異常的KClass實例。Kotlin編譯器通過讀取注解參數(shù),在生成的字節(jié)碼中自動添加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

這個注解很容易理解,顧名思義,主要用于產(chǎn)生同步方法。Kotlin語言不支持synchronized關鍵字,處理類似Java語言的并發(fā)問題,Kotlin語言建議使用同步方法進行處理。

Kotlin團隊認為同步的邏輯應該交給代碼處理,而不應該在語言層面處理:

image

但為了兼容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

這個注解主要用于處理泛型參數(shù),這涉及到兩個新的知識點:逆變協(xié)變。由于Java語言不支持協(xié)變,為了保證安全地相互調用,可以通過在泛型參數(shù)聲明的位置添加該注解使用Kotlin編譯器生成通配符形式的泛型參數(shù)(?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語言中,泛型參數(shù)類型是不可變的,按照上面的寫法顯然已經(jīng)做不到了。

正確轉換到Java代碼應該是這樣:

Base unboxBase(Box<? extends Base> box) { …… }

為了使這樣的轉換正確生成,我們需要在泛型參數(shù)的位置添加上面的注解:

fun unboxBase(box: Box<@JvmWildcard Base>): Base = box.value

@JvmSuppressWildcards

這個注解的作用與@JvmWildcard恰恰相反,它是用來抑制通配符泛型參數(shù)的生成,即在不需要型變泛型參數(shù)的情況下,我們可以通過添加這個注解來避免生成型變泛型參數(shù)。

fun unboxBase(box: Box<@JvmSuppressWildcards Base>): Base = box.value
// 生成的代碼相當于下面這段Java代碼
Base unboxBase(Box<Base> box) { …… }

正確使用上述注解,可以抹平Kotlin與Java泛型處理的差異,避免出現(xiàn)安全轉換問題。

@Volatile @Transient

這兩個注解恰好對應Java端的兩個關鍵字volatiletransient,前者主要用于解決多線程臟數(shù)據(jù)問題,后者用于標記序列化對象中不參與序列化的屬性。

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

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

最佳實踐

如果在工程中必須存在部分Java代碼,為了實現(xiàn)完美調用,一定要謹慎并正確地使用上述注解。要充分理解Kotlin編譯器與Java編譯器生成的字節(jié)碼差異。

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

如果是新工程,建議大家全部使用Kotlin代碼,避免出現(xiàn)上述注解,減少閱讀上的困難。目前,Kotlin版本已經(jīng)非常穩(wěn)定了,請大家放心使用。

閱讀更多技術文章,請關注微信公眾號”歐陽鋒工作室“


image

參與Kotlin技術討論,請?zhí)砑游ㄒ还俜絈Q交流群:329673958

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容