42

Kotlin重载个方法,还有两幅面孔,省代码的同时也带来一个深坑 | Kotlin 原理

 4 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzIxNjc0ODExMA%3D%3D&%3Bmid=2247486517&%3Bidx=1&%3Bsn=021621a3f3da964001cb88b9817611eb
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
iqYvQjF.jpg!webM3eeiqR.jpg!web

一. 序

今年五月的 Google I/O 上,Google 正式向全球宣布 Kotlin-First 这一重要概念,Kotlin 将成为 Android 开发者的首选语言。

新语言有新特性,开发者还保持 Java 的编程习惯去写 Kotlin,也不是不行,但是总感觉差点意思。

最近公众号「谷歌开发者」连载了一个《实用 Kotlin 构建 Android 应用 | Kotlin 迁移指南》的系列文章,就举例了一些 Kotlin 编码的小技巧。

既然是一种指南性质的文章,自然在「多而广」的基础上,有意去省略一些细节,同时举例的场景,可能还有一些不恰当的地方。

这里我就来补齐这些细节, 今天聊聊利用 Kotlin 的方法默认参数的特性,完成类似 Java 的方法重载的效果 。完全解析这个特性的使用方式和原理,以及在使用过程中的一个深坑。

二. Kotlin 的简易方法重载

2.1 Kotlin 如何简化方法重载?

在 Java 中,我们可以在同一个类中,定义多个同名的方法,只需要保证每个方法具有不同的参数类型或参数个数,这就是 Java 的 方法重载

class Hello {
    public static void hello() {
        System.out.println("Hello, world!");
    }

    public static void hello(String name) {
        System.out.println("Hello, "+ name +"!");
    }

    public static void hello(String name, int age) {
        if (age > 0) {
            System.out.println("Hello, "+ name + "(" +age +")!");
        } else {
            System.out.println("Hello, "+ name +"!");
        }
    }
}

在这个例子中,我们定义了三个同名的 hello() 方法,具有不同的逻辑细节。

在 Kotlin 中,因为它支持在同一个方法里,通过 「 ? 」标出可空参数,以及通过「 = 」给出参数的默认值。那这三个方法就可以在 Kotlin 中,被柔和成一个方法。

object HelloDemo{
    fun hello(name: String = "world", age: Int = 0) {
        if (age > 0) {
            System.out.println("Hello, ${name}(${age})!");
        } else {
            System.out.println("Hello, ${name}!");
        }
    }
}

在 Kotlin 类中调用,和前面 Java 实现的效果是一致的。

HelloDemo.hello()
HelloDemo.hello("承香墨影")
HelloDemo.hello("承香墨影", 16)

但是这个通过 Kotlin 方法参数默认值的特性申明的方法,在 Java 类中使用时,就有些区别了。因为 HelloDemo 类被声明为 object,所以在 Java 中需要使用 INSTANCE 来调用它的方法。

HelloDemo.INSTANCE.hello("承香墨影",16);

Kotlin 中调用 hello() 方法很方便,可以选择性的忽略参数,但是在 Java 中使用,必须全量的显式的去做参数赋值。

这就是使用了参数默认值的方法申明时,分别在 Kotlin 和 Java 中的使用方式,接下来我们看看原理。

2.2 Kotlin 方法参数指定默认值的原理

Kotlin 编写的代码,之所以可以在 Java 系的虚拟机中运行,主要是因为它在编译的过程中,会被编译成虚拟机可识别的 Java 字节码。所以我们通过两次转换的方式( Show Kotlin Bytecode + Decompile ),就可以得到 Kotlin 生成的对应 Java 代码了。

public final void hello(@NotNull String name, int age) {
  Intrinsics.checkParameterIsNotNull(name, "name");
  if (age > 0) {
     System.out.println("Hello, " + name + '(' + age + ")!");
  } else {
     System.out.println("Hello, " + name + '!');
  }
}

// $FF: synthetic method
public static void hello$default(HelloDemo var0, String var1, int var2, int var3, Object var4) {
  if ((var3 & 1) != 0) {
     var1 = "world";
  }

  if ((var3 & 2) != 0) {
     var2 = 0;
  }
  var0.hello(var1, var2);
}

