基础播放器构建:SimpleExoPlayer 创建、MediaItem 封装、PlayerView 在 Compose 中嵌入、SurfaceProvider 绑定

本章将完整搭建一个基于 ExoPlayer 的最小播放器内核,涵盖从 player 实例创建、媒体条目封装、到 Compose 视图集成与 Surface 绑定四个核心步骤。这是后续所有高级功能(缓存、DRM、字幕、自定义控制器)的基石,必须确保每一步的生命周期与渲染链路正确闭合。

2.1 核心职责与数据流

一个完整的基础播放器由四个角色协作完成:

  • SimpleExoPlayer:播放引擎核心,负责解码、渲染、状态管理。
  • MediaItem:媒体资源描述对象,封装 URI、DRM 方案、字幕轨道等元数据。
  • PlayerView(Compose 侧):通过 AndroidView 封装原生 SurfaceView/TextureView,为 player 提供显示画布。
  • SurfaceProvider:连接 player 输出端与视图 Surface 的生命周期安全桥接。

下图展示了四者之间的依赖与数据流向:

基础播放器组件架构 SimpleExoPlayer 播放引擎核心 MediaItem 媒体条目描述 PlayerView Compose 包裹视图 SurfaceProvider 生命周期安全绑定 ① MediaItem 通过 setMediaItem() 注入 Player ② PlayerView 通过 AndroidView 提供 Surface,③ SurfaceProvider 确保 Surface 在播放时有效

2.2 SimpleExoPlayer 创建

ExoPlayer 推荐通过 ExoPlayer.Builder 链式构建,并绑定应用生命周期。核心点:

  • 使用 android.content.Context 创建 builder。
  • 可通过 .setRenderersFactory() 自定义解码器工厂。
  • 必须通过 .build() 获取实例,并在 Activity/Fragment 的 onStop 中 release()。
// 创建包含默认渲染器的播放器
SimpleExoPlayer player = new ExoPlayer.Builder(context)
        .setRenderersFactory(new DefaultRenderersFactory(context))
        .build();

// 生命周期管理示例(在 ViewModel 或 CompositionLocal 中维护)
override fun onStop() {
    super.onStop()
    player.release() // 释放解码器与 Surface
}
最佳实践:将 Player 实例托管在 ViewModel 中,避免配置变更时重建。使用 rememberPlayer(context) Compose 方法时确保跟随保留实例。

2.3 MediaItem 封装

MediaItem 是 ExoPlayer 3.x 引入的统一资源描述对象,替代旧版 MediaSource 链式构建。封装时需明确:

参数说明示例值
Uri媒体源地址(本地文件 / 网络流 / 资产)Uri.parse("https://...mp4")
DrmConfigurationDRM 方案与许可证 URL(可选)MediaItem.DrmConfiguration.Builder(Uri.parse(...)).build()
SubtitleConfiguration外挂字幕轨道(可选)SubtitleConfiguration.Builder(uri, MimeTypes.TEXT_VTT)
MediaMetadata标题、封面等元数据(可选)MediaMetadata.Builder().setTitle("Demo").build()
MediaItem mediaItem = new MediaItem.Builder()
        .setUri(Uri.parse("https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv"))
        .setMediaMetadata(
            new MediaMetadata.Builder()
                .setTitle("ExoPlayer 测试视频")
                .build()
        )
        .build();
注意:MediaItem 构建器方法是 Java 风格的,Kotlin 中需注意使用 apply { } 或直接构造函数。URI 必须为路径或 content:// scheme,不支持相对路径。

2.4 PlayerView 在 Compose 中嵌入

Compose 中没有对应的 ExoPlayer 原生视图,必须通过 AndroidView 桥接 StyledPlayerView 或 PlayerView(来自 com.google.android.exoplayer2.ui 包)。核心步骤:

  1. 在 AndroidView 的 factory 中创建 StyledPlayerView(context)。
  2. 在 update 或 LaunchedEffect 中绑定 player 实例。
  3. 设置 useController 控制是否显示内置控制条。
@Composable
fun ExoPlayerView(
    player: SimpleExoPlayer,
    modifier: Modifier = Modifier
) {
    // 使用 StyledPlayerView 以获得默认控制条
    AndroidView(
        modifier = modifier,
        factory = { context ->
            StyledPlayerView(context).apply {
                this.player = player
                useController = true        // 显示播放/暂停/进度条
                keepScreenOn = true
            }
        },
        update = { view ->
            view.player = player             // 确保重组时重新绑定
        }
    )
}

2.5 SurfaceProvider 绑定

当需要自定义渲染 Surface(例如使用 TextureView 配合 Camera/GL 叠加)时,必须手动提供 SurfaceProvider。ExoPlayer 从 2.13 开始使用 setVideoSurfaceProvider() 替代旧的 setVideoSurface(),以支持异步 Surface 生命周期。

// 自定义 TextureView 作为 SurfaceProvider
val textureView = TextureView(context)
val surfaceProvider = object : SurfaceProvider {
    override fun onSurfaceReady(surface: Surface) {
        player.setVideoSurface(surface, true) // true 表示释放旧 surface
    }
    override fun onSurfaceDestroyed() {
        player.setVideoSurface(null, false)
    }
}
textureView.surfaceTextureListener = object : TextureView.SurfaceTextureListener {
    override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
        surfaceProvider.onSurfaceReady(Surface(surface))
    }
    override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
        surfaceProvider.onSurfaceDestroyed()
        return true
    }
    // 其他回调...
}

在 Compose 中如果直接使用 StyledPlayerView,内部已自动管理 SurfaceProvider,无需手动处理。只有在自定义渲染视图时才需要介入。

2.6 调试要点与常见错误

错误现象根因解决方案
黑屏无画面Surface 未绑定 / 生命周期中 player 被释放检查 PlayerView.player 赋值时机,确保在 onStart 之后调用 player.prepare()
播放器崩溃:MediaCodec 错误URI 不可访问 / 编解码器不支持使用 DefaultRenderersFactory 或添加 MediaSource.Factory 降级
Compose 重组闪烁AndroidView 的 update 未正确使用 key将 player 实例作为 remember 的 key,或用 LaunchedEffect(player) 绑定
音频正常但视频卡顿Surface 尺寸与视频分辨率不匹配在 player.addListener 的 onVideoSizeChanged 中动态调整视图尺寸

2.7 完整集成流程总结

  1. 创建 SimpleExoPlayer 实例(建议使用 ExoPlayer.Builder 并注入 Application Context)。
  2. 构造 MediaItem,设置 URI 及可选元数据。
  3. 调用 player.setMediaItem(mediaItem) 并 player.prepare()。
  4. 在 Compose 中通过 AndroidView 放置 StyledPlayerView,绑定 player。
  5. 如需自定义 Surface,手动实现 SurfaceProvider 并调用 player.setVideoSurfaceProvider()。
  6. 在 DisposableEffect 或 onStop 中 player.release()。

核心检查清单:

  • ❒ Player 实例在 ViewModel 层保持单例。
  • ❒ MediaItem 使用 setUri() 而非字符串拼接。
  • ❒ Compose 中使用 remember 保持 PlayerView 不因重组重复创建。
  • ❒ 生命周期回调 onStop 释放 player,onStart 恢复播放。