【從零開始擼一個App】Kotlin

萊布尼茨發表於2020-10-10

工欲善其事必先利其器。像我們從零開始擼一個App的話,選擇最合適的語言是首要任務。如果你跟我一樣對Java蹣跚的步態和僵硬的語法頗感無奈,那麼Kotlin在很大程度上不會令你失望。雖然為了符合JVM規範和相容Java,它引入了一些較為複雜的概念和語法,很多同學就是因此放棄入門。其實越深入進去,就會越欲罷不能。除了Android開發,博主也常在後端使用Kotlin編碼,有時因為某些原因同時使用Java混編。總的來說,能減少程式碼量,提高生產效率,似乎程式碼結構也更清晰了。如果你沒有Kotlin的經驗,但是比較過Java和C#,你就明白我的意思了,甚至Kotlin有些地方比C#還方便。可以說Kotlin既有C#便捷的語法,亦背靠Java平臺良好的生態,那麼你還在猶豫什麼?

基礎

var:可變,是一個可變變數。可知var型別屬性不能設定為延遲載入屬性,因為在lazy中並沒有setValue(…)方法。在DI場景下,常與lateinit搭配使用,可參看Kotlin中lateinit變數在位元組碼層面上的解釋
val:不可變,一個只讀變數。另外還有const val,只允許在top-level級別和object中使用。它們的區別如下:

  • const val 可見性為public final static,可以直接訪問。
  • val 可見性為private final static,並且val 會生成方法getNormalObject(),通過方法呼叫訪問。

Unit:當一個函式沒有返回值的時候,我們用Unit來表示這個特徵,同Java中的void。

open:在java中允許建立任意的子類並重寫方法任意的方法,除非顯示的使用了final關鍵字進行標註。而在Kotlin的世界裡面則不是這樣,在Kotlin中它所有的類預設都是final的,那麼就意味著不能被繼承,而且在類中所有的方法也是預設是final的,那麼就是Kotlin的方法預設也不能被重寫。為類增加open,class就可以被繼承了;為方法增加open,那麼方法就可以被重寫了。

inlineKotlin 行內函數 inline。它會將程式碼塊拷貝到呼叫的地方,減少了呼叫層數和額外物件的產生。
crossinline:這是因inline的副作用而引入的關鍵字。由於inline會將程式碼拷貝到呼叫的地方,如果程式碼裡面有return,那麼目的碼(呼叫者)的邏輯可能就被破壞了。用crossinline修飾相應的lambda,將return返回到對應標籤[,而不是返回到整個方法]。
reified:為了應對Java偽泛型導致的程式碼冗餘問題。可參看使用Kotlin Reified 讓泛型更簡單安全。這主要是應對Java中的泛型擦除。Java中的泛型是偽泛型,即它的泛型只存在於編譯期,在生成的位元組碼檔案中是不包含任何泛型資訊的(不過至少在編譯期就能及早發現型別不匹配的問題),在編譯後的位元組碼檔案中,就已經被替換為原來的原始型別(Raw Type/Object)了,並且在相應的地方插入了強制轉型程式碼,是為型別擦除。因此對於執行期的Java語言來說,ArrayList<int>與ArrayList<String>就是同一個型別。相對的,C#中使用的泛型,就是真泛型,其泛型無論在程式原始碼中、編譯後的IL中或是執行期的CLR中都是切實存在的,List<int>與List<String>就是兩個不同的型別,它們有自己的虛方法表和型別資料。
下面是我封裝RabbitMQ消費端監聽的程式碼(感興趣的同學可以參看本人博文RabbitMQ入門指南獲取更多資訊):

    /**
     * 從指定佇列獲取訊息,並定義回撥(for kotlin)
     *
     * @param queue the name of the queue from where receive messages
     * @param block callback when a message arrived
     */
    inline fun <reified T> receive(queue: String, crossinline block: (T) -> Boolean) {
        factory.newConnection().use {
            val conn = it.get()
            val channel = conn.createChannel()
            channel.basicConsume(queue, false, object : DefaultConsumer(channel) {
                override fun handleDelivery(consumerTag: String?, envelope: Envelope, properties: AMQP.BasicProperties?, body: ByteArray) {
                    try {
                        val message = JSON.parseObject(String(body), object : TypeReference<T>() {})
                        val done = block(message)
                        if (done) {
                            channel.basicAck(envelope.deliveryTag, false)
                        } else {
                            //若失敗則重新投遞一次,否則丟棄或投遞到死信佇列(若配置了的話)
                            channel.basicNack(envelope.deliveryTag, false, !envelope.isRedeliver)
                        }
                    } catch (e: Exception) {
                        _logger.error("處理訊息-${String(body)}時發生錯誤-${e.message}")
                        throw e
                    }
                }
            })
        }
    }

