Kotlin 进阶 | 动画代码太丑,用 DSL 动画库拯救

本贴最后更新于 1629 天前,其中的信息可能已经事过景迁

Android 构建动画的代码语法啰嗦,可读性差。若能构建一套可读性更强的接口就能提高动画的开发效率。本文尝试用 Kotlin 的 DSL 重写了整套构建动画的 API ,使得构建动画的代码量锐减,语义一目了然。另外,Android 提供了反转动画的接口,但只有在 API level 26 以上才能使用,本文尝试突破这个限制。

原生动画代码

假设需求如下:“缩放 textView 的同时平移 button ,然后拉长 imageView,动画结束后 toast 提示”。用系统原生接口构建如下:

PropertyValuesHolder scaleX = PropertyValuesHolder.ofFloat("scaleX", 1.0f, 1.3f);
PropertyValuesHolder scaleY = PropertyValuesHolder.ofFloat("scaleY", 1.0f, 1.3f);
ObjectAnimator tvAnimator = ObjectAnimator.ofPropertyValuesHolder(textView, scaleX, scaleY);
tvAnimator.setDuration(300);
tvAnimator.setInterpolator(new LinearInterpolator());

PropertyValuesHolder translationX = PropertyValuesHolder.ofFloat("translationX", 0f, 100f);
ObjectAnimator btnAnimator = ObjectAnimator.ofPropertyValuesHolder(button, translationX);
btnAnimator.setDuration(300);
btnAnimator.setInterpolator(new LinearInterpolator());

ValueAnimator rightAnimator = ValueAnimator.ofInt(ivRight, screenWidth);
rightAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        int right = ((int) animation.getAnimatedValue());
        imageView.setRight(right);
    }
});
rightAnimator.setDuration(400);
rightAnimator.setInterpolator(new LinearInterpolator());

AnimatorSet animatorSet = new AnimatorSet();
animatorSet.play(tvAnimator).with(btnAnimator);
animatorSet.play(tvAnimator).before(rightAnimator);
animatorSet.addListener(new Animator.AnimatorListener() {
    @Override
    public void onAnimationStart(Animator animation) {}
    @Override
    public void onAnimationEnd(Animator animation) {
        Toast.makeText(activity,"animation end" ,Toast.LENGTH_SHORT).show();
    }
    @Override
    public void onAnimationCancel(Animator animation) {}
    @Override
    public void onAnimationRepeat(Animator animation) {}
});
animatorSet.start();

啰嗦!而且乍一看不知道在做啥,只能一行一行的细看,待看完整段代码后,才能在脑海中构建出整个需求的样子。

但逐行看也很费劲,不信就试着从第一行开始读:

创建一个横向缩放属性
创建一个纵向缩放属性
创建一个动画,这个动画施加在 textView 上,并且包含缩放和透明度属性
动画时长300毫秒
动画使用线性插值器

原生 API 将“缩放 textView ”这短短的一句话拆分成一个个零散的逻辑单元,并以一种不符合自然语言的顺序排列,所以不得不读完所有单元,才能拼凑出整个语义。

如果有一种更符合自然语言的 API,就能更省力地构建动画,更快速地理解代码。

用 Kotlin 预定义扩展函数简化代码

AnimatorSet().apply {
    ObjectAnimator.ofPropertyValuesHolder(
            textView,
            PropertyValuesHolder.ofFloat("scaleX", 1.0f, 1.3f),
            PropertyValuesHolder.ofFloat("scaleY", 1.0f, 1.3f)
    ).apply {
        duration = 300L
        interpolator = LinearInterpolator()
    }.let {
        play(it).with(
                ObjectAnimator.ofPropertyValuesHolder(
                        button,
                        PropertyValuesHolder.ofFloat("translationX", 0f, 100f)
                ).apply {
                    duration = 300L
                    interpolator = LinearInterpolator()
                }
        )
        play(it).before(
                ValueAnimator.ofInt(ivRight,screenWidth).apply { 
                    addUpdateListener { animation -> imageView.right= animation.animatedValue as Int }
                    duration = 400L
                    interpolator = LinearInterpolator()
                }
        )
    }
    addListener(object : Animator.AnimatorListener {
        override fun onAnimationRepeat(animation: Animator?) {}
        override fun onAnimationEnd(animation: Animator?) {
            Toast.makeText(activity,"animation end",Toast.LENGTH_SHORT).show()
        }
        override fun onAnimationCancel(animation: Animator?) {}
        override fun onAnimationStart(animation: Animator?) {}
    })
    start() 
}

