正确使用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 class Client {

    public static void main(String[] args) {
        Person p = new Person();
        // 在添加@JvmField注解之前,这样访问会报错
        // 只能通过p.getName()的方式进行访问
        String name = p.name;
    }
}

在添加@JvmField属性前我们试图通过p.name的方式进行访问,编译器出现报错。因为,默认生成的成员变量name是私有的。而添加该注解之后我们居然可以正常访问了。

由此可见,@JvmField注解的确使生成的字节码发生了变化,我们将字节码用Java代码来表示,具体发生的变化类似下面代码发生的变化:

添加注解之前

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注解添加到普通变量上方,如果添加到伴随对象的成员变量上方,会发生什么呢?我们来试试看:

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

但为了兼容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版本已经非常稳定了,请大家放心使用。

阅读更多技术文章,请关注微信公众号”欧阳锋工作室“


image

参与Kotlin技术讨论,请添加唯一官方QQ交流群:329673958

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容