作者:equationl https://juejin.cn/post/7173913850230603812
前言不知道各位是否已經開始了解 Jetpack Compose?
如果已經開始了解並且上手寫過。那麼,不知道你們有沒有發現,在 Compose 中對於作用域(Scopes)的應用特別多。比如, weight 修飾符只能用在 RowScope 或者 ColumnScope 作用域中。又比如,item 組件只能用在 LazyListScope 作用域中。
如果你還沒有了解過 Compose 的話,那你也應該知道,kotlin 標準庫中有 5 個作用域函數:let() apply() also() with() run() ,這 5 個函數會以不同的方式持有和返回上下文對象,即調用這些函數時,在它們的 lambda 參數中寫的代碼將處於特定的作用域。
不知道你們有沒有思考過,這些作用域限制是怎麼實現的呢?如果我們想自定義一個 Composable 函數,只支持在特定的作用域中使用,應該怎麼寫呢?
本文將為你解開這個疑惑。不過在正式開始之前我們還是先大概補充一點有關 kotlin 中作用域的基本知識。
什麼是作用域其實對於咱們程序員來說,不管學的是什麼語言,對於作用域應該都是有一個了解的。
舉個簡單的例子:
valvalueFile="file"funa(){valvalueA="a"println(valueFile)println(valueA)println(valueB)}funb(){valvalueB="b"println(valueFile)println(valueA)println(valueB)}這段代碼不用運行都知道肯定會報錯,因為在函數 a 中無法訪問 valueB ;在函數 b 中無法訪問 valueA 。但是這兩個函數都可以成功訪問 valueFile 。
這是因為 valueFile 的作用域是整個 .kt 文件,也就是說,只要是在這個文件中的代碼,都可以訪問到它。
而 valueA 和 valueB 的作用域則分別是在函數 a 和 b 中,顯然只能在各自的作用域中使用。
同理,如果我們想要調用類的方法或者函數也需要考慮作用域:
classTest{valvalueTest="test"funa():String{valvalueA="a"println(valueTest)println(valueA)return"returnA"}funb(){println(valueA)println(valueTest)println(a())}}funmain(){println(valueTest)println(valueA)println(a())}這裡舉的例子可能不太恰當,但是這裡是為了說明這個情況,不要過多糾結哦~
顯然,上面這個代碼,在 main 函數中是無法訪問到變量 valueTest 和 valueA 的,並且也無法調用函數 a() ;而在 Test 類中的函數 a() 顯然可以訪問到 valueTest 和 valueA ,並且函數 b() 也可以調用函數 a(),可以訪問變量 valueTest 但是無法訪問變量 valueA 。
這是因為函數 a() 和 b() 以及變量 valueTest 位於同一個作用域中,即類 Test 的作用域。
而變量 valueA 位於函數 a() 的作用域內,由於 a() 又位於 Test 的作用域內,所以實際上這裡的 valueA 的作用域稱為嵌套作用域,即同時位於 a() 和 Test 的作用域內。
因為本節只是為了引出我們今天要介紹的內容,所以有關作用域的知識就簡單介紹這麼多。
kotlin 標準庫中的作用域函數在前言中我們說過,kotlin標準庫中有5個稱之為作用域函數的東西:with、run、let、also、apply 。
它們有什麼作用呢?
先看一段我們經常會遇到的代碼形式:
valperson=Person()person.fullName="equationl"person.lastName="l"person.firstName="equation"person.age=24person.gender="man"在某些情況下,我們可能會需要多次重複的寫一堆 person,可讀性很差,寫起來也很繁瑣。
此時我們就可以使用作用域函數,例如使用 with 改寫:
with(person){fullName="equationl"lastName="l"firstName="equation"age=24gender="man"}此時,我們就可以省略掉 person ,直接訪問或修改它的屬性值,這是因為 with 的第一個參數接收的是需要作為第二個參數的 lambda 上下文對象,即此時,第二個參數 lambda 匿名函數所在的作用域為第一個參數傳入的對象,此時 IDE 的提示也指出了此時 with 的匿名函數中的作用域為 Person :