在这里会生成一个 hello() 方法,同时还会有一个 合成方法synthetic methodhello$default ,用来处理默认参数的问题。在 Kotlin 中调用 hello() 方法,会在编译期间,有选择性的自动替换成 hello() 的合成方法去调用。

// Kotlin 调用
HelloDemo.hello()
HelloDemo.hello("承香墨影")
HelloDemo.hello("承香墨影", 16)

// 编译后的 Java 代码
HelloDemo.hello$default(HelloDemo.INSTANCE, (String)null, 0, 3, (Object)null);
HelloDemo.hello$default(HelloDemo.INSTANCE, "承香墨影", 0, 2, (Object)null);
HelloDemo.INSTANCE.hello("承香墨影", 16);

注意看示例的末尾,当使用 hello(name,age) 这个方法重载时,其实与 Java 中的调用,是一致的,这没什么好说的。

这就是 Kotlin 方法重载时,使用指定默认参数的方式,省去多个方法重载代码的原理。

理解原理后,发现它确实减少了我们编写的代码量,但是有没有场景,是我们就需要显式的存在这几个方法的重载的?自然是有的,例如自定义 View 时。

三. 自定义 View 遇上 Kotlin

3.1 构造方法也是方法

再回到前面提到的谷歌开发者的《 实用 Kotlin 构建 Android 应用 | Kotlin 迁移指南 》系列文章中,举的例子其实很不恰当。

j6F3ma2.png!web

它这里的例子中,使用了 View 这个词,并且重载的几个方法,都是 View 的构造方法,我们在自定义 View 时,经常会和这三个方法打交道。

但是谷歌工程师在这里举的例子,很容易让人误会,实际上你如果在自定义 View 时,这么写一定是会报错的。

例如我们自定义一个 DemoView,它继承自 EditView。

class DemoView(
        context: Context, 
        attrs: AttributeSet? = null, 
        defStyleAttr: Int = 0
) : EditText(context, attrs, defStyleAttr) {
}

这个自定义的 DemoView,当使用在 XML 布局中时,虽然编译不会出错,但是运行时,你会得到一个 NoSuchMethodException。

Caused by: java.lang.NoSuchMethodException: <init> [class android.content.Context, interface android.util.AttributeSet]

什么问题呢?

在 LayoutInflater 创建控件时,找不到 DemoView(Context, AttributeSet) 这个重载方法,所以就报错了。

这其实很好理解,在前面说到 Kotlin 在使用带 默认值的方法的原理,其实 Kotlin 最终会在编译后,额外生成一个 合成方法 ,来处理方法的参数默认值的情况,它和 Java 的方法重载还不一样,用它生成的方法,确实不会存在多个方法的重载。

所以要明白, Kotlin 的方法指定默认参数与 Java 的方法重载,并不等价 。只能说它们在某些场景下,特性是类似的。

3.2 使用 @JvmOverloads

那么回到这里的问题,在自定义 View 或者其他需要保留 Java 方法重载的场景下,怎么让 Kotlin 在编译时,真实的去生成对应的重载方法?

这里就需要用到 @JvmOverloads 了。

当 Kotlin 使用了默认值的方法,被增加了 @JvmOverloads 注解后,它的含义就是 在编译时,保持并暴露出该方法的多个重载方法

其实当我们自定义 View 时,AS 已经给了我们充分的提示,它会自动帮我们生成带 @JvmOverloads 构造方法。

YJB7vqz.png!web

AS 帮我们补全的代码如下:

class DemoView @JvmOverloads constructor(
        context: Context, 
        attrs: AttributeSet? = null, 
        defStyleAttr: Int = 0
) : AppCompatEditText(context, attrs, defStyleAttr) {
}

再用「Kotlin Bytecode + Decompile」查看一下编译后的代码,来验证 @JvmOverloads 的效果。

@JvmOverloads
public DemoView(@NotNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
  Intrinsics.checkParameterIsNotNull(context, "context");
  super(context, attrs, defStyleAttr);
}

