Compose动画原理-我的一点小思考

思想

Compose的动画系统是基于值系统的动画,和传统的基于回调的动画不同,Compose的动画api通常对外暴露一个可观察的随时间改变的状态,进而驱动重组或者重绘,从而达成动画的效果

基本使用

可见性动画使用AnimatedVisibility,内容尺寸动画用animateContentSize,根据不同的状态展示不同的Composable之间的动画切换使用AnimatedContent,也可以使用Crossfade

简单的值动画使用使用animateXXAsState,多个动画同时启用,可以用Transition进行管理,Transition可以基于状态启动多个动画

如果需要在启动的时候就进行一个动画,推荐使用Transition或者Animtable动画接口,通过在可组合函数中声明一个基础状态,在compose中启动该动画是一个副作用效应,应该在LaunchEffect中进行,Animatable提供的animateTo是一个中断函数,直到动画状态进行到目标状态时才会恢复,通过该特性我们可以执行序列化的动画,同样地,如果我们想要动画同时执行的话,可以通过启动多个协程来达成这一点

Compose动画api的设计

底层动画为实现Animation接口的两个对象,一个是TargetBasedAnimation,提供了从起始值到目标值变更的动画逻辑,DecayAnimation 一个无状态的动画,提供了一个从初始速度渐渐慢下来的动画逻辑(滑动动画)

Animation的目标是提供无状态的动画逻辑,因此他是一个底层组件,顶层组件在Animation的基础上构建有状态的动画逻辑,设计为无状态的接口,意味着不同于安卓View动画,是没有pause,cancel等逻辑的,换句话说,如果使用底层动画api创建的动画被取消了,并且需要重新开始,这种情况下应该重新创建一个底层动画的实例,并且初始值和初始速度为之前取消时的当前值和当前速度

Animatable是在Animation的基础上封装的有状态的动画api,提供停止和开始动画的能力

Animatable使用

  1. 创建动画实例,指定初始值,如果是在compose中使用,要用remember保存起来
java 复制代码
val animatable = remember { Animatable(targetValue, typeConverter, visibilityThreshold, label) }
  1. 启动动画

根据动画是以值动画为基础,还是以速度为基础,分别调用不同的api,前者是animateTo,后者是animateDecay,这两个api实际上也对应了底层两个不同的Animation类型,

可以看到在启动动画这里

java 复制代码
animatable.animateTo(newTarget, animSpec)
  1. 停止动画

分为两种两种,一种是我们需要手动停止动画,另外一种是随着需要动画的composable退出组合自动停止动画,其中手动停止动画,直接调用Animatable的stop方法,另外一种是自动停止动画,只需要在使用该动画的composable处使用LaunchEffect启动动画就好

手动停止动画会设置一个flag,在动画执行的过程中会检查该flag,从而达到停止动画的目的,而自动停止动画,则是通过协程的取消机制来保证的,

java 复制代码
suspend fun animateTo(
        targetValue: T,
        animationSpec: AnimationSpec<T> = defaultSpringSpec,
        initialVelocity: T = velocity,
        block: (Animatable<T, V>.() -> Unit)? = null
    ): AnimationResult<T, V> {
        val anim = TargetBasedAnimation(
            animationSpec = animationSpec,
            initialValue = value,
            targetValue = targetValue,
            typeConverter = typeConverter,
            initialVelocity = initialVelocity
        )
        return runAnimation(anim, initialVelocity, block)
    }

private suspend fun runAnimation(
        animation: Animation<T, V>,
        initialVelocity: T,
        block: (Animatable<T, V>.() -> Unit)?
    ): AnimationResult<T, V> {

        // Store the start time before it's reset during job cancellation.
        val startTime = internalState.lastFrameTimeNanos
        return mutatorMutex.mutate {
            try {
                internalState.velocityVector = typeConverter.convertToVector(initialVelocity)
                targetValue = animation.targetValue
                isRunning = true

                val endState = internalState.copy(
                    finishedTimeNanos = AnimationConstants.UnspecifiedTime
                )
                var clampingNeeded = false
                endState.animate(
                    animation,
                    startTime
                ) {
                    updateState(internalState)
                    val clamped = clampToBounds(value)
                    if (clamped != value) {
                        internalState.value = clamped
                        endState.value = clamped
                        block?.invoke(this@Animatable)
                        cancelAnimation()
                        clampingNeeded = true
                    } else {
                        block?.invoke(this@Animatable)
                    }
                }
                val endReason = if (clampingNeeded) BoundReached else Finished
                endAnimation()
                AnimationResult(endState, endReason)
            } catch (e: CancellationException) {
                // Clean up internal states first, then throw.
                endAnimation()
                throw e
            }
        }
    }

