You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

ExoPlayer自定义Composable组件性能问题及UI异常排查咨询

ExoPlayer自定义Composable组件性能问题及UI异常排查咨询

我基于ExoPlayer实现了自定义的Composable播放组件,之前遇到播放时应用无响应(ANR)的问题,调整位置更新周期到1秒后没有改善,怀疑是pointerInput里事件处理逻辑过多导致的。考虑过用Slider替代Canvas,但因为后续要实现复杂进度条所以没采用。

后来我对组件做了优化:把直接传入Player改成传入playerProvider: () -> Player,用retain持有Player实例,替换直接的AndroidView为VideoSurface组件。现在ANR提示不再出现,但还是担心未来有性能隐患,而且偶尔会出现点击后播放按钮和进度条不显示的UI异常,想请教下排查方向。


当前核心实现代码

1. PlayerComponent

@OptIn(UnstableApi::class)
@Composable
fun PlayerComponent(
    modifier: Modifier = Modifier,
    playerProvider: () -> Player,
    isFullScreen: Boolean = false,
    onFullscreen: ((Boolean) -> Unit)? = null,
    onRatioObtained: ((Float) -> Unit)? = null
) {
    val context = LocalContext.current
    val player = retain {
        playerProvider()
    }
    var playerState by rememberSaveable { mutableIntStateOf(player.playbackState) }
    var isPlaying by rememberSaveable { mutableStateOf(player.isPlaying) }
    var duration by rememberSaveable { mutableLongStateOf(player.duration) }
    var position by rememberSaveable { mutableLongStateOf(player.currentPosition) }
    var savedPlayState by rememberSaveable { mutableStateOf(player.isPlaying) }
    var aspectRatio by rememberSaveable { mutableFloatStateOf(16f / 9) }

    Box(
        modifier = modifier.background(Color.Black),
        contentAlignment = Alignment.Center
    ) {
        VideoSurface(
            modifier = Modifier.matchParentSize(),
            playerProvider = playerProvider
        )
        val seekRange = 5000L
        PlayerControls(
            modifier = Modifier.matchParentSize(),
            position = position,
            duration = duration,
            isPlaying = isPlaying,
            onPlay = player::play,
            onPause = player::pause,
            onSeekBack = {
                val newPosition = (player.currentPosition - seekRange).coerceAtLeast(0)
                player.seekTo(newPosition)
            },
            onSeekForward = {
                val newPosition =
                    (player.currentPosition + seekRange).coerceAtMost(player.duration - 1)
                player.seekTo(newPosition)
            },
            onSeek = player::seekTo,
            isFullScreen = isFullScreen,
            onFullscreen = onFullscreen
        )
    }

    LaunchedEffect(isPlaying) {
        val changePeriod = 250L
        while (isActive && isPlaying) {
            position = player.currentPosition
            val delayDuration = changePeriod - (position % changePeriod)
            delay(delayDuration)
        }
    }
}

2. VideoSurface

@Composable
internal fun VideoSurface(
    modifier: Modifier = Modifier,
    playerProvider: () -> Player
) {
    AndroidView(
        modifier = modifier,
        factory = {
            PlayerView(it).apply {
                useController = false
                this.player = playerProvider()
            }
        }
    )
}

3. PlayerControls

@Composable
internal fun PlayerControls(
    modifier: Modifier = Modifier,
    isPlaying: Boolean,
    position: Long,
    duration: Long,
    onPlay: () -> Unit,
    onPause: () -> Unit,
    onSeek: (Long) -> Unit,
    onSeekBack: () -> Unit,
    onSeekForward: () -> Unit,
    isFullScreen: Boolean = false,
    onFullscreen: ((Boolean) -> Unit)? = null,
) {
    val coroutineScope = rememberCoroutineScope()

    var isInteracting by rememberSaveable {
        mutableStateOf(false)
    }
    val interactionSource = remember { MutableInteractionSource() }

    val controlsShowDuration = 3000
    var notActiveTimer by rememberSaveable { mutableIntStateOf(0) }
    var controlsAlpha by rememberSaveable { mutableFloatStateOf(0f) }
    val animatedControlsAlpha by animateFloatAsState(
        targetValue = controlsAlpha,
        animationSpec = tween(300)
    )

    LaunchedEffect(notActiveTimer) {
        when (notActiveTimer) {
            controlsShowDuration -> controlsAlpha = 1f
            0 -> controlsAlpha = 0f
        }
    }

    LaunchedEffect(isInteracting) {
        if (isInteracting) {
            notActiveTimer = controlsShowDuration
        } else if (notActiveTimer > 0) {
            delay(controlsShowDuration.toLong())
            notActiveTimer = 0
        }
    }

    ConstraintLayout(
        modifier = modifier
            .clickable(
                interactionSource = interactionSource,
                indication = null
            ) {}
            .pointerInput(Unit) {
                awaitPointerEventScope {
                    while (true) {
                        val event = awaitPointerEvent(PointerEventPass.Main)
                        val change = event.changes.firstOrNull() ?: continue
                        when {
                            !change.previousPressed && change.pressed -> { // Pointer down
                                isInteracting = true
                            }

                            change.pressed && change.positionChange() != Offset.Zero -> { // Dragging
                                isInteracting = true
                            }

                            change.previousPressed && !change.pressed -> { // Pointer up or Cancelled
                                isInteracting = false
                            }
                        }
                    }
                }
            }
    ) {
        val (seekBack, seekForward, playBtn, seekBar) = createRefs()

        if (animatedControlsAlpha > 0) {
            if (duration >= 0) {
                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .graphicsLayer(alpha = animatedControlsAlpha)
                        .constrainAs(seekBar) {
                            bottom.linkTo(parent.bottom)
                            start.linkTo(parent.start)
                            end.linkTo(parent.end)
                        }
                ) {
                    Box(
                        modifier = Modifier
                            .fillMaxWidth()
                            .height(35.dp)
                            .pointerInput(Unit) {
                                awaitPointerEventScope {
                                    while (true) {
                                        val event = awaitPointerEvent(PointerEventPass.Main)
                                        val change = event.changes.firstOrNull() ?: continue
                                        when {
                                            !change.previousPressed && change.pressed -> { // Pointer down
                                                isInteracting = true
                                                val newProgress =
                                                    (change.position.x / size.width).coerceIn(0f..1f)
                                                val newPosition =
                                                    (newProgress * duration).toLong()
                                                onSeek(newPosition)
                                            }

                                            change.pressed && change.positionChange() != Offset.Zero -> { // Dragging
                                                isInteracting = true
                                                val newProgress =
                                                    (change.position.x / size.width).coerceIn(0f..1f)
                                                val newPosition =
                                                    (newProgress * duration).toLong()
                                                onSeek(newPosition)
                                            }

                                            change.previousPressed && !change.pressed -> { // Pointer up or Cancelled
                                                isInteracting = false
                                            }
                                        }
                                    }
                                }
                            }
                    ) {
                        val primaryColor = MaterialTheme.colorScheme.primary
                        Canvas(
                            modifier = Modifier.matchParentSize()
                        ) {
                            val barHeight = 12f
                            val width = size.width
                            val height = size.height
                            val progress = position.toFloat() / duration
                            drawRoundRect(
                                color = Color(200f, 200f, 200f, 0.7f),
                                topLeft = Offset(0f, height / 2f - barHeight * 3f),
                                size = Size(width, barHeight),
                                cornerRadius = CornerRadius(barHeight)
                            )
                            drawRoundRect(
                                color = primaryColor,
                                topLeft = Offset(0f, height / 2f - barHeight * 3f),
                                size = Size(width * progress, barHeight),
                                cornerRadius = CornerRadius(barHeight)
                            )
                            drawCircle(
                                color = primaryColor,
                                radius = 1.3f * barHeight,
                                center = Offset(
                                    width * progress,
                                    height / 2f - barHeight * 3f + barHeight / 2
                                )
                            )
                        }
                    }
                }
            }
        }
    }
}

4. 使用示例

@Composable
fun Demo() {
    val context = LocalContext.current
    PlayerComponent(
        modifier = Modifier
            .fillMaxWidth()
            .aspectRatio(16f/9),
        playerProvider = { ExoPlayer.Builder(context).build() },
        onRatioObtained = {},
        isFullScreen = false,
        onFullscreen = {}
    )
}

待排查的问题

  1. 虽然ANR消失,但是否还有潜在的性能瓶颈?
  2. 偶尔出现的「点击后控制栏不显示」是什么原因?

排查与优化建议

一、性能瓶颈排查优化

1. 简化PointerInput手势处理

当前PlayerControls有两层重复的pointerInput逻辑,手动循环处理PointerEvent容易冗余且耗资源。建议用Compose内置的手势API替代:

// 替换外层ConstraintLayout的pointerInput
.pointerInput(Unit) {
    detectTapGestures(
        onPress = {
            isInteracting = true
            tryAwaitRelease()
            if (!isInteracting) { // 防止拖拽时误触隐藏
                delay(3000)
                controlsAlpha = 0f
            }
        }
    )
    detectDragGestures(
        onDragStart = { isInteracting = true },
        onDragEnd = { 
            isInteracting = false
            delay(3000)
            controlsAlpha = 0f
        },
        onDrag = { change, dragAmount ->
            change.consume()
            val progress = (change.position.x / size.width).coerceIn(0f..1f)
            onSeek((progress * duration).toLong())
        }
    )
}

同时移除进度条Box的内层pointerInput,合并手势处理,减少事件监听的资源占用。

2. 替换手动轮询为Player监听器

当前LaunchedEffect(isPlaying)里的循环更新position是轮询方式,不如直接监听Player的状态变化精准,还能减少Coroutine的资源消耗:

LaunchedEffect(player) {
    val listener = object : Player.Listener {
        override fun onPlaybackStateChanged(playbackState: Int) {
            playerState = playbackState
            isPlaying = playbackState == Player.STATE_READY && player.isPlaying
        }

        override fun onIsPlayingChanged(isPlaying: Boolean) {
            this@PlayerComponent.isPlaying = isPlaying
        }

        override fun onDurationChanged(duration: Long) {
            this@PlayerComponent.duration = duration
        }

        override fun onPositionDiscontinuity(
            oldPosition: Player.PositionInfo,
            newPosition: Player.PositionInfo,
            reason: Int
        ) {
            position = player.currentPosition
        }
    }
    player.addListener(listener)
    onDispose { player.removeListener(listener) }
}

3. 优化Canvas绘制

drawWithCache缓存静态绘制内容(比如背景进度条),只动态更新进度部分,减少重绘开销:

Canvas(modifier = Modifier.matchParentSize()) {
    val barHeight = 12f
    val width = size.width
    val height = size.height
    val progress = position.toFloat() / duration

    drawWithCache {
        // 缓存静态背景条
        val backgroundTop = height / 2f - barHeight * 3f
        val backgroundRect = RoundRect(
            left = 0f, top = backgroundTop,
            right = width, bottom = backgroundTop + barHeight,
            cornerRadius = CornerRadius(barHeight)
        )
        onDrawBehind {
            drawRoundRect(
                color = Color(200f, 200f, 200f, 0.7f),
                topLeft = Offset(0f, backgroundTop),
                size = Size(width, barHeight),
                cornerRadius = CornerRadius(barHeight)
            )
        }
        onDrawWithContent {
            // 动态绘制进度条和圆点
            drawRoundRect(
                color = primaryColor,
                topLeft = Offset(0f, backgroundTop),
                size = Size(width * progress, barHeight),
                cornerRadius = CornerRadius(barHeight)
            )
            drawCircle(
                color = primaryColor,
                radius = 1.3f * barHeight,
                center = Offset(width * progress, backgroundTop + barHeight/2)
            )
        }
    }
}

二、控制栏不显示的UI异常排查

1. 简化控制栏显示逻辑

当前用notActiveTimer作为中间状态容易导致状态不同步,建议直接用isInteracting和播放状态控制:

// 移除原来的notActiveTimer相关逻辑,替换为:
LaunchedEffect(isPlaying, isInteracting) {
    controlsAlpha = 1f
    if (isPlaying && !isInteracting) {
        delay(3000)
        if (!isInteracting) controlsAlpha = 0f
    }
}

减少中间状态,降低状态流转出错的概率。

2. 解决事件冲突

当前ConstraintLayout同时有clickablepointerInput,会导致事件消费冲突,点击事件被clickable吃掉后,pointerInput没触发isInteracting=true,进而控制栏不显示。直接移除clickable,用pointerInput处理所有手势。

3. 检查状态恢复

rememberSaveable保存的状态(比如isInteractingcontrolsAlpha)在屏幕旋转、后台恢复后可能异常,建议在LaunchedEffect(Unit)里初始化默认状态,确保组件启动时状态正确:

LaunchedEffect(Unit) {
    if (isPlaying) controlsAlpha = 0f else controlsAlpha = 1f
}

三、工具辅助排查

  1. CPU Profiler:播放时录制CPU使用情况,看是否有方法(比如PointerEvent处理、状态更新)占用过高CPU。
  2. Layout Inspector:给PlayerControls加Modifier.onCommit { println("PlayerControls重组了") },看是否有不必要的频繁重组,用derivedStateOf包裹计算值(比如progress = position/duration)减少重组。
  3. 复现UI异常:尝试快速连续点击、拖拽后点击、屏幕旋转后点击,复现控制栏不显示的场景,结合Logcat打印isInteractingcontrolsAlpha的值,定位状态不同步的节点。

火山引擎 最新活动