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 = {} ) }
待排查的问题
- 虽然ANR消失,但是否还有潜在的性能瓶颈?
- 偶尔出现的「点击后控制栏不显示」是什么原因?
排查与优化建议
一、性能瓶颈排查优化
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同时有clickable和pointerInput,会导致事件消费冲突,点击事件被clickable吃掉后,pointerInput没触发isInteracting=true,进而控制栏不显示。直接移除clickable,用pointerInput处理所有手势。
3. 检查状态恢复
用rememberSaveable保存的状态(比如isInteracting、controlsAlpha)在屏幕旋转、后台恢复后可能异常,建议在LaunchedEffect(Unit)里初始化默认状态,确保组件启动时状态正确:
LaunchedEffect(Unit) { if (isPlaying) controlsAlpha = 0f else controlsAlpha = 1f }
三、工具辅助排查
- CPU Profiler:播放时录制CPU使用情况,看是否有方法(比如PointerEvent处理、状态更新)占用过高CPU。
- Layout Inspector:给PlayerControls加
Modifier.onCommit { println("PlayerControls重组了") },看是否有不必要的频繁重组,用derivedStateOf包裹计算值(比如progress = position/duration)减少重组。 - 复现UI异常:尝试快速连续点击、拖拽后点击、屏幕旋转后点击,复现控制栏不显示的场景,结合Logcat打印
isInteracting、controlsAlpha的值,定位状态不同步的节点。