使用 apply()let() 避免了重复对象名,缩减了代码量。更重要的是 Kotlin 的代码有一种结构,这种结构让代码更符合自然语言。试着读一下:

构建动画集,它包含{
    动画1
    将动画1和动画2一起播放
    将动画3在动画1之后播放
    。。。
}

虽然在语义上已经比较清晰,但结构还是显得啰嗦,此起彼伏的缩进看着有点乱。

用 DSL 进一步简化代码

如果使用自定义的 DSL,就可以做的更好!

直接上代码:

animSet {
    objectAnim {
        target = textView
        scaleX = floatArrayOf(1.0f,1.3f)
        scaleY = scaleX
        duration = 300L
        interpolator = LinearInterpolator()
    } with objectAnim {
        target = button
        translationX = floatArrayOf(0f,100f)
        duration = 300
        interpolator = LinearInterpolator()
    } before anim {
        values = intArrayOf(ivRight,screenWidth)
        action = { value -> imageView.right = value as Int }
        duration = 400
        interpolator = LinearInterpolator()
    }
    onEnd = Toast.makeText(activity,"animation end",Toast.LENGTH_SHORT).show()
    start()
}

一目了然的语义和清晰的结构,就好像是一篇英语文章。

这里运用了多个 Kotlin 语言特性,包括扩展函数、带接收者的 lambda、顶层函数、抽象属性、属性访问器、中缀表示法、函数类型变量、apply()、also()、let()。

逐个讲解 Kotlin 语法知识点后,再分析整套 DSL 的实现方案。

带接收者的 lambda

代码中 animSet()objectAnim()anim() 都是带有一个参数的函数,这个参数是 带接受者的 lambdaanimSet() 代码如下:

fun animSet(creation: AnimSet.() -> Unit) = AnimSet().apply { creation() }.also { it.build() }

它是一个顶层函数,定义在类体外,即它不隶属于任何类。这样定义的目的是可以在任何地方调用 animSet() 来构造动画集。

它的参数类型是一个带接收者的 lambda AnimSet.() -> Unit,接收者是 AnimSet 类,它表示动画集(类似 AnimatorSet)。这样定义的好处是,可以在传入 animSet() 的 lambda 中访问 AnimSet 中的非私有成员,若把构建单个动画的方法 objectAnim()anim() 定义在 AnimSet() 中,就可以像写 HTML 一样使用结构化的语法构建动画。所以参数 creation 描述的是在动画集中构建动画的过程。

animSet() 在函数体中,创建了一个动画集 AnimSet 实例,并将构建子动画的方法应用在此实例上。

关于 带接收者的lambdaapply()also()let() 更详细的讲解可以点击这里

构建动画的方法定义如下:

class AnimSet {
    //'构建ValueAnim'
    fun anim(animCreation: ValueAnim.() -> Unit): Anim = ValueAnim().apply(animCreation).also { anims.add(it) }

    //'构建ObjectAnim'
    fun objectAnim(animCreation: ObjectAnim.() -> Unit): Anim = ObjectAnim().apply(animCreation).also { it.setPropertyValueHolder() }.also { anims.add(it) }
}

这两个函数和构建动画集的函数非常相似,都使用了 带接收者的lambda 作为参数,它定义了如何构建动画。ValueAnimObjectAnim 分别对应于原生的 ValueAnimatorObjectAnimator。它们有一个共同的基类 Anim 对应于原生的 Animator

abstract class Anim {
    //'原生动画实例'
    abstract var animator: ValueAnimator
    //'动画时长'
    var duration
        get() = 300L
        set(value) {
            animator.duration = value
        }
    //'插值器'
    var interpolator
        get() = LinearInterpolator() as Interpolator
        set(value) {
            animator.interpolator = value
        }
    //'动画与动画之间的连机器'
    var builder:AnimatorSet.Builder? = null
    //'反转动画'
    abstract fun reverseValues()
}

抽象属性

动画基类 Anim 是抽象类,因为 animator 属性和 reverseValues() 方法是抽象的。

animator 属性对于 ValueAnim 来说是 ValueAnimator 实例,对于 ObjectAnim 来说是 ObjectAnimator 实例:

class ObjectAnim : Anim() {
    override var animator: ValueAnimator = ObjectAnimator()
}

class ValueAnim : Anim() {
    override var animator: ValueAnimator = ValueAnimator()
}

关于抽象属性更详细的介绍可以点击这里

反转动画的算法对于 ValueAnimObjectAnim 有所不同,将反转算法作为抽象函数放在基类的好处时,在动画集 AnimSet 中可以无需关心算法细节而是直接调用 reverseValues() 实现反转动画:

class AnimSet {
    //'动画集中包含的所有子动画'
    private val anims by lazy { mutableListOf<Anim>() }
    fun reverse() {
        if (animatorSet.isRunning) return
        //'遍历所有动画并让其反转'
        anims.takeIf { !isReverse }?.forEach { anim -> anim.reverseValues() }
        animatorSet.start()
        isReverse = true
    }
}

反转动画的算法会在下面分析,先来看下一个用到的 Kotlin 特性。

属性访问器

var duration
    get() = 300L
    set(value) {
        animator.duration = value
    }

在类属性的下面实现 set()get() 方法,这样的语法叫属性访问器。当定义了访问器的属性被赋值时,set() 函数会执行,属性被读取时,get() 函数会执行,所以访问器定义了属性值的读写算法

访问器在这里的好处是提供了默认值并隐藏了赋值细节,如果在构建动画时没有提供 duration ,则默认为 300ms,为 Anim 实例设置 duration 时,其实就是调用了原生的 ValueAnimator.setDuration() 方法,属性访问器隐藏了这一细节,使得可以使用如下这样简洁的语法构建动画:

anim{
    values = intArrayOf(ivRight,screenWidth)
    action = { value -> imageView.right = value as Int }
    duration = 400 //'为动画设置时长'
    interpolator = LinearInterpolator()
}

函数类型

构建单个动画进行了 4 个属性赋值操作。其中 action 属性表示“如何将动画值的序列应用到 View 上”:

class ValueAnim : Anim() {
    override var animator: ValueAnimator = ValueAnimator()
    var action: ((Any) -> Unit)? = null
        set(value) {
            field = value
            animator.addUpdateListener { valueAnimator ->
                valueAnimator.animatedValue.let { value?.invoke(it) }
            }
        }
}

Kotlin 中可以将函数保存在一个变量中,这种变量的类型叫做 函数类型action 的类型就是 函数类型,用 ((Any) -> Unit)? 描述,意思是这个函数接收一个 Any 类型的参数但什么也不返回。

这个属性也用到了访问器,当 action 被赋值时就会为原生动画设置 AnimatorUpdateListener,并将属性值变化的序列作为参数传递给存放在 action 中的 lambda,这样在构建动画时,就可以用一个简单的 lambda 定义做什么样的动画,比如下面就是在做向右平移动画:

anim{
    values = floatArrayOf(0f,100f)
    action = { value -> imageView.translationX = value as Float }
    duration = 400
    interpolator = LinearInterpolator()
}

其中的 values 属性表示动画值序列:

class ValueAnim : Anim() {
    var values: Any? = null
        set(value) {
            field = value
            value?.let {
                //'构建ValueAnimator对象'
                when (it) {
                    is FloatArray -> animator.setFloatValues(*it)
                    is IntArray -> animator.setIntValues(*it)
                    else -> throw IllegalArgumentException("unsupported value type")
                }
            }
        }
}

values 属性也使用了访问器,将根据类型调用 ValueAnimator.setXXXValue() 细节隐藏。

中缀表示法

Kotlin 中,当函数调用只有一个参数时,可以省略包括参数的 (),以让代码更简洁,更符合自然语言,这种表示法叫中缀表示法。上述代码中用于连接多个动画的 before() 函数就使用了中缀表示法:

infix fun Anim.before(anim: Anim): Anim {
    animatorSet.play(animator).before(anim.animator).let { this.builder = it }
    return anim
}

中缀表示的方法必须以关键词 infix 开头,且函数只能有一个参数。同时这也是一个 Anim 类的扩展函数。这个函数的调用者、参数、返回值都是一个 Anim 实例。所以可以像 a1 with a2 with a3 这样将多个 Anim 连接起来。(连接动画的原理会在下面分析。)

实现方案

将从“如何构建 Object 动画”、“如何反转动画”、“如何连接动画”这三个方面来分析整套 DSL 的实现方法,关于 DSL 更详细的解释可以点击这里

构建 ObjectAnim

整套 DSL 并不是实现一个全新的动画框架。而是将原生动画提供的接口通过 DSL 封装成结构化的 API 以减少代码量并增加可读性。

ObjectAnim 中定义了属性用于存放动画值序列:

