Kotlin實戰:用實戰程式碼更深入地理解預定義擴充套件函式

唐子玄發表於2019-06-27

這是該系列的第三篇,系列文章目錄如下:

  1. Kotlin基礎:白話文轉文言文般的Kotlin常識

  2. Kotlin基礎:望文生義的Kotlin集合操作

  3. Kotlin實戰:用實戰程式碼更深入地理解預定義擴充套件函式

  4. Kotlin實戰:使用DSL構建結構化API去掉冗餘的介面方法

  5. Kotlin基礎:屬性也可以是抽象的

  6. Kotlin進階:動畫程式碼太醜,用DSL動畫庫拯救,像說話一樣寫程式碼喲!

  7. Kotlin基礎:用約定簡化相親

  8. Kotlin基礎 | 2 = 12 ?泛型、類委託、過載運算子綜合應用

理論和實踐之間那條鴻溝一直在那,如果不去跨越,就只能發出“懂了這麼多道理,依然過不好這一生”這樣的感嘆。這一篇就試著用專案中的實戰程式碼來跨越這條鴻溝。本文的口號是“demo code is cheap, show me the real project code!”。包含如下知識點:函式型別、擴充套件函式、帶接收者的lambda、apply()、also()、let()、安全呼叫運算子、Elvis運算子。

apply()

第一篇中提到過apply()函式,這一次結合實戰程式碼,講的更深入一點。

在 Android 將多個動畫組合在一起會用到 AnimatorSet,使用apply()可以讓構建組合動畫的程式碼減少重複的物件名,讓整個構建語義一目瞭然:

val span = 300
AnimatorSet().apply {
    playTogether(
            ObjectAnimator.ofPropertyValuesHolder(
                    tvTitle,
                    PropertyValuesHolder.ofFloat("alpha", 0f, 1.0f),
                    PropertyValuesHolder.ofFloat("translationY", 0f, 100f)).apply {
                interpolator = AccelerateInterpolator()
                duration = span
            },
            ObjectAnimator.ofPropertyValuesHolder(
                    ivAvatar,
                    PropertyValuesHolder.ofFloat("alpha", 1.0f, 0f),
                    PropertyValuesHolder.ofFloat("translationY", 0f,100f)).apply {
                interpolator = AccelerateInterpolator()
                duration = span
            }
    )
    start()
}
複製程式碼

同時對 tvTitle 和 ivAvatar 控制元件做透明度和位移動畫,並設定了動畫時間和插值器。程式碼中沒有出現多個AnimatorSet物件及多個Animator物件,這都得益於apply()

  1. object.apply()接收一個 lambda 作為引數。它的語義是:將lambda應用於object物件,其中的 lambda 是一種特殊的 lambda,稱為帶接收者的lambda。這是 kotlin 中特有的,java 中沒有。

    帶接收者的lambda的函式體除了能訪問其所在類的成員外,還能訪問接收者的所有非私有成員,這個特性是它具有魅力的關鍵。(這個特性還使得它非常適用於構建DSL,下一篇會提到)

    上述程式碼中緊跟在apply()後的 lambda 函式體除了訪問其外部的變數span,還訪問了 AnimatorSet 的playTogether()start(),就好像在 AnimatorSet 類內部一樣。(可以在這兩個函式前面加上this,省略了更簡潔)。

  2. object.apply()的另一個特點是:在它對 object 物件進行了一段操作後還會返回 object 物件本身。

所以apply()適用於 “構建物件後緊接著還需要呼叫該物件的若干方法進行設定並最終返回這個物件例項” 的場景

let()

let()apply()非常像,但因為下面的兩個區別,使得它的應用場景和apply()不太一樣:

  1. 它接收一個普通的 lambda 作為引數。
  2. 它將 lambda 的值作為返回值。

在專案中有這樣一個場景:啟動一個 Fragment 並傳 bundle 型別的引數,如果其中的 duration 值不為 0 則顯示檢視A,否則顯示檢視B。用let()實現如下:

class FragmentA : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        
        arguments?.let { arg ->
            //呼叫物件方法
            arg.getBundle(KEY)?.takeIf { it[DURATION] != 0 }?.let { duration -> 
                //將物件作為引數傳遞給另一個函式
                showA(duration)
            } ?: showB()
        }
    }
}
複製程式碼

