為 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萬現金大獎」