Compose动画之与View动画互操作:AndroidView中的动画、Compose与View的动画桥接

说实话,很多从View体系迁移到Compose的团队,最头疼的问题不是Compose动画学不会,而是——老项目里那一堆用AnimationSet、ObjectAnimator写好的动画怎么办?全部重写?那成本也太高了。

我去年接手过一个中型项目,里面光是一个启动页的Logo动画就用了三个View动画叠加,还有自定义Interpolator。要是全改成Compose动画,测试回归至少两周。所以,Compose官方也想到了这一点,给我们留了一条「后路」——互操作

AndroidView:View动画的「避风港」

在Compose里,如果你想用传统的View动画,最直接的方式就是通过AndroidView这个Composable。它允许你把一个传统的View嵌入到Compose的组件树中,然后在这个View上跑你熟悉的View动画。

核心思路:Compose负责布局和生命周期,View动画负责具体的动画执行。两者各司其职,互不干扰。

来看一个最简单的例子。假设你有一个老项目里的ImageView,它有一个旋转动画:

// 传统的View动画
val rotateAnim = RotateAnimation(
    0f, 360f,
    Animation.RELATIVE_TO_SELF, 0.5f,
    Animation.RELATIVE_TO_SELF, 0.5f
).apply {
    duration = 2000
    repeatCount = Animation.INFINITE
    interpolator = LinearInterpolator()
}

现在你想把它放到Compose里,怎么做?

@Composable
fun OldSchoolRotatingImage() {
    AndroidView(
        factory = { context ->
            ImageView(context).apply {
                setImageResource(R.drawable.ic_logo)
                // 启动View动画
                startAnimation(rotateAnim)
            }
        },
        modifier = Modifier.size(100.dp)
    )
}

嗯,就这么简单。AndroidViewfactory回调里,你可以创建任何传统的View,并且直接调用它的startAnimation()方法。Compose不会干涉这个View内部的动画逻辑。

小提示AndroidView还有一个update回调,可以用来在Compose重组时更新View的属性。但动画本身是View自己管理的,Compose不会帮你暂停或恢复。

生命周期问题:别让动画「跑飞」了

我在项目中遇到过一个问题:当Compose的AndroidView因为重组被移除时,里面的View动画还在跑。这会导致内存泄漏,甚至Crash。

为什么会这样?因为AndroidView的生命周期和Compose的Composable生命周期是绑定的,但View动画的生命周期是独立的。你需要手动处理。

正确的做法是使用DisposableEffect来清理:

@Composable
fun SafeRotatingImage() {
    val context = LocalContext.current
    val animRef = remember { mutableStateOf<Animation?>(null) }

    AndroidView(
        factory = { ctx ->
            ImageView(ctx).apply {
                setImageResource(R.drawable.ic_logo)
                val anim = RotateAnimation(...).apply {
                    duration = 2000
                    repeatCount = Animation.INFINITE
                }
                animRef.value = anim
                startAnimation(anim)
            }
        },
        modifier = Modifier.size(100.dp)
    )

    // 离开Composable时清理动画
    DisposableEffect(Unit) {
        onDispose {
            animRef.value?.cancel()
            animRef.value = null
        }
    }
}

警告:如果你在AndroidView里用了ObjectAnimator,记得在onDispose里调用animator.cancel()。否则,即使View被移除了,Animator可能还在持有View的引用。

Compose与View动画的桥接:双向通信

有时候,你需要在Compose里控制View动画的播放状态,或者反过来——View动画结束时通知Compose更新状态。这就涉及到「桥接」了。

我个人习惯用remember + mutableStateOf来做这个桥梁。举个例子:

@Composable
fun BridgedAnimationExample() {
    // 这个状态控制View动画的播放
    var isPlaying by remember { mutableStateOf(true) }
    // 这个状态接收View动画的完成事件
    var animationFinished by remember { mutableStateOf(false) }

    val context = LocalContext.current
    val animatorRef = remember { mutableStateOf<ObjectAnimator?>(null) }

    AndroidView(
        factory = { ctx ->
            TextView(ctx).apply {
                text = "Hello from View"
                // 创建一个ObjectAnimator
                val animator = ObjectAnimator.ofFloat(this, "alpha", 1f, 0f).apply {
                    duration = 2000
                    repeatCount = ObjectAnimator.INFINITE
                    addListener(object : AnimatorListenerAdapter() {
                        override fun onAnimationEnd(animation: Animator?) {
                            // 桥接:通知Compose动画结束了
                            animationFinished = true
                        }
                    })
                }
                animatorRef.value = animator
            }
        },
        modifier = Modifier.fillMaxWidth()
    )

    // Compose控制View动画的播放
    LaunchedEffect(isPlaying) {
        if (isPlaying) {
            animatorRef.value?.start()
        } else {
            animatorRef.value?.pause()
        }
    }

    // Compose响应View动画的事件
    if (animationFinished) {
        Text("动画已结束", color = Color.Red)
    }

    Button(onClick = { isPlaying = !isPlaying }) {
        Text(if (isPlaying) "暂停" else "继续")
    }
}

说白了,就是通过mutableStateOf把Compose的状态「注入」到View动画里,再把View动画的回调「映射」回Compose的状态。这样两边就能对话了。

性能考量:别让桥接变成瓶颈

你想想看,每次Compose重组都会触发AndroidViewupdate回调。如果你在update里频繁修改View动画的参数,可能会造成性能抖动。

我曾经踩过一个坑:在update里根据Compose的滚动偏移量动态更新View动画的进度。结果每帧都在触发update,View动画的插值器被频繁重置,动画变得一卡一卡的。

解决方案是:只在必要的时候更新。比如用snapshotFlow来监听状态变化,而不是每帧都更新。

// 不推荐:每帧都更新
AndroidView(
    factory = { ... },
    update = { view ->
        view.alpha = someComposeState.value // 频繁触发
    }
)

// 推荐:只在状态变化时更新
val alpha by someComposeState
LaunchedEffect(alpha) {
    // 只在alpha变化时更新View
    animatorRef.value?.setDuration(alpha.toLong())
}

什么时候该用桥接,什么时候该重写?

这个问题没有标准答案。我个人的经验是:

  • 简单动画(平移、旋转、淡入淡出):直接重写成Compose动画,代码更简洁,性能更好。
  • 复杂动画(多阶段、自定义Interpolator、依赖View内部状态):用桥接,避免重写带来的bug。
  • 第三方库的动画(比如Lottie、Shimmer):用桥接,因为那些库本身就是基于View的。

一句话总结:桥接是过渡方案,不是最终方案。如果你的项目正在全面迁移到Compose,建议逐步把View动画重写为Compose动画。但如果你只是想在Compose里快速复用老代码,桥接完全够用。

知识体系总览

下面这张图帮你理清Compose与View动画互操作的三种模式:

Compose ↔ View 动画互操作三种模式 模式一:AndroidView嵌入 Compose 组件树 └─ AndroidView └─ View + startAnimation() View动画独立运行,Compose不干涉 模式二:状态桥接 Compose State ↕ mutableStateOf View动画参数/回调 双向通信,控制播放/监听结束 模式三:生命周期绑定 DisposableEffect └─ onDispose └─ anim.cancel() 防止内存泄漏和动画跑飞 选择建议 • 简单动画 → 直接重写为Compose动画(推荐) • 复杂动画/第三方库 → 模式一 + 模式二组合使用 • 任何情况 → 模式三(生命周期清理)必须加上 Compose动画演进之路 · 第25章

三种模式各有适用场景。我个人建议:模式三(生命周期绑定)是必选项,不管你用哪种方式,都要确保动画在Composable离开组合时被清理。这是很多初学者容易忽略的。

避坑指南:我曾经在AndroidViewfactory里直接启动了ValueAnimator,结果Compose重组时factory不会重新执行,但update会。如果你在factory里创建了动画,记得用remember把它缓存起来,否则每次重组都会创建一个新的动画实例。

好了,关于Compose与View动画的互操作,核心就是这些。记住:桥接不是目的,平滑过渡才是。你的目标是让老代码和新架构和谐共存,而不是为了炫技。


公众号:蓝海资料掘金营,微信deep3321