摘要:那麼回到這裏的問題,在自定義 View 或者其他需要保留 Java 方法重載的場景下,怎麼讓 Kotlin 在編譯時,真實的去生成對應的重載方法。這其實很好理解,在前面說到 Kotlin 在使用帶 默認值的方法的原理,其實 Kotlin 最終會在編譯後,額外生成一個 合成方法 ,來處理方法的參數默認值的情況,它和 Java 的方法重載還不一樣,用它生成的方法,確實不會存在多個方法的重載。

一. 序

今年五月的 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 遷移指南 》系列文章中,舉的例子其實很不恰當。

它這裏的例子中,使用了 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 構造方法。

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 個優質問題,上桌聯機學習。

公衆號後臺回覆成長『 成長 』,將會得到我準備的學習資料。

相關文章