动画被放在mutatorMutex.mutate逻辑中,这个函数被设计成启动成获取新锁的时候会取消前一个协程,等前一个协程取消时,再重新获取锁,保证同一时间只有一段代码在访问临界区的Animatable相关状态,可以看到多次调用Animatable的animateTo方法,会将当前动画的进度和速度保存下来,作为新的起点,因此如果动画进行到一半,动画被取消了,动画会从一半的进度继续播放到目标进度,如果动画进行已经结束了,这个时候再调用animateTo,则会相当于重新执行动画

Animtable将一些动画的内部状态保留在AnimationState 这个数据结构中,

实际的动画逻辑在此处

java 复制代码
endState.animate(
                    animation,
                    startTime
                ) {
                    updateState(internalState)
                    val clamped = clampToBounds(value)
                    if (clamped != value) {
                        internalState.value = clamped
                        endState.value = clamped
                        block?.invoke(this@Animatable)
                        cancelAnimation()
                        clampingNeeded = true
                    } else {
                        block?.invoke(this@Animatable)
                    }
                }

首先我们看下animate函数本身做了什么

java 复制代码
internal suspend fun <T, V : AnimationVector> AnimationState<T, V>.animate(
    animation: Animation<T, V>,
    startTimeNanos: Long = AnimationConstants.UnspecifiedTime,
    block: AnimationScope<T, V>.() -> Unit = {}
) {
    val initialValue = animation.getValueFromNanos(0) // 获取动画的初始值
        // 获取动画的初始速度
    val initialVelocityVector = animation.getVelocityVectorFromNanos(0)
      // 如果前一个动画是被取消的,那么这两个值都会继承
    var lateInitScope: AnimationScope<T, V>? = null
    try {
        if (startTimeNanos == AnimationConstants.UnspecifiedTime) {
            val durationScale = coroutineContext.durationScale
            animation.callWithFrameNanos {
                lateInitScope = AnimationScope(
                    initialValue = initialValue,
                    typeConverter = animation.typeConverter,
                    initialVelocityVector = initialVelocityVector,
                    lastFrameTimeNanos = it, 
                    targetValue = animation.targetValue,
                    startTimeNanos = it,
                    isRunning = true,
                    onCancel = { isRunning = false }
                ).apply {
                    // First frame
                    doAnimationFrameWithScale(it, durationScale, animation, this@animate, block)
                }
            }
        } else {
            lateInitScope = AnimationScope(
                initialValue = initialValue,
                typeConverter = animation.typeConverter,
                initialVelocityVector = initialVelocityVector,
                lastFrameTimeNanos = startTimeNanos,
                targetValue = animation.targetValue,
                startTimeNanos = startTimeNanos,
                isRunning = true,
                onCancel = { isRunning = false }
            ).apply {
                // First frame
                doAnimationFrameWithScale(
                    startTimeNanos,
                    coroutineContext.durationScale,
                    animation,
                    this@animate,
                    block
                )
            }
        }
        // Subsequent frames
        while (lateInitScope!!.isRunning) {
            val durationScale = coroutineContext.durationScale
            animation.callWithFrameNanos {
                lateInitScope!!.doAnimationFrameWithScale(it, durationScale, animation, this, block)
            }
        }
        // End of animation
    } catch (e: CancellationException) {
        lateInitScope?.isRunning = false
        if (lateInitScope?.lastFrameTimeNanos == lastFrameTimeNanos) {
            // There hasn't been another animation.
            isRunning = false
        }
        throw e
    }
}

根据是第一次启动该动画(或者重新运行)构建一个AnimationScope,记录下动画开始的正确时间戳,创建完该Scope对象,调用该Scope对象上的doAnimationFrameWithScale方法,后续只要动画没有取消,就一直跟随时钟运行动画

