如何在 ExoPlayer 中实现播放列表和字幕切换功能?
解读
国内面试中,音视频方向是“高阶 Android”标签之一。ExoPlayer 作为 Google 官方维护、国内厂商(爱优腾、B 站)普遍落地的播放内核,面试官通过“播放列表 + 字幕切换”这一组合题,既考察候选人对 Media3/ExoPlayer 核心 API 的掌握,也验证其对“多轨道管理”“UI 状态同步”“内存与耗电”等落地细节的理解。答题时切忌只背 API,要体现“为什么这么做、边界 case 怎么处理、如何灰度验证”。
知识点
- ExoPlayer 架构:Player 接口 → ExoPlayer 实现;MediaItem 为不可变描述单元;MediaSource 负责协议解析(Progressive/ Dash/ HLS);TrackSelector 负责轨道决策;Renderer 负责解码渲染。
- 播放列表:ConcatenatingMediaSource(旧)/ Playlist 接口(Media3 推荐)支持动态增删、懒加载预缓冲;setMediaItems(List<MediaItem>) 可一次性提交队列。
- 字幕轨道:
- 内嵌字幕:MP4/MKV 中的 text track,ExoPlayer 通过 MappingTrackSelector 暴露 Format 对象,selectionFlags 包含 SELECTION_FLAG_DEFAULT。
- 外挂字幕:Side-loaded,构造 MediaItem.SubtitleConfiguration(Uri、MimeType、language、selectionFlags),与主视频在 MergingMediaSource 中合并;支持 WebVTT、TTML、SRT。
- 轨道切换:TrackSelectionParameters.Builder.setPreferredTextLanguage() / setDisabledTextTrackSelectionFlags();亦可运行时 TrackSelector.setParameters → player.setTrackSelectionParameters。
- UI 同步:Player.Listener.onTracksInfoChanged() 回调刷新字幕按钮;需主线程 post 避免并发。
- 国内合规:外挂字幕需先下载到沙箱私有目录,禁止明文存储;若含用户上传内容,需通过内容安全审核接口先审后发。
- 性能与体验:
- 预缓冲:DefaultLoadControl 的 maxBufferMs 建议 50 000 ms,5G 场景可动态下调;
- 无缝切换:setHandleAudioBecomingNoisy(true) + 音频焦点管理;
- 低电耗:后台播放使用 MediaSession + PlayerNotificationManager,适配 Android 14 前台服务类型“mediaPlayback”。
- 灰度与验证:内部测试使用 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% 自动回退。
拓展思考
- 多视角赛事直播:每条 MediaItem 对应一路视角(如足球主裁、门线),字幕轨道携带实时战术解说,如何利用 ExoPlayer 的 “chunkless preparation” 做到视角秒切且字幕不闪?
- 无障碍合规:国内工信部 2024 新规要求 TOP 100 App 视频必须提供“实时字幕”,若源片无字幕,需调用云端语音转写并以 side-loaded 方式注入,如何设计低延迟 (< 300 ms) 的转写字幕管道?
- 广告拼接:播放列表中插入 Client-Side 广告,广告片通常不带字幕,如何动态为广告片注入与正片同语言字幕,避免用户感知“字幕消失”?
- 折叠屏场景:当用户展开折叠屏导致 Activity 重建,播放列表进度与字幕语言选择如何借助 Media3 的 DefaultMediaSession 实现跨进程恢复,保证“无缝续播”?