你的代码太啰嗦了 | 这么多对象名?

4,785 阅读11分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

写代码犹如写作文,有些代码言简意赅,而有些则啰里吧嗦。

这一篇从项目实战代码出发讲述如何使用 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 控件做透明度和位移动画,并设置了动画时间和插值器。

整个代码的表达略显啰嗦,主要表现在冗余的对象名:animatorSetobjectAnimatorobjectAnimator2。其中第一个对象可能还有存在的价值,比如在某个时候停止或重播动画都需要它。而另外两个对象就显得很冗余,从它们的命令就可以看出很敷衍,其实我不想给他们取一个名字,因为它们是临时的对象,用完就弃。但为了给每个子动画设置属性,在 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() :

  1. object.apply() 接收一个 lambda 作为参数。它的语义是:将lambda应用于object对象,其中的 lambda 是一种特殊的 lambda,称为带接收者的lambda。这是 kotlin 中特有的,java 中没有。

    带接收者的lambda的函数体除了能访问其所在类的成员外,还能访问接收者的所有非私有成员,这个特性是它具有魅力的关键。

    上述代码中紧跟在 apply() 后的 lambda 函数体除了访问其外部的变量 span ,还访问了 AnimatorSet 的 playTogether() 和 start() 方法,就好像在 AnimatorSet 类内部一样。(也可以在这两个函数前面加上this,省略了更简洁)。

  2. 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() 不太一样:

  1. 它接收一个普通的 lambda 作为参数。
  2. 它将 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()的三个用法惯例:

  1. 通常情况下 let() 会和安全调用运算符?一起使用,即object?.let(),它的语义是:如果object不为空则对它做一些操作,这些操作可以是调用它的方法,或者将它作为参数传递给另一个函数

    apply()对比一下,因为 apply() 通常用于构建新对象( let() 用于既有对象),新建的对象不可能为空,所以不需要?,而且就使用习惯而言,apply() 后的 lambda 中通常只有调用对象的方法,而不会将对象作为参数传递给另一个函数(虽然也可以这么做,只要传this就可以)

  2. let() 也会结合Elvis运算符?:实现空值处理,当调用 let() 的对象为空时,其 lambda 中的逻辑不会被执行,如果需要指定此时执行的逻辑,可以使用?:

  3. 当 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()相比:

  1. 就传参而言,apply() 传入的是带接收者的lambda,而 also() 传入的是普通 lambda。所以在 lambda 函数体中前者通过this引用调用者,后者通过it引用调用者(如果不定义参数名字,默认为it)
  2. 就使用场景而言,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(可省略,不可重命名)构建对象的同时设置属性
letlambda 的值it(不可省略,可重命名)优雅的空安全写法
also调用者本身it(不可省略,可重命名)将对同一对象不同类型的操作分段处理
withlambda 的值this(可省略,不可重命名)利用当前对象计算出另一个值
runlambda 的值this(可省略,不可重命名)利用当前对象执行一段操作并返回另一个值

参考

Kotlin(run,apply)陷阱

Scope functions | Kotlin (kotlinlang.org)

推荐阅读

业务代码参数透传满天飞?(一)

业务代码参数透传满天飞?(二)

全网最优雅安卓控件可见性检测

全网最优雅安卓列表项可见性检测

页面曝光难点分析及应对方案

你的代码太啰嗦了 | 这么多对象名?

你的代码太啰嗦了 | 这么多方法调用?