所以在這個匿名函數中能直接訪問或修改 Person 的屬性。
同理,我們也可以使用 run 函數改寫:
person.run{fullName="equationl"lastName="l"firstName="equation"age=24gender="man"}可以看出,run 與 with 非常相似,只是 run 是以擴展函數的形式接收上下文對象,它的參數只有一個 lambda 匿名函數。
後面還有 let :
person.let{it.fullName="equationl"it.lastName="l"it.firstName="equation"it.age=24it.gender="man"}它與 run 的區別在於,匿名函數中的上下文對象不再是隱式接收器(this),而是作為一個參數(it)存在。
使用 also() 則是:
person.also{it.fullName="equationl"it.lastName="l"it.firstName="equation"it.age=24it.gender="man"}和 let 一樣,它也是擴展函數,並且上下文也作為參數傳入匿名函數,但是不同於 let ,它會返回上下文對象,這樣可以方便的進行鏈式調用,如:
valpersonString=person.also{it.age=25}.toString()最後是 apply :
person.apply{fullName="equationl"lastName="l"firstName="equation"age=24gender="man"}與 also 一樣,它是擴展函數,也會返回上下文對象,但是它的上下文將作為隱式接收者,而不是匿名函數的一個參數。
下面是它們 5 個函數的對比圖和表格:

在前言中我們說過,在 Compose 對作用域限制的應用非常多。
例如 Modifier 修飾符,從Compose 修飾符列表中,我們也能看到很多修飾符的作用域都做了限制:
Compose 修飾符列表:https://developer.android.com/jetpack/compose/modifiers-list

這裡需要對修飾符做限制的原因非常簡單:
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 中還有很多作用域限制的例子,例如:

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

那麼,這些是怎麼實現的呢?
自定義作用域函數 原理在開始實現我們自己的作用域函數之前,我們需要先了解一下原理。
這裡我們以 Compose 的 Canvas 為例來看看。
首先是 Canvas 的定義:

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

可以看到這是一個接口,裡面定義了一些屬性變量(如我們上面說的 size) 和一些方法(如我們上面說的 drawRect )。
然後再實現這個接口,編寫具體實現代碼:

所以總結來說,如果我們想實現自己的作用域限制大致分為三步:
下面我們舉個例子。假如我們要在 Compose 中實現一個遮罩引導層,用於引導新用戶操作,類似這樣:


但是我們希望引導層上的提示可以多樣化,例如可以支持文字提示、圖片提示、甚至播放視頻或動圖提示,但是我們不希望這些提示 item 在遮罩層以外的地方被調用,因為它們依賴於遮罩層的某些參數,如果在外部調用會出錯。
這時候,使用作用域限制就非常合適。
首先,我們編寫一個接口:
interfaceShowcaseScreenScope{valisShowOnce:Boolean@ComposablefunShowcaseTextItem()}在這個接口中我們定義了一個屬性變量 isShowOnce 用於表示這個引導層是否只顯示一次、定義一個方法 ShowcaseTextItem 表示在引導層上顯示一串文字,同理我們還可以定義 ShowcaseImageItem 表示顯示圖片。
然後實現這個接口:
privateclassShowcaseScopeImpl:ShowcaseScreenScope{overridevalisShowOnce:Booleanget()=TODO("在這裡編寫是否只顯示一次的邏輯")@ComposableoverridefunShowcaseTextItem(){//在這裡寫你的實現代碼Text(text="我是說明文字")}}在接口實現中,根據我們的需求編寫相應的實現邏輯代碼。
最後,寫一個提供給外部調用的 Composable:
@ComposablefunShowcaseScreen(content:@ComposableShowcaseScreenScope.()->Unit){//在這裡實現其他邏輯(例如顯示遮罩)後調用content//……ShowcaseScopeImpl().content()}在這個 composable 中,我們可以先處理完其他邏輯,例如顯示遮罩層 UI 或顯示動畫後再調用 ShowcaseScopeImpl().content() 將我們傳遞的子 Item 組合上去。
最後,使用時只需要調用:
ShowcaseScreen{if(!isShowOnce){ShowcaseTextItem()}}當然,這個 ShowcaseTextItem() 和 isShowOnce 位於 ShowcaseScreenScope 作用域內,在外面是不能調用的:

本文簡要介紹了 Kotlin 中的作用域概念和標準庫中的作用域函數,並引申到 Compsoe 中關於作用域的應用,最終分析實現原理並講解如何自定義一個我們自己的 Compose 作用域函數。
本文寫的可能比較淺顯,很多知識點都是點到為止,沒有過多講解,推薦讀者閱讀完後,可以看看文末的參考鏈接中其他大佬寫的文章。
- FIN -
更多閱讀
Kotlin DSL 實戰:像 Compose 一樣寫代碼
Kotlin 標準庫隨處可見的 contract 到底是什麼?
謹慎使用 Kotlin 的成員擴展函數