前言
项目中播放网络视频的需求应该算是较为常见的,不论是短视频类的 App 还是电商类的 App,都离不开视频播放。但是很遗憾在 Compose 中暂时还没有关于视频播放的官方轮子。所幸 Compose 对原生 View 是支持的,所以基于官方出品的 ExoPlayer 做一个 Compose 的封装。封装不太完善并且达不到轮子的程度,有类似需求的看官们自行甄别,理性发表意见。
效果展示

第一个是直接放横屏视频的样子。
第二个是使用了 ExoPlayer 内置控制器的样子。
第三个是直接放竖屏视频的样子(占用过多空间)。
第四个是使用了isFixHeight
参数控制高度的样子。
准备工作
导入 ExoPlayer。
官方推荐使用 AndroidX 包中 Media3 下的 ExoPlayer。旧的 ExoPlayer 应该也是类似的。
在 app 的build.gradle.kts文件中添加依赖
groovy
dependencies {
...
val exoplayer_version = "1.2.0"
implementation ("androidx.media3:media3-exoplayer:$exoplayer_version")
implementation ("androidx.media3:media3-ui:$exoplayer_version")
}
直接上代码
kotlin
/**
* Compose 封装的 ExoPlayer
* @param data String? 视频的网络地址
* @param modifier Modifier
* @param isFixHeight Boolean 用于指定控件是否固定高度,true 则需传入与高度相关的 Modifier;false 则根据视频比例调整
* @param useExoController Boolean 是否使用 ExoPlayer 的内置控制器
* @param cache Cache? 视频缓存功能,null 表明不启用缓存
* @param onSingleTap 单击视频控件事件回调
* @param onDoubleTap 双击视频控件事件回调
* @param onVideoDispose ExoPlayer release 后的回调
* @param onVideoGoBackground 后台事件回调
*/
@OptIn(UnstableApi::class)
@Composable
fun VideoPlayer(
data: String?,
modifier: Modifier = Modifier,
isFixHeight: Boolean = false,
useExoController: Boolean = false,
cache: Cache? = null,
onSingleTap: (exoPlayer: ExoPlayer) -> Unit = {},
onDoubleTap: (exoPlayer: ExoPlayer, offset: Offset) -> Unit = { _, _ -> },
onVideoDispose: () -> Unit = {},
onVideoGoBackground: () -> Unit = {}
) {
val context = LocalContext.current
//初始的比例,设置成这么大用来模拟 0 高度
var ratio by remember { mutableStateOf(1000f) }
//当前视频播放的进度
var currentPosition by remember { mutableStateOf(0L) }
//是否在播放
var isVideoPlaying by remember { mutableStateOf(false) }
//自己实现的控制器是否可见
var isControllerVisible by remember { mutableStateOf(false) }
//标志是否为初次进入,防止 lifecycle 的 onStart 事件导致自动播放
var isFirstIn by remember { mutableStateOf(true) }
LaunchedEffect(isControllerVisible) {
if (isControllerVisible) {
//如果控制器可见,5 秒后自动消失
delay(5000)
isControllerVisible = false
}
}
//实例化 ExoPlayer
val exoPlayer = remember(context) {
ExoPlayer.Builder(context).build().apply {
//用视频网址构建 MediaItem
val item = MediaItem.fromUri(Uri.parse(data))
if (cache != null) {
//启动缓存
val httpDataSource =
DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
val defaultDataSource = DefaultDataSource.Factory(context, httpDataSource)
val cacheSourceFactory = CacheDataSource.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(defaultDataSource)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
setMediaSource(
ProgressiveMediaSource
.Factory(cacheSourceFactory)
.createMediaSource(item)
)
} else {
//不启用缓存则直接 setMediaItem
setMediaItem(item)
}
//设置重复播放的模式(这里也不是很搞得懂)
repeatMode = Player.REPEAT_MODE_ONE
//关闭自动播放
playWhenReady = false
//开始准备资源
prepare()
}
}
val lifecycleOwner by rememberUpdatedState(LocalLifecycleOwner.current)
DisposableEffect(lifecycleOwner) {
val lifeCycleObserver = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_STOP -> {
//暂停视频播放并调用 onVideoGoBackground
exoPlayer.pause()
onVideoGoBackground()
}
Lifecycle.Event.ON_START -> if (!isFirstIn) exoPlayer.play() //恢复播放
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(lifeCycleObserver)
onDispose {
lifecycleOwner.lifecycle.removeObserver(lifeCycleObserver)
}
}
//构建播放器控件
val playerView = remember {
//这里使用了 XML 的布局构建 view 是因为项目需要设置播放器的渲染方式,只能用 XML 的属性设置
LayoutInflater.from(context).inflate(R.layout.video_player, null)
.findViewById<PlayerView>(R.id.my_video_player).apply {
player = exoPlayer
useController = useExoController
}
}
DisposableEffect(Unit) {
playerView.setAspectRatioListener { targetAspectRatio, _, _ ->
//获取到视频比例时给控件比例赋值
ratio = targetAspectRatio
}
val listener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
//是否正在播放的监听
isVideoPlaying = isPlaying
}
}
exoPlayer.addListener(listener)
onDispose {
//收尾工作
exoPlayer.removeListener(listener)
playerView.setAspectRatioListener(null)
exoPlayer.release()
onVideoDispose()
}
}
LaunchedEffect(exoPlayer) {
while (isActive) {
//每 1 秒读一次当前进度,用于自定义控制器的进度显示
currentPosition = exoPlayer.currentPosition / 1000
delay(1000)
}
}
val singleTapWrapper: (Offset) -> Unit = {
//单击回调装饰器,控制自定义控制器的可见性并回调 onSingleTap
isControllerVisible = !isControllerVisible
onSingleTap(exoPlayer)
}
val doubleTapWrapper: (Offset) -> Unit = {
//双击回调装饰器,控制视频的暂停和播放并回调 onDoubleTap
if (exoPlayer.isPlaying) exoPlayer.pause()
else exoPlayer.play()
isFirstIn = false
onDoubleTap(exoPlayer, it)
}
val actualModifier = if (isFixHeight) {
modifier
} else {
//非指定高度则设置控件比例
modifier.aspectRatio(ratio)
}
Box(modifier = actualModifier) {
//播放器本体
AndroidView(
factory = { playerView },
modifier = actualModifier.pointerInput(Unit) {
detectTapGestures(
onTap = singleTapWrapper,
onDoubleTap = doubleTapWrapper
)
}
)
//以下是自定义控制器的 UI,使用 Exoplayer 内置控制器时不显示
if (!useExoController) {
val controllerBgAlpha by animateFloatAsState(targetValue = if (isControllerVisible || isFirstIn) 0.7f else 0f)
val controllerContentAlpha by animateFloatAsState(targetValue = if (isControllerVisible || isFirstIn) 1f else 0f)
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(10.dp)
.height(20.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color.Black.copy(alpha = controllerBgAlpha))
.padding(horizontal = 6.dp),
contentAlignment = Alignment.Center
) {
val formattedTime =
"${currentPosition / 60}:${String.format("%02d", (currentPosition % 60))}"
Text(
text = formattedTime,
color = Color.White.copy(alpha = controllerContentAlpha),
style = MaterialTheme.typography.labelSmall
)
}
Box(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(10.dp)
.size(30.dp)
.clip(CircleShape)
.background(Color.Black.copy(alpha = controllerBgAlpha))
.clickable {
if (exoPlayer.isPlaying) exoPlayer.pause() else exoPlayer.play()
isFirstIn = false
}
.padding(6.dp),
contentAlignment = Alignment.Center
) {
Icon(
painterResource(if (isVideoPlaying) R.drawable.icon_pause else R.drawable.icon_play),
contentDescription = "",
tint = Color.White.copy(alpha = controllerContentAlpha)
)
}
}
}
}
使用示例
kotlin
//最简单的使用,直接传视频网址
VideoPlayer(data = videoUrl)
//使用 ExoPlayer 内置控制器
VideoPlayer(data = videoUrl, useExoController = true)
//标志为固定高度并传入指定了高度的 modifier,通常是让竖屏视频不占用太多空间
VideoPlayer(
data = videoUrl,
modifier = Modifier
.fillMaxWidth()
.height(150.dp),
isFixHeight = true
)
//增加视频缓存逻辑(我项目中尝试过使用缓存,但最后还是会发起视频请求,如果有懂的一定要指点我一下)
val videoCache = context.cacheDir.resolve("video_cache")
val lru = LeastRecentlyUsedCacheEvictor(100 * 1024 * 1024)
val cache = SimpleCache(videoCache, lru, StandaloneDatabaseProvider(context))
VideoPlayer(data = videoUrl, cache = cache)
改进空间
- 对于不同尺寸(横屏竖屏)的视频适配性欠佳,
isFixHeight
这个参数非常不优雅。 - 缓存逻辑貌似不生效。
- 自定义控制器可以设计成「槽」,让调用者自行实现。
- 可以将 ExoPlayer 的功能抽象成一个 VideoState,让调用者可以获取视频信息以及控制视频。
- ......
后记
笔者水平有限,只是一个粗糙的成果,希望起抛砖引玉之作用。