為 Kotlin 的函數添加作用域限制(以 Compose 為例)

語言: CN / TW / HK

前言

不知道各位是否已經開始瞭解 Jetpack Compose?

如果已經開始瞭解並且上手寫過。那麼,不知道你們有沒有發現,在 Compose 中對於作用域(Scopes)的應用特別多。比如, weight 修飾符只能用在 RowScope 或者 ColumnScope 作用域中。又比如,item 組件只能用在 LazyListScope 作用域中。

如果你還沒有了解過 Compose 的話,那你也應該知道,kotlin 標準庫中有 5 個作用域函數:let() apply() also() with() run() ,這 5 個函數會以不同的方式持有和返回上下文對象,即調用這些函數時,在它們的 lambda 參數中寫的代碼將處於特定的作用域。

不知道你們有沒有思考過,這些作用域限制是怎麼實現的呢?如果我們想自定義一個 Composable 函數,只支持在特定的作用域中使用,應該怎麼寫呢?

本文將為你解開這個疑惑。

作用域

不過在正式開始之前我們還是先大概補充一點有關 kotlin 中作用域的基本知識。

什麼是作用域

其實對於咱們程序員來説,不管學的是什麼語言,對於作用域應該都是有一個瞭解的。

舉個簡單的例子:

```kotlin val valueFile = "file"

fun a() { val valueA = "a" println(valueFile) println(valueA) println(valueB) }

fun b() { val valueB = "b" println(valueFile) println(valueA) println(valueB) } ```

這段代碼不用運行都知道肯定會報錯,因為在函數 a 中無法訪問 valueB ;在函數 b 中無法訪問 valueA 。但是這兩個函數都可以成功訪問 valueFile

這是因為 valueFile 的作用域是整個 .kt 文件,也就是説,只要是在這個文件中的代碼,都可以訪問到它。

valueAvalueB 的作用域則分別是在函數 a 和 b 中,顯然只能在各自的作用域中使用。

同理,如果我們想要調用類的方法或者函數也需要考慮作用域:

```kotlin class Test { val valueTest = "test"

fun a(): String {
    val valueA = "a"
    println(valueTest)
    println(valueA)

    return "returnA"
}

fun b() {
   println(valueA)
   println(valueTest)
   println(a())
}

}

fun main() { println(valueTest) println(valueA) println(a()) } ```

這裏舉的例子可能不太恰當,但是這裏是為了説明這個情況,不要過多糾結哦~

顯然,上面這個代碼,在 main 函數中是無法訪問到變量 valueTestvalueA 的,並且也無法調用函數 a() ;而在 Test 類中的函數 a() 顯然可以訪問到 valueTestvalueA ,並且函數 b() 也可以調用函數 a(),可以訪問變量 valueTest 但是無法訪問變量 valueA

這是因為函數 a()b() 以及變量 valueTest 位於同一個作用域中,即類 Test 的作用域。

而變量 valueA 位於函數 a() 的作用域內,由於 a() 又位於 Test 的作用域內,所以實際上這裏的 valueA 的作用域稱為嵌套作用域,即同時位於 a()Test 的作用域內。

因為本節只是為了引出我們今天要介紹的內容,所以有關作用域的知識就簡單介紹這麼多,更多有關作用域的知識可以閲讀參考資料 1 。

kotlin 標準庫中的作用域函數

在前言中我們説過,kotlin標準庫中有5個稱之為作用域函數的東西:withrunletalsoapply

它們有什麼作用呢?

先看一段我們經常會遇到的代碼形式:

kotlin val person = Person() person.fullName = "equationl" person.lastName = "l" person.firstName = "equation" person.age = 24 person.gender = "man"

在某些情況下,我們可能會需要多次重複的寫一堆 person,可讀性很差,寫起來也很繁瑣。

此時我們就可以使用作用域函數,例如使用 with 改寫:

kotlin with(person) { fullName = "equationl" lastName = "l" firstName = "equation" age = 24 gender = "man" }

此時,我們就可以省略掉 person ,直接訪問或修改它的屬性值,這是因為 with 的第一個參數接收的是需要作為第二個參數的 lambda 上下文對象,即此時,第二個參數 lambda 匿名函數所在的作用域為第一個參數傳入的對象,此時 IDE 的提示也指出了此時 with 的匿名函數中的作用域為 Person

1.png

所以在這個匿名函數中能直接訪問或修改 Person 的屬性。

同理,我們也可以使用 run 函數改寫:

kotlin person.run { fullName = "equationl" lastName = "l" firstName = "equation" age = 24 gender = "man" }

可以看出,runwith 非常相似,只是 run 是以擴展函數的形式接收上下文對象,它的參數只有一個 lambda 匿名函數。

後面還有 let

kotlin person.let { it.fullName = "equationl" it.lastName = "l" it.firstName = "equation" it.age = 24 it.gender = "man" }

它與 run 的區別在於,匿名函數中的上下文對象不再是隱式接收器(this),而是作為一個參數(it)存在。

使用 also() 則是:

kotlin person.also { it.fullName = "equationl" it.lastName = "l" it.firstName = "equation" it.age = 24 it.gender = "man" }

let 一樣,它也是擴展函數,並且上下文也作為參數傳入匿名函數,但是不同於 let ,它會返回上下文對象,這樣可以方便的進行鏈式調用,如:

kotlin val personString = person .also { it.age = 25 } .toString()

最後是 apply

kotlin person.apply { fullName = "equationl" lastName = "l" firstName = "equation" age = 24 gender = "man" }

also 一樣,它是擴展函數,也會返回上下文對象,但是它的上下文將作為隱式接收者,而不是匿名函數的一個參數。

下面是它們 5 個函數的對比圖和表格:

2.png

| 函數 | 上下文形式 | 返回值 | 是否是擴展函數 | | :--: | :----: | :----: | :----: | | with | 隱式接收者(this) | lambda函數(Unit) | 否 | | run | 隱式接收者(this) | lambda函數(Unit) | 是 | | let | 匿名函數的參數(it) | lambda函數(Unit) | 是 | | also | 匿名函數的參數(it) | 上下文對象 | 是 | | apply| 隱式接收者(this) | 上下文對象 | 是 |

Compose 中的作用域限制

在前言中我們説過,在 Compose 對作用域限制的應用非常多。

例如 Modifier 修飾符,從這個 Compose 修飾符列表 中,我們也能看到很多修飾符的作用域都做了限制:

3.png

這裏需要對修飾符做限制的原因非常簡單:

In the Android View system, there is no type safety. Developers usually find themselves trying out different layout params to discover which ones are considered and their meaning in the context of a particular parent.

在傳統的 xml view 體系中就是沒有對佈局的參數做限制,這就導致所有的參數都可以用在任意佈局中,這會導致一些問題。輕則參數無效,寫了一堆無用參數;嚴重的可能會干擾到佈局的正常使用。

當然,Modifier 修飾符限制只是 Compose 中其中一個應用,在 Compose 中還有很多作用域限制的例子,例如:

4.png

在上圖中 item 只能在 LazyListScope 作用域使用,drawRect 只能在 DrawScope 作用域使用。

當然,正如我們前面説的,作用域中不只有函數和方法,還可以訪問類的屬性,例如,在 DrawScope 作用域提供了一個名為 size 的屬性,可以通過它來拿到當前的畫布大小:

5.png

那麼,這些是怎麼實現的呢?

自定義我們的作用域限制函數

原理

在開始實現我們自己的作用域函數之前,我們需要先了解一下原理。

這裏我們以 Compose 的 Canvas 為例來看看。

首先是 Canvas 的定義:

6.png

可以看到這裏 Canvas 接收了兩個參數:modifier 和 onDraw 的 lambda ,且這個 lambda 的 Receiver(接收者) 為 DrawScope ,也就是説,onDraw 這個匿名函數的作用域被限制在了 DrawScope 內,這也意味着可以在匿名函數內部使用 DrawScope 作用域內的屬性、方法等。

再來看看這個 DrawScope 是何方神聖:

7.png

可以看到這是一個接口,裏面定義了一些屬性變量(如我們上面説的 size) 和一些方法(如我們上面説的 drawRect )。

然後再實現這個接口,編寫具體實現代碼:

8.png

實現

所以總結來説,如果我們想實現自己的作用域限制大致分為三步:

  1. 編寫作為作用域的接口
  2. 實現這個接口
  3. 在暴露的方法中將 lambda 參數接收者使用上面定義的接口

下面我們舉個例子。

假如我們要在 Compose 中實現一個遮罩引導層,用於引導新用户操作,類似這樣:

main_intro.gif

圖源 Intro-showcase-view

但是我們希望引導層上的提示可以多樣化,例如可以支持文字提示、圖片提示、甚至播放視頻或動圖提示,但是我們不希望這些提示 item 在遮罩層以外的地方被調用,因為它們依賴於遮罩層的某些參數,如果在外部調用會出錯。

這時候,使用作用域限制就非常合適。

首先,我們編寫一個接口:

```kotlin interface ShowcaseScreenScope { val isShowOnce: Boolean

@Composable
fun ShowcaseTextItem()

} ```

在這個接口中我們定義了一個屬性變量 isShowOnce 用於表示這個引導層是否只顯示一次、定義一個方法 ShowcaseTextItem 表示在引導層上顯示一串文字,同理我們還可以定義 ShowcaseImageItem 表示顯示圖片。

然後實現這個接口:

```kotlin private class ShowcaseScopeImpl: ShowcaseScreenScope {

override val isShowOnce: Boolean
    get() = TODO("在這裏編寫是否只顯示一次的邏輯")

@Composable
override fun ShowcaseTextItem() {
    // 在這裏寫你的實現代碼
    Text(text = "我是説明文字")
}

} ```

在接口實現中,根據我們的需求編寫相應的實現邏輯代碼。

最後,寫一個提供給外部調用的 Composable:

kotlin @Composable fun ShowcaseScreen(content: @Composable ShowcaseScreenScope.() -> Unit) { // 在這裏實現其他邏輯(例如顯示遮罩)後調用 content // …… ShowcaseScopeImpl().content() }

在這個 composable 中,我們可以先處理完其他邏輯,例如顯示遮罩層 UI 或顯示動畫後再調用 ShowcaseScopeImpl().content() 將我們傳遞的子 Item 組合上去。

最後,使用時只需要調用:

kotlin ShowcaseScreen { if (!isShowOnce) { ShowcaseTextItem() } }

當然,這個 ShowcaseTextItem()isShowOnce 位於 ShowcaseScreenScope 作用域內,在外面是不能調用的:

9.png

總結

本文簡要介紹了 Kotlin 中的作用域概念和標準庫中的作用域函數,並引申到 Compsoe 中關於作用域的應用,最終分析實現原理並講解如何自定義一個我們自己的 Compose 作用域函數。

本文寫的可能比較淺顯,很多知識點都是點到為止,沒有過多講解,推薦讀者閲讀完後,可以看看文末的參考鏈接中其他大佬寫的文章。

參考資料

  1. Scopes and Scope Functions
  2. Kotlin DSL 實戰:像 Compose 一樣寫代碼
  3. Scope composables to a parent composable
  4. Compose modifiers-Type safety in Compose

本文正在參加「金石計劃 . 瓜分6萬現金大獎」