// $FF: synthetic method
public DemoView(Context var1, AttributeSet var2, int var3, int var4, DefaultConstructorMarker var5) {
  if ((var4 & 2) != 0) {
     var2 = (AttributeSet)null;
  }

  if ((var4 & 4) != 0) {
     var3 = 0;
  }

  this(var1, var2, var3);
}

@JvmOverloads
public DemoView(@NotNull Context context, @Nullable AttributeSet attrs) {
  this(context, attrs, 0, 4, (DefaultConstructorMarker)null);
}

@JvmOverloads
public DemoView(@NotNull Context context) {
  this(context, (AttributeSet)null, 0, 6, (DefaultConstructorMarker)null);
}

可以看到, @JvmOverloads 生效后,会按照我们的预期生成对应的重载方法,同时保留合成方法,完成在 Kotlin 中使用时,使用默认参数的需求。

是不是以为到这里就完了?并不是,如果你在自定义 View 时,完全按照 AS 给你的提示生成代码,虽然程序不会崩溃了,但你会得到一些未知的错误。

3.3 View 中别直接用 AS 生成代码

在自定义 View 时,依赖 AS 的提示生成代码,会遇到一些未知的错误。例如在本文的例子中,我们想要实现一个 EditView 的子类,用 AS 提示生成了代码。

会出现什么问题呢?

在 EditView 的场景下,你会发现焦点没有了,点击之后软键盘也不会自动弹出。

那为什么会出现这种问题?

原因就在 AS 在自动生成的代码时,对参数默认值的处理。

当在自定义 View 时,通过 AS 生成重载方法时,它对参数默认值的处理规则是这样的。

  1. 遇到对象,默认值为 null。

  2. 遇到基础数据类型,默认值为基本数据类型的默认值。例如 Int 就是 0,Boolean 就是 false。

而在这里的场景下, defStyleAttr 这个参数的类型为 Int,所以默认值会被赋值为 0,但是它并不是我们需要的。

在 Android 中,当 View 通过 XML 文件来布局使用时,会调用两个参数的构造方法 (Context context, AttributeSet attrs) ,而它内部会调用三个参数的构造方法,并传递一个默认的 defStyleAttr ,注意它并不是 0。

既然找到了问题,就很好解决了。我们看看自定义 View 的父类中,两个参数的构造方法如何实现的,将 defStyleArrt 当默认值传递进去就好了。

那我们先看看 AppCompatEditText 中的实现。

public AppCompatEditText(Context context, 
                         AttributeSet attrs) {
    this(context, attrs, R.attr.editTextStyle);
}

再修改 DemoView 中对 defStyleAttr 默认值的指定即可。

class DemoView @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null, 
        defStyleAttr: Int = R.attr.editTextStyle
) : AppCompatEditText(context, attrs, defStyleAttr) {
}

到这里,自定义 View 中,使用默认参数的构造方法重载问题,也解决了。

在自定义 View 的场景下,当然也可以通过重写多个 constructor 方法来实现类似的效果,但是既然已经明白了它的原理,那就放心大胆的使用吧。

四. 小结时刻

到这里就弄清楚 Kotlin 中,使用默认参数来减少方法重载代码的使用技巧和原理,以及注意事项了。

弄清楚原理以及需要注意的点,可以帮助我们更好的使用 Kotlin 的特性。我们最后再总结一下本文的知识点:

  1. Kotlin 可以通过对一个方法的参数,通过指定默认值的方式,来完成类似 Java 中「方法重载」的效果。

  2. 若想保留 Java 的重载方法,可以使用 @JvmOverloads 注解标记,它会自动生成该方法的全部重载方法。

  3. 在自定义 View 时,需要注意指定参数 defStyleAttr 的默认值,而不应该是 0。

今天就到这里,对本文的内容你有什么问题嘛?欢迎留言讨论。

本文对你有帮助吗? 留言、转发、点好看 是最大的支持,谢谢!

联机圆桌 」:point_left:推荐我的知识星球,一年 50 个优质问题,上桌联机学习。

公众号后台回复成长『 成长 』,将会得到我准备的学习资料。

jmUB73J.jpg!web

z26NFrm.png!web

zUjUvy3.png!web

2yQnAfB.jpg!web

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK