前言
不知道各位是否已经开始了解 Jetpack Compose?
如果已经开始了解并且上手写过。那么,不知道你们有没有发现,在 Compose 中对于作用域(Scopes)的应用特别多。比如, weight
修饰符只能用在 RowScope
或者 ColumnScope
作用域中。又比如,item
组件只能用在 LazyListScope
作用域中。
如果你还没有了解过 Compose 的话,那你也应该知道,kotlin 标准库中有 5 个作用域函数:let()
apply()
also()
with()
run()
,这 5 个函数会以不同的方式持有和返回上下文对象,即调用这些函数时,在它们的 lambda 参数中写的代码将处于特定的作用域。
不知道你们有没有思考过,这些作用域限制是怎么实现的呢?如果我们想自定义一个 Composable 函数,只支持在特定的作用域中使用,应该怎么写呢?
本文将为你解开这个疑惑。
作用域
不过在正式开始之前我们还是先大概补充一点有关 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 文件,也就是说,只要是在这个文件中的代码,都可以访问到它。
而 valueA
和 valueB
的作用域则分别是在函数 a 和 b 中,显然只能在各自的作用域中使用。
同理,如果我们想要调用类的方法或者函数也需要考虑作用域:
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
函数中是无法访问到变量 valueTest
和 valueA
的,并且也无法调用函数 a()
;而在 Test
类中的函数 a()
显然可以访问到 valueTest
和 valueA
,并且函数 b()
也可以调用函数 a()
,可以访问变量 valueTest
但是无法访问变量 valueA
。
这是因为函数 a()
和 b()
以及变量 valueTest
位于同一个作用域中,即类 Test
的作用域。
而变量 valueA
位于函数 a()
的作用域内,由于 a()
又位于 Test
的作用域内,所以实际上这里的 valueA
的作用域称为嵌套作用域,即同时位于 a()
和 Test
的作用域内。
因为本节只是为了引出我们今天要介绍的内容,所以有关作用域的知识就简单介绍这么多,更多有关作用域的知识可以阅读参考资料 1 。
kotlin 标准库中的作用域函数
在前言中我们说过,kotlin标准库中有5个称之为作用域函数的东西:with
、run
、let
、also
、apply
。
它们有什么作用呢?
先看一段我们经常会遇到的代码形式:
val person = Person()
person.fullName = "equationl"
person.lastName = "l"
person.firstName = "equation"
person.age = 24
person.gender = "man"
在某些情况下,我们可能会需要多次重复的写一堆 person
,可读性很差,写起来也很繁琐。
此时我们就可以使用作用域函数,例如使用 with
改写:
with(person) {
fullName = "equationl"
lastName = "l"
firstName = "equation"
age = 24
gender = "man"
}
此时,我们就可以省略掉 person
,直接访问或修改它的属性值,这是因为 with
的第一个参数接收的是需要作为第二个参数的 lambda 上下文对象,即此时,第二个参数 lambda 匿名函数所在的作用域为第一个参数传入的对象,此时 IDE 的提示也指出了此时 with 的匿名函数中的作用域为 Person
:
所以在这个匿名函数中能直接访问或修改 Person 的属性。
同理,我们也可以使用 run
函数改写:
person.run {
fullName = "equationl"
lastName = "l"
firstName = "equation"
age = 24
gender = "man"
}
可以看出,run
与 with
非常相似,只是 run
是以扩展函数的形式接收上下文对象,它的参数只有一个 lambda 匿名函数。
后面还有 let
:
person.let {
it.fullName = "equationl"
it.lastName = "l"
it.firstName = "equation"
it.age = 24
it.gender = "man"
}
它与 run
的区别在于,匿名函数中的上下文对象不再是隐式接收器(this),而是作为一个参数(it)存在。
使用 also()
则是:
person.also {
it.fullName = "equationl"
it.lastName = "l"
it.firstName = "equation"
it.age = 24
it.gender = "man"
}
和 let
一样,它也是扩展函数,并且上下文也作为参数传入匿名函数,但是不同于 let
,它会返回上下文对象,这样可以方便的进行链式调用,如:
val personString = person
.also {
it.age = 25
}
.toString()
最后是 apply
:
person.apply {
fullName = "equationl"
lastName = "l"
firstName = "equation"
age = 24
gender = "man"
}
与 also
一样,它是扩展函数,也会返回上下文对象,但是它的上下文将作为隐式接收者,而不是匿名函数的一个参数。
下面是它们 5 个函数的对比图和表格:
函数 | 上下文形式 | 返回值 | 是否是扩展函数 |
---|---|---|---|
with | 隐式接收者(this) | lambda函数(Unit) | 否 |
run | 隐式接收者(this) | lambda函数(Unit) | 是 |
let | 匿名函数的参数(it) | lambda函数(Unit) | 是 |
also | 匿名函数的参数(it) | 上下文对象 | 是 |
apply | 隐式接收者(this) | 上下文对象 | 是 |
Compose 中的作用域限制
在前言中我们说过,在 Compose 对作用域限制的应用非常多。
例如 Modifier 修饰符,从这个 Compose 修饰符列表 中,我们也能看到很多修饰符的作用域都做了限制:
这里需要对修饰符做限制的原因非常简单:
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
)。
然后再实现这个接口,编写具体实现代码:
实现
所以总结来说,如果我们想实现自己的作用域限制大致分为三步:
- 编写作为作用域的接口
- 实现这个接口
- 在暴露的方法中将 lambda 参数接收者使用上面定义的接口
下面我们举个例子。
假如我们要在 Compose 中实现一个遮罩引导层,用于引导新用户操作,类似这样:
图源 Intro-showcase-view
但是我们希望引导层上的提示可以多样化,例如可以支持文字提示、图片提示、甚至播放视频或动图提示,但是我们不希望这些提示 item 在遮罩层以外的地方被调用,因为它们依赖于遮罩层的某些参数,如果在外部调用会出错。
这时候,使用作用域限制就非常合适。
首先,我们编写一个接口:
interface ShowcaseScreenScope {
val isShowOnce: Boolean
@Composable
fun ShowcaseTextItem()
}
在这个接口中我们定义了一个属性变量 isShowOnce
用于表示这个引导层是否只显示一次、定义一个方法 ShowcaseTextItem
表示在引导层上显示一串文字,同理我们还可以定义 ShowcaseImageItem
表示显示图片。
然后实现这个接口:
private class ShowcaseScopeImpl: ShowcaseScreenScope {
override val isShowOnce: Boolean
get() = TODO("在这里编写是否只显示一次的逻辑")
@Composable
override fun ShowcaseTextItem() {
// 在这里写你的实现代码
Text(text = "我是说明文字")
}
}
在接口实现中,根据我们的需求编写相应的实现逻辑代码。
最后,写一个提供给外部调用的 Composable:
@Composable
fun ShowcaseScreen(content: @Composable ShowcaseScreenScope.() -> Unit) {
// 在这里实现其他逻辑(例如显示遮罩)后调用 content
// ……
ShowcaseScopeImpl().content()
}
在这个 composable 中,我们可以先处理完其他逻辑,例如显示遮罩层 UI 或显示动画后再调用 ShowcaseScopeImpl().content()
将我们传递的子 Item 组合上去。
最后,使用时只需要调用:
ShowcaseScreen {
if (!isShowOnce) {
ShowcaseTextItem()
}
}
当然,这个 ShowcaseTextItem()
和 isShowOnce
位于 ShowcaseScreenScope
作用域内,在外面是不能调用的:
总结
本文简要介绍了 Kotlin 中的作用域概念和标准库中的作用域函数,并引申到 Compsoe 中关于作用域的应用,最终分析实现原理并讲解如何自定义一个我们自己的 Compose 作用域函数。
本文写的可能比较浅显,很多知识点都是点到为止,没有过多讲解,推荐读者阅读完后,可以看看文末的参考链接中其他大佬写的文章。
参考资料
- Scopes and Scope Functions
- Kotlin DSL 实战:像 Compose 一样写代码
- Scope composables to a parent composable
- Compose modifiers-Type safety in Compose