class ObjectAnim : Anim() {
    //'构建空ObjectAnimator对象'
    override var animator: ValueAnimator = ObjectAnimator()
    //'各个属性值序列'
    var translationX: FloatArray? = null
    var translationY: FloatArray? = null
    var scaleX: FloatArray? = null
    var scaleY: FloatArray? = null
    var alpha: FloatArray? = null
    //'用数组存放非空的属性值序列'
    private val valuesHolder = mutableListOf<PropertyValuesHolder>()

当调用如下代码时,属性被赋值:

objectAnim {
    target = textView
    scaleX = floatArrayOf(1.0f,1.3f)
    scaleY = scaleX
    duration = 300L
    interpolator = LinearInterpolator()
}

因为并不知道,每个动画会为哪些属性赋值,所以不能调用 ObjectAnimator.ofPropertyValuesHolder(textView, scaleX, scaleY); 来构建 ObjectAnimator 对象。而只能用一个数组存放所有被赋值的属性,并且通过遍历数组调用 ObjectAnimator.setValues() 异步构建 ObjectAnimator 对象:

class AnimSet {
    fun objectAnim(action: ObjectAnim.() -> Unit): Anim = ObjectAnim().apply(action).also { it.setPropertyValueHolder() }.also { anims.add(it) }
}

class ObjectAnim : Anim() {
    fun setPropertyValueHolder() {
        //'遍历所有属性序列,如果非空则构建PropertyValuesHolder并将其加入到集合中'
        translationX?.let { PropertyValuesHolder.ofFloat(TRANSLATION_X, *it) }?.let { valuesHolder.add(it) }
        translationY?.let { PropertyValuesHolder.ofFloat(TRANSLATION_Y, *it) }?.let { valuesHolder.add(it) }
        scaleX?.let { PropertyValuesHolder.ofFloat(SCALE_X, *it) }?.let { valuesHolder.add(it) }
        scaleY?.let { PropertyValuesHolder.ofFloat(SCALE_Y, *it) }?.let { valuesHolder.add(it) }
        alpha?.let { PropertyValuesHolder.ofFloat(ALPHA, *it) }?.let { valuesHolder.add(it) }
        animator.setValues(*valuesHolder.toTypedArray())
    }
}

反转动画

反转动画的思路是:“将动画值序列倒序并重新播放动画”。动画基类 AnimSet 中定义了反转算法的抽象方法:

abstract class Anim {
    abstract fun reverseValues()
}

ValueAnimator 重写如下:

class ValueAnim : Anim() {
    override var animator: ValueAnimator = ValueAnimator()
    //'属性值序列,它是ValueAnim必须的属性'
    var values: Any? = null
        set(value) {
            field = value
            value?.let {
                //'根据类型将属性值序列设置给ValueAnimator'
                when (it) {
                    is FloatArray -> animator.setFloatValues(*it)
                    is IntArray -> animator.setIntValues(*it)
                    else -> throw IllegalArgumentException(’unsupported value type’)
                }
            }
        }
  