注意Java編譯器不支援inlinereified等關鍵字,所以如果要使用Java呼叫,還需要另外寫for java的版本。

field:用於屬性取值/賦值邏輯(如果顯式定義的話),類似於C#屬性中的value關鍵字,防止訪問器的自遞迴而導致程式崩潰的 StackOverflowError異常,參看kotlin學習—Field

this@ClassName:匿名內部類物件引用[包含它的]外部物件。

by:修飾屬性和欄位,提供若干效用,可參看Kotlin by
還可以在類定義時使用,可以將某例項的所有的 public 方法委託該類[,似乎這些方法就是在這個類中定義的]。這應該是組合的形態,但我們也可用它實現某種語法程度的“多繼承”,以後面協程部分的程式碼片段為例:

class BasicCorotineActivity : AppCompatActivity(), CoroutineScope by MainScope() {}

其中CoroutineScope是interface,MainScope()返回的是CoroutineScope的實現類ContextScope例項。也就是說,BasicCorotineActivity實現了介面CoroutineScope,但BasicCorotineActivity本身不實現其中的方法,而是委託給MainScope()返回的物件幫它實現。這減少了程式碼冗餘,從寫法上看,也似乎BasicCorotineActivity同時繼承了AppCompatActivity類和CoroutineScope例項:)

在kotlin中interface不僅可以宣告函式,還可以對函式進行實現。與類唯一不同的是它們是無狀態的,所以屬性需要子類去重寫。類需要去負責儲存介面屬性的狀態。

Elvis操作符:?: ,類似js中的 | ,若前者為null則取後者。

Kotlin並非一門純粹的語言,它在語法部分常考慮到Java的相容和可轉換性,為此增添了不少讓新手困惑的語法和關鍵字。如對一個屬性或一個主構造器的引數進行註解時,Kotlin元素將會生成對應的多個Java元素,因此在Java位元組碼中該註解有多個可能位置。如果要精確指定該如何生成該註解,可使用以下語法:

class Example(@field:Ann val foo,    // annotate Java field
              @get:Ann val bar,      // annotate Java getter
              @param:Ann val quux)   // annotate Java constructor parameter

更多可參看Kotlin編碼竅門之註解(Annotations)

companion objectobject:Kotlin 移除了 static 的概念,這兩者轉換成Java後都有靜態單例的模式,容易讓人困惑它們的區別。其實從使用場景分析就比較明瞭了,前者作為一個類的靜態內部單例類[物件]使用(companion就是伴侶的意思),後者就是一個靜態單例類[物件],不需要外圍類的存在(沒有companion嘛)。
在companion object場景下我們常使用@JvmStatic@JvmField以便將它們修飾的方法和欄位[在外部Java程式碼看來]暴露為類的子級,可參看微知識#1 Kotlin 的 @JvmStatic 和 @JvmField 註解
相關概念:@JvmOverloads
object關鍵字還可用於建立介面或者抽象類的匿名物件。

Kotlin允許你在檔案中定義頂級的函式和屬性。

Kotlin除了有擴充套件方法,還有擴充套件屬性,參看Kotlin的擴充套件屬性和擴充套件方法

Kotlin的函式引數是隻讀的。


lambda

Kotlin中的語法糖特別的多,比如lambda表示式,作為引數傳遞就有幾種不同的寫法:

  1. 普通方式:button.setOnClickListener({strInfo: String -> Unit})
  2. 如果最後一個引數是傳遞的lambda表示式,可以在圓括號之外指定:button.setOnClickListener(){strInfo: String -> Unit}
  3. 如果函式的引數只有一個[或者其它引數都有預設值],並且這個引數是lambda,就可以省略圓括號:button.setOnClickListener{strInfo: String -> Unit}
  4. 甚至可以省略為:button.setOnClickListener{strInfo}

以上面例子為例,如果setOnClickListener接受的引數不是lambda型別而是一個interface,該interface下只有一個方法,那麼同樣可以使用上述語法[,似乎setOnClickListener接受的引數就是lambda型別]。此類interface常用@FunctionalInterface修飾。(其實這應該就是java的特性,如RxJava中的subscribe(Consumer<? super T> onNext),在別人呼叫它的時候就可以直接傳lambda表示式)。

在呼叫時將lambda方法體移至括號外面應該是為了程式碼的可讀性,使得更貼近程式碼邏輯塊而非單個引數的感覺。可參看Kotlin系列之let、with、run、apply、also函式的使用中這些擴充套件函式的簽名定義,順便了解下這些函式的使用場景。


協程Coroutine

