写代码犹如写作文,有些代码言简意赅,而有些则啰里吧嗦。
这一篇从项目实战代码出发讲述如何使用 Kotlin 的域方法Scope functions
来简化啰嗦的代码。
本篇会包含如下 Kotlin 知识点:扩展函数、带接收者的lambda、apply()、also()、let()、run()、with()、安全调用运算符、Elvis运算符。
引子
在 Android 将多个动画组合在一起会用到 AnimatorSet
,
AnimatorSet animatorSet = new AnimatorSet(); ObjectAnimator objectAnimator = ObjectAnimator.ofPropertyValuesHolder( tvTitle, PropertyValuesHolder.ofFloat("alpha", 0f, 1.0f), PropertyValuesHolder.ofFloat("translationY", 0f, 100f) ); objectAnimator.setInterpolator(new AccelerateInterpolator()); objectAnimator.setDuration(300); ObjectAnimator objectAnimator2 = ObjectAnimator.ofPropertyValuesHolder( ivAvatar, PropertyValuesHolder.ofFloat("alpha", 0f, 1.0f), PropertyValuesHolder.ofFloat("translationY", 0f, 100f) ); objectAnimator2.setInterpolator(new AccelerateInterpolator()); objectAnimator2.setDuration(300); animatorSet.playTogether(objectAnimator, objectAnimator2); animatorSet.start();
上述代码用 Java 同时对 tvTitle 和 ivAvatar 控件做透明度和位移动画,并设置了动画时间和插值器。
整个代码的表达略显啰嗦,主要表现在冗余的对象名:animatorSet
,objectAnimator
,objectAnimator2
。其中第一个对象可能还有存在的价值,比如在某个时候停止或重播动画都需要它。而另外两个对象就显得很冗余,从它们的命令就可以看出很敷衍,其实我不想给他们取一个名字,因为它们是临时的对象,用完就弃。但为了给每个子动画设置属性,在 Java 中不得不声明一个对象。
而且得读到最后一行代码才知道这段代码的用意,代码的语义无法做到一目了然。
apply
为了解决这些问题,Kotlin 使用系统预定义了一系列域方法。当前场景就可以用到其中的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() }
首先代码中没有出现任何一个对象名,这得益于 apply() :
- object.apply() 接收一个 lambda 作为参数。它的语义是:将lambda应用于object对象,其中的 lambda 是一种特殊的 lambda,称为带接收者的lambda。这是 kotlin 中特有的,java 中没有。
带接收者的lambda的函数体除了能访问其所在类的成员外,还能访问接收者的所有非私有成员,这个特性是它具有魅力的关键。
上述代码中紧跟在 apply() 后的 lambda 函数体除了访问其外部的变量 span ,还访问了 AnimatorSet 的 playTogether() 和 start() 方法,就好像在 AnimatorSet 类内部一样。
(也可以在这两个函数前面加上this
,省略了更简洁)。
object.apply()
的另一个特点是:在它对 object 对象进行了一段操作后还会返回 object 对象本身。
apply()
的语义可以概括为 “我要构建一个对象并同时为其设置属性”。
其次,上述代码是有层次的。当去除了冗余对象名后,代码层次就水到渠成了。在最外层,构建的的对象是 AnimatorSet,其内部又构建了两个 ObjectAnimator 对象,并且它们被组织成一同播放。代码的层次瞬间表达出了这种层次关系(从属关系)。
原理
apply()
为啥会具有简化代码的魔力?下面是它的源码:
public inline fun <T> T.apply(block: T.() -> Unit): T { ... block() return this // 返回调用对象本身 }
apply 被声明为 T 的扩展方法
,T 表示泛型。扩展方法是在类体外为类新增功能的手段。在扩展函数中,可以像类的其他成员函数一样访问类对象以及它的公共属性和方法。
扩展方法本质是一个静态方法,并且方法的第一个参数是调用对象,这样在方法内部就能方便地访问到调用者。
在 apply 中,把调用者把自己作为 lambda 的接收者,这样在 lambda 内部就可以通过 this 来引用。
apply 在方法内部先执行了传入的 lambda,然后返回调用对象本身。
其中让 lambda 执行的block()
语法称为invoke约定
,它简化了 lambda 的执行(原型应该是block.invoke()
),关于约定背后原理的详细解析可以点击你的代码太啰嗦了 | 这么多方法调用?。
用一个简单的 demo 看看 apply() 语法糖背后的实现:
"abcd".apply { substring(0,1).length }
上述代码创建了一个 String 对象abcd
,然后对其调用 apply 方法,在其 lambda 内部调用 String.subString()取字串并计算长度。看看编译成 Java 代码是怎么样的:
String var2 = "abce"; // 原始对象 byte var6 = 0; byte var7 = 1; // 字串局部变量 String var10000 = var2.substring(var6, var7); var10000.length(); // 对局部变量求长度
看完 java 的实现就毫无神奇可言了,就是通过声明冗余布局变量实现的,作为 apply 参数的 lambda 和其调用对象处于同一个 Java 上下文中,所以在 lambda 中可以方便地访问到原始对象。
陷阱
回看 apply() 的定义:
public inline fun <T> T.apply(block: T.() -> Unit): T {}
apply 被声明为 T 的扩展方法,这里的 T 可以为 null。假设下面这个场景:
class Test { fun get():String { return "B" } } class MainActivity : AppCompatActivity(){ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val test:Test? = null test.apply { Log.d("test", "${get()}") } } fun get(): String { return "A" } }
你猜输出结果是 A 还是 B?
结果是 A,因为当前 test 对象是 null,所以 apply lambda 中的 this 也是 null。而${get()}
隐含的意思是${this.get()}
,显然这会报空指针异常。幸好 Activity 中又定义了一个同样签名的 get() 方法,所以就优先指向了它。显然这违背了我们的本意。
如果把 test 对象改为非空,结果就符合预期了:
class Test { fun get():String { return "B" } } class MainActivity : AppCompatActivity(){ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val test:Test = Test() test.apply { Log.d("test", "${get()}") // 输出 B } } fun get(): String { return "A" } }
这种方法指向对象的变换极具隐藏性,所以在使用 apply 时对于可控类型的调用要非常小心。
let()
let()
和apply()
非常像,但因为下面的两个区别,使得它的应用场景和 apply() 不太一样:
- 它接收一个普通的 lambda 作为参数。
- 它将 lambda 的值作为返回值。
在项目中有这样一个场景:启动一个 Fragment 并传 bundle 类型的参数,如果其中的 duration 值不为 0 则显示视图A,否则显示视图B。
public class FragmentA extends Fragment{ @Override public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); Bundle argument = getArguments(); if (argument != null) { Bundle bundle = argument.getBundle(KEY); if (bundle != null) { Long duration = bundle.get(DURATION); if (duration != 0) { showA(duration); } else { showB() } } } } }
其中声明了3个零时变量:argument,bundle,duration。并且分别对它们做了判空处理。
用 Kotlin 预定义的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()
的三个用法惯例:
- 通常情况下 let() 会和
安全调用运算符?
一起使用,即object?.let()
,它的语义是:如果object不为空则对它做一些操作,这些操作可以是调用它的方法,或者将它作为参数传递给另一个函数
和apply()
对比一下,因为 apply() 通常用于构建新对象( let() 用于既有对象),新建的对象不可能为空,所以不需要?
,而且就使用习惯而言,apply() 后的 lambda 中通常只有调用对象的方法,而不会将对象作为参数传递给另一个函数(虽然也可以这么做,只要传this
就可以)
- let() 也会结合
Elvis运算符?:
实现空值处理,当调用 let() 的对象为空时,其 lambda 中的逻辑不会被执行,如果需要指定此时执行的逻辑,可以使用?:
- 当 let() 嵌套时,显示地指明 lambda 参数名称避免
it
的歧义。在 kotlin 中如果 lambda 参数只有一个则可将参数声明省略,并用 it 指代它。但当 lambda 嵌套时,it 的指向就有歧义。所以代码中用arg
显示指明这是 Fragment 的参数,用duration
显示指明这是 Bundle 中的 duration。
除了上面这种用法,还可以把 let() 当做变换函数
使用,就好像 RxJava 中的map()
操作符。因为 let() 将 lambda 的值作为其返回值。
比如定义一个返回当前周一的毫秒时方法:
fun thisMondayInMillis() = Calendar.getInstance().let { c -> if (c.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) c.add(Calendar.DATE, -1) c.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY) c.set(Calendar.HOUR_OF_DAY, 0) c.set(Calendar.MINUTE, 0) c.set(Calendar.SECOND, 0) c.set(Calendar.MILLISECOND, 0) c.timeInMillis }
要构建的对象是 Calendar,要返回的确是毫秒时,并且毫秒时的获取依赖于构建的对象。
let() 的源码如下:
public inline fun <T, R> T.let(block: (T) -> R): R { return block(this) }
在方法内部执行了 lambda,并且将调用对象作为参数传入,以便可以通过 it 引用。
with()
上面这个计算毫秒时的例子依然有一些啰嗦的成分,因为有重复的对象名.方法()
。
with() 就用来对此进一步简化:
fun thisMondayInMillis() = with(Calendar.getInstance()) { if (get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) add(Calendar.DATE, -1) set(Calendar.DAY_OF_WEEK, Calendar.MONDAY) set(Calendar.HOUR_OF_DAY, 0) set(Calendar.MINUTE, 0) set(Calendar.SECOND, 0) set(Calendar.MILLISECOND, 0) timeInMillis }
所有的对象名都被隐藏了(默认隐藏 this)。
with() 的源码如下:
public inline fun <T, R> with(receiver: T, block: T.() -> R): R { return receiver.block() }
with() 不是一个扩展方法,而是一个顶层方法
,它就相当于 Java 中的静态函数,可以在任何地方访问到。
with() 的第一参数是一个对象,该对象会成为第二个 lambda 参数的接收者,这样 lambda 中就能通过 this 引用它。with() 的返回值是 lambda 的计算结果。
with 的语义可以概括为:我要用当前对象计算出另一个值。
run()
还有一个 with() 类似的方法:
public inline fun <T, R> T.run(block: T.() -> R): R { return block() }
调用对象也是作为 lambda 的接收者,并且将 lambda 的值作为整体返回值。
唯一的区别是,run() 是一个扩展方法。
用 run() 改造上面的例子:
fun thisMondayInMillis() = Calendar.getInstance().run { if (get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) add(Calendar.DATE, -1) set(Calendar.DAY_OF_WEEK, Calendar.MONDAY) set(Calendar.HOUR_OF_DAY, 0) set(Calendar.MINUTE, 0) set(Calendar.SECOND, 0) set(Calendar.MILLISECOND, 0) timeInMillis }
我想不出 run() 和 with() 具体的使用场景上的区别,完全看你是喜欢对象.run {}
还是with(对象) {}
。
also()
also()
几乎和 let() 相同,唯一的却别是它会返回调用者本身而不是将 lambda 的值作为返回值。
和同样返回调用者本身的apply()
相比:
- 就传参而言,apply() 传入的是带接收者的lambda,而 also() 传入的是普通 lambda。所以在 lambda 函数体中前者通过
this
引用调用者,后者通过it
引用调用者(如果不定义参数名字,默认为it)
- 就使用场景而言,
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 { bitmap -> //显示逻辑 ivImg.setImageResource(bitmap) } }
因为also()
返回的是调用者本身,所以可以用also()
将不同类型的逻辑分段,这样的代码更容易理解和修改。这个例子逻辑比较简单,只有一句话,将他们合并在一起也没什么不好。
also() 的源码如下:
public inline fun <T> T.also(block: (T) -> Unit): T { block(this) return this }
在方法内部执行 lambda 并返回对象本身。它的 lambda 不带有接收者,而是直接把调用者作为 lambda 的参数传入,所以不能通过 this 访问到调用者。
知识点总结
扩展函数
是一种可以在类体外为类新增功能的特性,在扩展函数体中可以访问类的成员(除了被private和protected修饰的成员)
带接收者的lambda
是一种特殊的lambda,在函数体中可以访问接收者的非私有成员。可以把它理解成接收者的扩展函数,只不过这个扩展函数没有函数名。
apply()
also()
let()
with()
run()
是系统预定义的扩展函数。它们被称为域方法scope funciton
,它们都用于在一个对象上执行一顿操作,并返回一个值。区别在于如何引用对象,以及返回值(详见下表)。域方法的价值在于将和对象相关的操作内聚在一个域(lambda)中,以减少冗余对象的声明,打到简化代码的效果。
?.
称为安全调用运算符,若object?.fun()
中的 object 为空,则fun()
不会被调用。
?:
称为Elvis运算符,它为 null 提供了默认逻辑,funA() ?: funB()
,如果 funA() 返回值不为 null 则执行它并将它的返回值作为整个表达式的返回值,否则执行 funB() 并采用它的返回值。
域方法 | 返回值 | 引用调用者方式 | 语义 |
apply | 调用者本身 | this(可省略,不可重命名) | 构建对象的同时设置属性 |
let | lambda 的值 | it(不可省略,可重命名) | 优雅的空安全写法 |
also | 调用者本身 | it(不可省略,可重命名) | 将对同一对象不同类型的操作分段处理 |
with | lambda 的值 | this(可省略,不可重命名) | 利用当前对象计算出另一个值 |
run | lambda 的值 | this(可省略,不可重命名) | 利用当前对象执行一段操作并返回另一个值 |
参考
Scope functions | Kotlin (kotlinlang.org)