    override fun reverseValues() {
        values?.let {
            //'将属性值序列原地翻转并重新应用到ValueAnimator上'
            when (it) {
                is FloatArray -> {
                    it.reverse()
                    animator.setFloatValues(*it)
                }
                is IntArray -> {
                    it.reverse()
                    animator.setIntValues(*it)
                }
                else -> throw IllegalArgumentException("unsupported type of value")
            }
        }
    }
}

AnimSet 提供反转动画对的外接口:

class AnimSet {
    //'动画集所有子动画'
    private val anims by lazy { mutableListOf<Anim>() }
    //'反转动画中所有子动画'
    fun reverse() {
        if (animatorSet.isRunning) return
        //'逐个调用Anim.reverseValues()'
        anims.takeIf { !isReverse }?.forEach { anim -> anim.reverseValues() }
        animatorSet.start()
        isReverse = true
    }
}

ObjectAnim 的反转算法略有不同:

class ObjectAnim : Anim() {
    //'属性序列'
    var translationX: FloatArray? = null
    var translationY: FloatArray? = null
    var scaleX: FloatArray? = null
    var scaleY: FloatArray? = null
    var alpha: FloatArray? = null
    //'属性序列集合'
    private val valuesHolder = mutableListOf<PropertyValuesHolder>()
    //'遍历属性序列集合并翻转对应属性序列'
    override fun reverseValues() {
        valuesHolder.forEach { valuesHolder ->
            when (valuesHolder.propertyName) {
                TRANSLATION_X -> translationX?.let {
                    it.reverse()
                    valuesHolder.setFloatValues(*it)
                }
                TRANSLATION_Y -> translationY?.let {
                    it.reverse()
                    valuesHolder.setFloatValues(*it)
                }
                SCALE_X -> scaleX?.let {
                    it.reverse()
                    valuesHolder.setFloatValues(*it)
                }
                SCALE_Y -> scaleY?.let {
                    it.reverse()
                    valuesHolder.setFloatValues(*it)
                }
                ALPHA -> alpha?.let {
                    it.reverse()
                    valuesHolder.setFloatValues(*it)
                }
            }
        }
    }
}

连接动画

DSL 中的连接方案抛弃了 AnimatorSet.playTogether()playSequentially(),而是采用更加灵活的 AnimtorSet.Builder 方式。

被加入到 AnimatorSetAnimator 会被保存在 N 这个结构中:

public final class AnimatorSet extends Animator {
    private static class Node implements Cloneable {
        Animator mAnimation;
        //孩子列表
        ArrayList<Node> mChildNodes = null;
        //兄弟列表
        ArrayList<Node> mSiblings;
        //父亲列表
        ArrayList<Node> mParents;
    }
}

Animator 之间的播放顺序关系通过三个列表维护。兄弟列表中的动画会和自己同时播放,孩子列表会晚于自己播放,父亲列表会早于自己播放。

为了向这三个列表填值,系统定义了 Builder 类:

public final class AnimatorSet extends Animator {
    public class Builder {
        private Node mCurrentNode;
        //'为当前动画构建新结点'
        Builder(Animator anim) {
            mDependencyDirty = true;
            mCurrentNode = getNodeForAnimation(anim);
        }
        //'向当前动画的兄弟列表中添加动画'
        public Builder with(Animator anim) {
            Node node = getNodeForAnimation(anim);
            mCurrentNode.addSibling(node);
            return this;
        }
        //'向当前动画的孩子列表中添加动画'
        public Builder before(Animator anim) {
            Node node = getNodeForAnimation(anim);
            mCurrentNode.addChild(node);
            return this;
        }
    }
    //'只能通过这个方法构建Builder'
    public Builder play(Animator anim) {
        if (anim != null) {
            return new Builder(anim);
        }
        return null;
    }
}

同时播放 a1,a2,a3 动画,只需要这样调用 java API:

AnimatorSet set = new AnimatorSet();
set.play(a1).with(a2).with(a3);

此时结点间只有一个层级,即 a1 在外层,a2 和 a3 存放在 a1 的兄弟列表中。 将上述 java 代码转换成 Kotlin 的中缀表示法如下:

class AnimSet {
    private val animatorSet = AnimatorSet()
  
    infix fun Anim.with(anim: Anim): Anim {
        //'当前动画没有Builder,则调用play()构建Builder,否则直接调用with()'
        if (builder == null) builder = animatorSet.play(animator).with(anim.animator)
        else builder?.with(anim.animator)
        return anim
    }
}

abstract class Anim {
    //'动画对应的Builder'
    var builder:AnimatorSet.Builder? = null
}

因为同时播放的动画只有一个层级,所以调用链中,只需要第一个动画调用一次 play() 即可。为 Anim 增加了 builder 属性以判断当前动画是否调用过 play() 来创建结点。

相比之下,顺序播放的代码层级就变多了,如果要先播放 a1,再播放 a2,最后播放 a3,java api 如下:

AnimatorSet set = new AnimatorSet();
set.play(a1).before(a2);
set.play(a2).before(a3);

这个结构有点像树,后续结点是之前结点的孩子。对应的中缀表达式定义如下:

class AnimSet {
    infix fun Anim.before(anim: Anim): Anim {
        animatorSet.play(animator).before(anim.animator).let { this.builder = it }
        return anim
    }
}

每次都为当前动画调用 play() 创建 Builder 并将后续动画存入孩子列表。

推荐阅读

  1. Kotlin 基础 | 白话文转文言文般的 Kotlin 常识
  2. Kotlin 基础 | 望文生义的 Kotlin 集合操作
  3. Kotlin 实战 | 用实战代码更深入地理解预定义扩展函数
  4. Kotlin 实战 | 使用 DSL 构建结构化 API 去掉冗余的接口方法
  5. Kotlin 基础 | 抽象属性的应用场景
  6. Kotlin 进阶 | 动画代码太丑,用 DSL 动画库拯救
  • Kotlin

    Kotlin 是一种在 Java 虚拟机上运行的静态类型编程语言,由 JetBrains 设计开发并开源。Kotlin 可以编译成 Java 字节码,也可以编译成 JavaScript,方便在没有 JVM 的设备上运行。在 Google I/O 2017 中,Google 宣布 Kotlin 成为 Android 官方开发语言。

    19 引用 • 33 回帖 • 63 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...