如何在 ExoPlayer 中实现播放列表和字幕切换功能?

解读

国内面试中,音视频方向是“高阶 Android”标签之一。ExoPlayer 作为 Google 官方维护、国内厂商(爱优腾、B 站)普遍落地的播放内核,面试官通过“播放列表 + 字幕切换”这一组合题,既考察候选人对 Media3/ExoPlayer 核心 API 的掌握,也验证其对“多轨道管理”“UI 状态同步”“内存与耗电”等落地细节的理解。答题时切忌只背 API,要体现“为什么这么做、边界 case 怎么处理、如何灰度验证”。

知识点

  1. ExoPlayer 架构:Player 接口 → ExoPlayer 实现;MediaItem 为不可变描述单元;MediaSource 负责协议解析(Progressive/ Dash/ HLS);TrackSelector 负责轨道决策;Renderer 负责解码渲染。
  2. 播放列表:ConcatenatingMediaSource(旧)/ Playlist 接口(Media3 推荐)支持动态增删、懒加载预缓冲;setMediaItems(List<MediaItem>) 可一次性提交队列。
  3. 字幕轨道:
    • 内嵌字幕:MP4/MKV 中的 text track,ExoPlayer 通过 MappingTrackSelector 暴露 Format 对象,selectionFlags 包含 SELECTION_FLAG_DEFAULT。
    • 外挂字幕:Side-loaded,构造 MediaItem.SubtitleConfiguration(Uri、MimeType、language、selectionFlags),与主视频在 MergingMediaSource 中合并;支持 WebVTT、TTML、SRT。
  4. 轨道切换:TrackSelectionParameters.Builder.setPreferredTextLanguage() / setDisabledTextTrackSelectionFlags();亦可运行时 TrackSelector.setParameters → player.setTrackSelectionParameters。
  5. UI 同步:Player.Listener.onTracksInfoChanged() 回调刷新字幕按钮;需主线程 post 避免并发。
  6. 国内合规:外挂字幕需先下载到沙箱私有目录,禁止明文存储;若含用户上传内容,需通过内容安全审核接口先审后发。
  7. 性能与体验:
    • 预缓冲:DefaultLoadControl 的 maxBufferMs 建议 50 000 ms,5G 场景可动态下调;
    • 无缝切换:setHandleAudioBecomingNoisy(true) + 音频焦点管理;
    • 低电耗:后台播放使用 MediaSession + PlayerNotificationManager,适配 Android 14 前台服务类型“mediaPlayback”。
  8. 灰度与验证:内部测试使用 FakeConcatenatingMediaSource 构造 200 条极限队列;字幕切换编写 Espresso 用例,断言“切换后 500 ms 内 onCues 回调语言码匹配”。

答案

步骤一:构造播放列表

val mediaItems = videoList.map { url ->
    MediaItem.Builder()
        .setUri(url)
        .setSubtitleConfigurations(listOf(
            SubtitleConfiguration.Builder(subtitleUri)
                .setMimeType(MimeTypes.TEXT_VTT)
                .setLanguage("zh-CN")
                .setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
                .build()
        ))
        .build()
}
player.setMediaItems(mediaItems,  /* resetPosition */ true)
player.prepare()

步骤二:运行时字幕切换

// 1. 读取当前 Tracks
val tracks = player.currentTracks
val textGroups = tracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }

// 2. 构造新参数
val params = player.trackSelectionParameters
    .buildUpon()
    .setPreferredTextLanguage("en-US")   // 或 null 表示关闭字幕
    .setDisabledTextTrackSelectionFlags(
        if (disable) C.SELECTION_FLAG_FORCED else 0
    )
    .build()

player.trackSelectionParameters = params

步骤三:UI 状态同步

player.addListener(object : Player.Listener {
    override fun onTracksInfoChanged(tracksInfo: TracksInfo) {
        val hasText = tracksInfo.groups.any { it.type == C.TRACK_TYPE_TEXT }
        subtitleButton.isVisible = hasText
        // 高亮当前语言
        val lang = player.trackSelectionParameters.preferredTextLanguage
        subtitleButton.text = lang ?: "关闭"
    }
})

步骤四:国内合规与性能

  • 外挂字幕文件先下载到 Context.getFilesDir()/sub/,文件名做 MD5 去重;
  • 下载完成调用 MediaItem.Builder.setSubtitleConfigurations 重新 setMediaItem 替换当前项,避免整队列重建;
  • 5G 弱网场景:监听 NetworkCallback,当迁移到蜂窝时下调 LoadControl 的 prioritizeTimeOverSizeThresholds = true,减少首帧卡顿;
  • 后台播放:Android 14 必须声明 mediaPlayback 前台服务类型,并在 5 秒内启动 MediaStyle 通知,否则应用被杀。

步骤五:验证

  • 使用 Systrace 检查切换轨道是否触发线程重排;
  • 使用 Media3 的 FakeClock 做单元测试,断言切换后 1 帧内字幕渲染语言码正确;
  • 线上灰度:通过 Firebase/火山引擎埋点,统计字幕切换成功率 < 99.5% 自动回退。

拓展思考

  1. 多视角赛事直播:每条 MediaItem 对应一路视角(如足球主裁、门线),字幕轨道携带实时战术解说,如何利用 ExoPlayer 的 “chunkless preparation” 做到视角秒切且字幕不闪?
  2. 无障碍合规:国内工信部 2024 新规要求 TOP 100 App 视频必须提供“实时字幕”,若源片无字幕,需调用云端语音转写并以 side-loaded 方式注入,如何设计低延迟 (< 300 ms) 的转写字幕管道?
  3. 广告拼接:播放列表中插入 Client-Side 广告,广告片通常不带字幕,如何动态为广告片注入与正片同语言字幕,避免用户感知“字幕消失”?
  4. 折叠屏场景:当用户展开折叠屏导致 Activity 重建,播放列表进度与字幕语言选择如何借助 Media3 的 DefaultMediaSession 实现跨进程恢复,保证“无缝续播”?