java 复制代码
private fun <T, V : AnimationVector> AnimationScope<T, V>.doAnimationFrameWithScale(
    frameTimeNanos: Long,
    durationScale: Float,
    anim: Animation<T, V>,
    state: AnimationState<T, V>,  // endState
    block: AnimationScope<T, V>.() -> Unit
) {
    val playTimeNanos =
        if (durationScale == 0f) {
            anim.durationNanos
        } else {
            ((frameTimeNanos - startTimeNanos) / durationScale).toLong()
        }
    // 根据外部的尺度来决定动画的速度
    doAnimationFrame(frameTimeNanos, playTimeNanos, anim, state, block)
}

private fun <T, V : AnimationVector> AnimationScope<T, V>.doAnimationFrame(
    frameTimeNanos: Long,
    playTimeNanos: Long,
    anim: Animation<T, V>,
    state: AnimationState<T, V>,
    block: AnimationScope<T, V>.() -> Unit
) {
    lastFrameTimeNanos = frameTimeNanos
    value = anim.getValueFromNanos(playTimeNanos) // 根据时长获取当前值
    velocityVector = anim.getVelocityVectorFromNanos(playTimeNanos)
    val isLastFrame = anim.isFinishedFromNanos(playTimeNanos)
    if (isLastFrame) {
        // TODO: This could probably be a little more granular
        // TODO: end time isn't necessarily last frame time
        finishedTimeNanos = lastFrameTimeNanos
        isRunning = false
    }
    updateState(state)
    block()
}

getValueFromNanos的调用链路,以TargetBasedAnimation 为例,

调用该方法,实际将方法转发给插值器,给插值器当前时间,以及初始状态,目标状态,初始速度等等值给出当前的值,如果当前动画在这一帧结束,就不再运行动画,将部分状态同步到state中,然后执行block, block代码如下

java 复制代码
updateState(internalState)
val clamped = clampToBounds(value)
if (clamped != value) {
   internalState.value = clamped
   endState.value = clamped
   block?.invoke(this@Animatable)
   cancelAnimation()
   clampingNeeded = true
                    } else {
   block?.invoke(this@Animatable)
                    }

首先是将scope的值同步到internalState,internalState对外暴露了一个state,这个state里面保存了最新的值,外部通过观察这个值,就能够得到当前动画的最新值

Compose动画中的插值器

首先这里存在两个概念,第一个是转换器,转换器是将我们外部希望进行动画变更的状态转换成动画库内部使用的状态,第二个是插值器,插值器能够对内部状态根据动画当前进行时长给出一个合理的值,插值器的命名都是XXXSpec,根据动画是否是一直运行的,分为FiniteAnimationSpec和InfiniteRepeatableSpec,插值器在安卓原生动画中的命名是以xxInterpolator来命名的,插值器的暗示意味更强

目前思考下来,动画的中间状态都保存在动画本身中,插值器可以设计为无状态的,这样插值器在各个动画之间复用都不会出现bug,实际看下来也验证了这个结论

Compose动画插值器接口声明如下

java 复制代码
interface AnimationSpec<T> {
    /**
     * Creates a [VectorizedAnimationSpec] with the given [TwoWayConverter].
     *
     * The underlying animation system operates on [AnimationVector]s. [T] will be converted to
     * [AnimationVector] to animate. [VectorizedAnimationSpec] describes how the
     * converted [AnimationVector] should be animated. E.g. The animation could simply
     * interpolate between the start and end values (i.e.[TweenSpec]), or apply spring physics
     * to produce the motion (i.e. [SpringSpec]), etc)
     *
     * @param converter converts the type [T] from and to [AnimationVector] type
     */
    fun <V : AnimationVector> vectorize(
        converter: TwoWayConverter<T, V>
    ): VectorizedAnimationSpec<V>
}

可以看到可以对任意类型做动画插值,前提是能够将这个类型转换成动画库内部使用的类型,也就是AnimationVector,AnimationVector本身也是一个接口,目前支持了最多四个维度的变化,其中每一个维度的数据限定为Float

所以实际进行动画的是VectorizedAnimationSpec,我们首先