上述程式碼展示了let()的三個用法慣例:

  1. 通常情況下let()會和安全呼叫運算子?一起使用,即object?.let(),它的語義是:如果object不為空則對它做一些操作,這些操作可以是呼叫它的方法,或者將它作為引數傳遞給另一個函式

    apply()對比一下,因為apply()通常用於構建新物件(let()用於既有物件),新建的物件不可能為空,所以不需要?,而且就使用習慣而言,apply()後的 lambda 中通常只有呼叫物件的方法,而不會將物件作為引數傳遞給另一個函式(雖然也可以這麼做,只要傳this就可以)

  2. let()也會結合Elvis運算子?:實現空值處理,當呼叫let()的物件為空時,其 lambda 中的邏輯不會被執行,如果需要指定此時執行的邏輯,可以使用?:

  3. let()巢狀時,顯示地指明 lambda 引數名稱避免it的歧義。在 kotlin 中如果 lambda 引數只有一個 可以將引數宣告省略,並用it指代它。但當 lambda 巢狀時,it的指向就有歧義。所以程式碼中用arg顯示指明這是 Fragment 的引數,用duration顯示指明這是 Bundle 中的 duration。

除了上面這種用法,還可以把let()當做變換函式使用,就好像RxJava中的map()操作符。因為let()將 lambda 的值作為其返回值。

在專案中有這樣一個需求:為某介面的所有點選事件新增資料埋點。

當然可以將埋點邏輯散落在各個控制元件的OnClickListener中,但如果希望對埋點邏輯統一控制,就可以用下面的這個方案:

  1. 先定義一個包含點選響應邏輯的類
class OnClickListenerBuilder {
    //'點選響應邏輯'
    var onClickAction: ((View) -> Unit)? = null

    //'為點選響應邏輯賦值的函式'
    fun onClick(action: (View) -> Unit) {
        onClickAction = action
    }
}
複製程式碼

函數語言程式設計中,把函式當做值來對待,你可以把函式當做值到處傳遞,也可以把函式獨立地宣告並儲存在一個變數中,但是最常見的還是直接宣告它並傳遞給函式作為引數。

OnClickListenerBuilder定義了一個函式型別的成員變數onClickAction,它的型別是((View) -> Unit)?,這和View.OnClickListener中的void onClick(View view)函式一摸一樣,即輸入一個View並返回空值。這個成員變數的值是可空的,所以在原本的函式型別(View) -> Unit外面又套了一個括號和問號。

這個類的目的是將自定義的點選響應邏輯儲存在函式型別的變數中,當點選事件發生時應用這段邏輯。

  1. 為 View 設定點選事件並應用自定義點選響應邏輯
//'定義擴充套件函式'
fun View.setOnDataClickListener(action: OnClickListenerBuilder.() -> Unit) {
    setOnClickListener(
            OnClickListenerBuilder().apply(action).let { builder ->
                View.OnClickListener { view ->
                    //'埋點邏輯'
                    Log.v(“ttaylor”, “view{$view} is clicked”)
                    //'點選響應邏輯'
                    builder.onClickAction?.invoke(view)
                }
            }
    )
}

//'在介面中使用擴充套件函式為控制元件設定點選事件'
btn.setOnDataClickListener {
    onClick {
        Toast.makeText(this@KotlinExample, “btn is click”, Toast.LENGTH_LONG).show()
    }
}
複製程式碼
  • 擴充套件函式

    View宣告瞭一個擴充套件函式setOnDataClickListener()。擴充套件函式是一個類的成員函式,但它定義在類體外面。這樣定義的好處是,可以在任何時候任何地方給類新增功能。

    在擴充套件函式中,可以像類的其他成員函式一樣訪問類的屬性和方法(除了被private和protected修飾的成員),在本例中呼叫了setOnClickListener()為控制元件設定點選事件。

  • 帶接收者的lambda

    為了應用儲存在OnClickListenerBuilder中的點選響應邏輯,必須先構建其例項。程式碼中的OnClickListenerBuilder().apply(action)在構建例項的同時對其應用了一個 lambda ,這是一個帶接收者的lambda,且接收者是OnClickListenerBuilder。它作為擴充套件函式的引數傳入。當呼叫setOnDataClickListener()的時候,我們在傳入的 lambda 中輕鬆地呼叫了onClick()方法,因為帶接收者的lambda函式體中可以訪問接收者的非私有成員。這樣就實現了將點選響應邏輯儲存在函式型別變數onClickAction中。

  • let()map()

    由於 API 的限定View.setOnClickListener()的引數必須是View.OnClickListener型別的,所以必須將OnClickListenerBuilder轉化成View.OnClickListener。呼叫let()就可以輕鬆做到,因為它將 lambda 的值作為返回值。

    最後在原生點選事件響應函式中實現了埋點邏輯並呼叫了儲存在函式型別變數中的自定義點選響應邏輯。

also()

also()幾乎和let()相同,唯一的卻別是它會返回撥用者本身而不是將 lambda 的值作為返回值。

和同樣返回撥用者本身的apply()相比:

  1. 就傳參而言,apply()傳入的是帶接收者的lambda,而also()傳入的是普通 lambda。所以在 lambda 函式體中前者通過this引用呼叫者,後者通過it引用呼叫者(如果不定義引數名字,預設為it)
  2. 就使用場景而言,apply()更多用於構建新物件並執行一頓操作,而also()更多用於對既有物件追加一頓操作。

在專案中,有一個介面初始化的時候需要載入一系列圖片並儲存到一個列表中:

listOf(
    R.drawable.img1,
    R.drawable.img2, 
    R.drawable.img3,
    R.drawable.img4, 
).forEach {resId->
    BitmapFactory.decodeResource(
        resources, 
        resId, 
        BitmapFactory.Options().apply {
            inPreferredConfig = Bitmap.Config.ARGB_4444
            inMutable = true
        }
    ).also { bitmap -> imgList.add(bitmap) }
}
複製程式碼

這個場景中用let()也沒什麼不可以。但是如果還需要將解析的圖片輪番顯示出來,用also()就再好不過了:

listOf(
    R.drawable.img1,
    R.drawable.img2, 
    R.drawable.img3,
    R.drawable.img4, 
).forEach {resId->
    BitmapFactory.decodeResource(
        resources, 
        resId, 
        BitmapFactory.Options().apply {
            inPreferredConfig = Bitmap.Config.ARGB_4444
            inMutable = true
        }
    ).also { 
        //儲存邏輯
        bitmap -> imgList.add(bitmap) 
    }.also {
        //顯示邏輯
        ivImg.setImageResource(it)   
    }
}
複製程式碼

因為also()返回的是呼叫者本身,所以可以also()將不同型別的邏輯分段,這樣的程式碼更容易理解和修改。這個例子邏輯比較簡單,只有一句話,將他們合併在一起也沒什麼不好。

知識點總結

  • 在函數語言程式設計中,把函式當做值來對待,你可以把函式當做值到處傳遞,也可以把函式獨立地宣告並儲存在一個變數中。
  • 函式型別是一種新的型別,它用 lambda 來描述一個函式的輸入和輸出。
  • 擴充套件函式是一種可以在類體外為類新增功能的特性,在擴充套件函式體中可以訪問類的成員(除了被private和protected修飾的成員)
  • 帶接收者的lambda是一種特殊的lambda,在函式體中可以訪問接收者的非私有成員。可以把它理解成接收者的擴充套件函式,只不過這個擴充套件函式沒有函式名。
  • apply() also() let()是系統預定義的擴充套件函式。用於簡化程式碼,減少重複的物件名。
  • ?.稱為安全呼叫運算子,若object?.fun()中的 object 為空,則fun()不會被呼叫。
  • ?:稱為Elvis運算子,它為 null 提供了預設邏輯,funA() ?: funB(),如果 funA() 返回值不為 null 執行它 並將它的返回值作為整個表示式的返回值,否則執行 funB() 並採用它的返回值。

下一篇會在這篇的基礎上講解一個更加複雜的例子並引出DSL的概念。

相關文章