You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Jetpack Compose中视频淡入动画失效问题排查与解决

解决Compose中视频淡入动画失效与切换卡顿问题

问题描述

开发混合渲染图片与视频的Demo时,图片的淡入淡出动画正常工作,但视频仅支持正常淡出,淡入动画完全失效,且切换到视频时画面存在明显卡顿。

原因分析

  • 视频初始化延迟:每次加载视频时,ExoPlayer的创建、prepare()调用需要一定时间,当AnimatedVisibility触发淡入动画时,视频还未完成渲染,导致动画无视觉效果,同时初始化过程带来性能开销引发卡顿。
  • Player实例频繁重建:每次切换到视频都会创建新的ExoPlayer实例,频繁的创建与释放操作会占用大量资源,加剧卡顿。
  • 动画与View不同步:AnimatedVisibility直接作用于AndroidView时,原生View的绘制流程与Compose动画的帧更新不同步,导致淡入动画无法正确作用到视频内容上。

修复方案

1. 缓存视频Player实例

对相同URL的视频复用ExoPlayer实例,避免重复创建带来的性能损耗。

2. 提前初始化视频Player

在视频即将显示前完成Player的初始化和准备工作,确保淡入动画启动时视频已可渲染。

3. 调整动画作用范围

将淡入淡出动画作用在包裹AndroidView的容器上,确保动画能正确覆盖视频内容。

4. 优化Player生命周期管理

视频不可见时暂停播放,重新显示时恢复播放,仅在确定不再使用时释放Player资源。

修复后的完整代码

import androidx.media3.common.MediaItem
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.tween
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.media3.common.Player
import coil.compose.AsyncImage
import coil.compose.ContentScale
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

enum class MediaType {
    IMAGE, VIDEO
}

data class Media(
    val url: String,
    val type: MediaType,
    val delay: Long
)

val media = listOf(
    Media("https://img.freepik.com/free-vector/flat-sale-landing-page-template_23-2149053848.jpg", MediaType.IMAGE, 5000),
    Media("https://img.freepik.com/free-vector/flat-sale-landing-page-template_23-2149053848.jpg", MediaType.IMAGE, 5000 ),
    Media("http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4", MediaType.VIDEO, 16000),
    Media("https://img.freepik.com/free-vector/flat-sale-landing-page-template_23-2149053848.jpg", MediaType.IMAGE, 5000),
    Media("http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4", MediaType.VIDEO, 16000),
    Media("https://img.freepik.com/free-vector/flat-sale-landing-page-template_23-2149053848.jpg", MediaType.IMAGE, 5000),
    Media("http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4", MediaType.VIDEO, 16000),
    Media("https://img.freepik.com/free-vector/flat-sale-landing-page-template_23-2149053848.jpg", MediaType.IMAGE, 5000),
    Media("http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4", MediaType.VIDEO, 16000),
    Media("https://img.freepik.com/free-vector/flat-sale-landing-page-template_23-2149053848.jpg", MediaType.IMAGE, 5000),
    Media("http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4", MediaType.VIDEO, 16000),
)

@Composable
fun ContentPlayer() {
    var currentIndex by rememberSaveable { mutableIntStateOf(0) }
    val coroutineScope = rememberCoroutineScope()
    // 缓存视频Player实例,key为视频URL
    val videoPlayerCache = remember { mutableMapOf<String, ExoPlayer>() }

    fun switchToNextMedia() {
        currentIndex = if (currentIndex == media.size - 1) 0 else currentIndex + 1
    }

    // 预加载下一个视频(如果是视频类型)
    LaunchedEffect(currentIndex) {
        val nextIndex = if (currentIndex == media.size - 1) 0 else currentIndex + 1
        val nextMedia = media[nextIndex]
        if (nextMedia.type == MediaType.VIDEO && !videoPlayerCache.containsKey(nextMedia.url)) {
            val context = LocalContext.current
            val player = ExoPlayer.Builder(context).build().apply {
                setMediaItem(MediaItem.fromUri(nextMedia.url.toUri()))
                prepare()
                playWhenReady = false // 预加载但不播放,等显示时再启动
            }
            videoPlayerCache[nextMedia.url] = player
        }
    }

    LaunchedEffect(currentIndex) {
        coroutineScope.launch {
            val currentMedia = media[currentIndex]
            delay(currentMedia.delay)
            switchToNextMedia()
        }
    }

    // 处理当前媒体的Player状态
    LaunchedEffect(currentIndex) {
        val currentMedia = media[currentIndex]
        if (currentMedia.type == MediaType.VIDEO) {
            videoPlayerCache[currentMedia.url]?.let { player ->
                player.playWhenReady = true
                player.seekTo(0) // 每次显示时回到开头
            }
        }
        // 暂停其他视频的播放
        videoPlayerCache.filter { it.key != currentMedia.url }.values.forEach { player ->
            player.playWhenReady = false
        }
    }

    // 释放不再需要的Player资源
    DisposableEffect(Unit) {
        onDispose {
            videoPlayerCache.values.forEach { it.release() }
            videoPlayerCache.clear()
        }
    }

    Box(modifier = Modifier.fillMaxSize()) {
        media.forEachIndexed { index, mediaItem ->
            AnimatedVisibility(
                visible = index == currentIndex,
                enter = fadeIn(tween(3000)),
                exit = fadeOut(tween(3000)),
            ) {
                Box(modifier = Modifier.fillMaxSize()) { // 动画作用在容器上
                    if (mediaItem.type == MediaType.IMAGE) {
                        AsyncImage(
                            contentScale = ContentScale.FillBounds,
                            model = mediaItem.url,
                            contentDescription = null,
                            modifier = Modifier.fillMaxSize()
                        )
                    } else {
                        VideoContentPlayer(
                            url = mediaItem.url,
                            player = videoPlayerCache[mediaItem.url]
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun VideoContentPlayer(url: String, player: ExoPlayer?) {
    val context = LocalContext.current

    val exoPlayer = player ?: remember(context, url) {
        ExoPlayer.Builder(context).build().apply {
            setMediaItem(MediaItem.fromUri(url.toUri()))
            prepare()
            playWhenReady = true
        }
    }

    DisposableEffect(exoPlayer) {
        onDispose {
            // 这里不直接释放,交给缓存统一管理
            exoPlayer.playWhenReady = false
        }
    }

    AndroidView(factory = {
        PlayerView(context).apply {
            this.player = exoPlayer
            useController = false
            setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS) // 显示缓冲状态,提升用户体验
        }
    })
}

关键优化点说明

  • Player缓存机制:通过videoPlayerCache复用相同URL的ExoPlayer实例,避免重复初始化带来的性能损耗。
  • 预加载逻辑:在当前媒体播放时,提前初始化下一个视频的Player,确保切换时视频已准备就绪,解决淡入动画失效问题。
  • 动画容器调整:将AnimatedVisibility作用在包裹媒体内容的Box上,确保动画能正确覆盖视频内容。
  • 生命周期优化:视频不可见时暂停播放,重新显示时恢复并回到开头,仅在全局销毁时统一释放所有Player资源。

内容的提问来源于stack exchange,提问作者Jalson1982

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.22 10:14:50