VectorizedAnimationSpec家族类图

VectorizedFloatAnimationSpec 被spring动画和tween动画用来实现内部逻辑,首先我们来,

因此细化拆分,我们需要有一个对Float做动画的机制,能够根据初始值,初始速度等等获取目标浮点值,VectorizedFloatAnimationSpec就是这个逻辑,它将相关进度值的获取委托给了FloatAnimationSpec

因此Tween的动画核心在FloatTweenSpec中getValueFromNanos

java 复制代码
override fun getValueFromNanos(
        playTimeNanos: Long,
        initialValue: Float,
        targetValue: Float,
        initialVelocity: Float
    ): Float {
        // TODO: Properly support Nanos in the impl
        val playTimeMillis = playTimeNanos / MillisToNanos
        val clampedPlayTime = clampPlayTime(playTimeMillis)
        val rawFraction = if (duration == 0) 1f else clampedPlayTime / duration.toFloat()
        val fraction = easing.transform(rawFraction.coerceIn(0f, 1f))
        return lerp(initialValue, targetValue, fraction)
    }

可以看到进度的确定是由easing来决定的,Easing有唯一一个实现CubicBezierEasing

java 复制代码
class CubicBezierEasing(
    private val a: Float,
    private val b: Float,
    private val c: Float,
    private val d: Float
)

4个控制点,从我的理解来看,x坐标代表了时间,y坐标了代表了这个时候的真实进度,具体数学逻辑留待进一步分析

Transition

公开给外部的有启动动画的接口animateTo,有结束Transition的接口onTransitionEnd,有添加各种动画的接口,以及创建子Transition的接口,

这部分逻辑应该是最复杂的,所以留待最后一部分分析,Transition本质上是根据状态的变更去管理一群动画同时运行,compose给我们提供的一个接口使用Transition的是updateTransition,这个函数可以驱动Transition状态变更,进而驱动动画的进行

java 复制代码
fun <T> updateTransition(
    targetState: T,
    label: String? = null
): Transition<T> {
    val transition = remember { Transition(targetState, label = label) }
    transition.animateTo(targetState)
    DisposableEffect(transition) {
        onDispose {
            // Clean up on the way out, to ensure the observers are not stuck in an in-between
            // state.
            transition.onTransitionEnd()
        }
    }
    return transition
}

Transition这里的各种各样的增加动画接口是在Animation的基础上扩展出来的,通过TransitionAnimationState进行管理该动画,后续所有的这些动画由Transition进行驱动管理,至于子Transition,首先它本身也是Transition,其次在原始Transition的概念上嵌套就可以了,什么场景适合Transition呢,当我们需要根据一个状态的变化同时做多种动画,且动画可能本身比较复杂的时候,就可以使用Transition来管理我们的东西,如果动画本身比较简单,根据自己的场景去挑选其他动画接口就可了,如果从某种意义上来说,Transition相当于AnimationManager,但它本身只能控制动画同时播放(除非设置延迟),不能保证动画播放的先后顺序,不过在Compose的场景下也是完全够用了

通篇看下来,可以说Compose的动画系统和原生的动画系统完全不是一回事,再次感叹State系统设计得十分巧妙!

👀关注公众号:Android老皮!!!欢迎大家来找我探讨交流👀

相关推荐
xiangpanf8 小时前
Laravel 10.x重磅升级:五大核心特性解析
android
robotx10 小时前
安卓线程相关
android
消失的旧时光-194311 小时前
Android 面试高频:JSON 文件、大数据存储与断电安全(从原理到工程实践)
android·面试·json
dalancon12 小时前
VSYNC 信号流程分析 (Android 14)
android
dalancon12 小时前
VSYNC 信号完整流程2
android
dalancon12 小时前
SurfaceFlinger 上帧后 releaseBuffer 完整流程分析
android
用户693717500138413 小时前
不卷AI速度,我卷自己的从容——北京程序员手记
android·前端·人工智能
程序员Android13 小时前
Android 刷新一帧流程trace拆解
android
墨狂之逸才14 小时前
解决 Android/Gradle 编译报错:Comparison method violates its general contract!
android
阿明的小蝴蝶15 小时前
记一次Gradle环境的编译问题与解决
android·前端·gradle