首先我們要知道一點,協程這個概念現在有點被濫用了,市面上流行的語言似乎都想把協程納入自己的特性裡。如果你對協程還不瞭解,請參看博主寫的再談協程或其它資料。博主認為真正的協程是如Go那樣的實現。Kotlin雖然也有協程,但更類似於C#裡的async/await,是在多執行緒層面的語法處理。更深入的分析可參看Kotlin 協程真的比 Java 執行緒更高效嗎?

suspend:關鍵字,它一般標識在一個函式的開頭,用於表示該函式是個耗時操作。這個關鍵字主要作用就是為了作一個提醒,並不會因為新增了這個關鍵字就會該函式立即跑到一個子執行緒上。是否切換執行緒仍是由launchwithContextasync決定的。當然了,有時候我們必須在函式前面加上suspend,如果函式內部呼叫了其它suspend函式的話。

如果使用retrofit2封裝網路請求的話,介面定義,原本每個函式應該返回的是Call<>(若有返回的話)型別。或者可以使用Jake Wharton寫的CoroutineCallAdapterFactory元件,它使得函式支援Deferred<>返回值,簡化協程+retrofit2的開發。不過從Retrofit 2.6.0起,Retrofit內建了對suspend關鍵字的支援,可以以更純粹的方式定義函式,如:

@GET("users/{id}")
suspend fun user(@Path("id") id: Long): User

慣常用CoroutineScope.launch建立協程(當然還有runBlockingwithContextasync等),它會返回一個Job物件,便於在外部對協程進行控制。

  • job.join():阻塞當前執行緒,直到job執行完畢。這是一個 suspend 函式,所以一般在 Coroutine 內呼叫,阻塞當前所在Coroutine。
  • job.cancel():取消job,執行後該job就進入cancelling狀態,但是否真的取消了需要看job自身實現。Coroutine標準庫中定義的 suspend function 都是支援取消操作的(比如 delay)。自定義job的時候可以通過 isActive 屬性來判斷當前任務是否被取消了,如果發現被取消了則停止繼續執行。如果自定義job沒有相應的處理邏輯,那麼就算呼叫job.cancel(),也並不能取消它的執行。
  • SupervisorJob(parent: Job? = null):返回一個job例項,裡面的子Job不相互影響,一個子Job失敗了,不影響其他子Job的執行。parent引數用於關聯自己本身的父job。如果研究協程原始碼的話,會常看到ContextScope(SupervisorJob() + Dispatchers.Main)的寫法(如ViewModel.viewModelScope的實現),這裡的 + 號是CoroutineContext對操作符plus的過載,前後兩者都是CoroutineContext的子類。

後記拾遺

當使用公有屬性時,有時會丟擲“Smartcast is impossible because propery has open or custom getter”的編譯時錯誤,究其原因是編譯器分析程式碼發現每次get屬性時返回的物件可能不是同一個。解決方法很簡單,只要定義一個臨時變數指向某次get獲得的值即可。可參看Smartcast is impossible because propery has open or custom getter

Java泛型擦除導致的問題。如下程式碼可正常執行:

    private fun  getToken(): Token? {
        val preference = TaoismApplication.appContext.sharedPreferences(SharedPreference.SESSION)
        val json = preference.getString(SharedPreference.TOKEN, "")
        if (!json.isNullOrBlank()) {
            return Gson().fromJson<Token>(json, object : TypeToken<Token>() {}.type)
        } else {
            return null
        }
    }

由於程式碼中有較多getXXX(),抽取模板程式碼:

    private fun <T : Any?> get(key: String): T? {
        val preference = TaoismApplication.appContext.sharedPreferences(SharedPreference.SESSION)
        val json = preference.getString(key, "")
        if (!json.isNullOrBlank()) {
            return Gson().fromJson<T>(json, object : TypeToken<T>() {}.type)
        } else {
            return null
        }
    }

呼叫get<Token>(SharedPreference.TOKEN)報錯:java.lang.ClassCastException: com.google.gson.internal.LinkedTreeMap cannot be cast to com.xxx.xxx.Token
so,只能將型別資訊顯式傳入,改造方法簽名為get(key: String, typeToken: Type)

kotlin異常:Kotlin 的異常都是 Unchecked exception。若在函式上註解了@Throws,則編譯成Java程式碼會變成符合Java模式的checked exception,即在方法定義上會顯式宣告可能丟擲的異常型別,需要在呼叫鏈路上處理。

使用intellij idea進行kotlin和java混合開發,最好將kotlin檔案和java檔案分各自資料夾存放,否則執行時可能會報找不到類的錯誤(因為編譯時會將不是屬於該資料夾的且沒有被其它檔案引用的程式碼檔案忽略)。如下:


參考資料

Kotlin協程 —— 今天說說 launch 與 async

相關文章