一、背景
国内互联网的发展的过程中,无论是3G、4G还是5G时代,甚至是在可见的未来nG时代,音视频领域一直自始至终参与其中,编解码标准也升级了一版又一版,和音视频的相关应用领域从传统的播放转为互动直播。从另一个方面,伴随中国的互联网发展的每一个过程,从高昂且卡慢流量资费到VIP、SVIP、SSVIP......,再到即将到来的人工智能和Web 3.0 ,必然也少不了音视频。接下来需要考虑你的钱包还能支撑多久,是不是已经准备好了?
音视频应用如腾讯视频、爱奇艺、B站、抖音、快手等大厂都支持码流切换,尤其是B站在码流切换和编解码器这方面玩的也是很溜,这类应用都可以很平滑的切换,当然各大厂的服务后台支持也很完善,HLS、DASH等自适应流支持的很完美。伴随着大环境的问题和市场需求,以及降本增效的影响,需要支持4K/1080P/720P/480P、音质切换、原伴唱切换的应用来说,如果受限于HLS和DASH支持不完善情况,这个难度相对来说还是比较高的。那有没有其他可行的方案呢 ?答案是肯定的,先来看看常见的切码流方案。
二、常见的切码流方案
DASH/HLS 切换:
这种切换相对来说是最友好的方式,可以在不中断播放的情况下,在下一个媒体片段处实现平滑切换,这种方式也是很多应用最常用的方案,无论是开发成本和用户体验也是最优的方案之一,同样对于前端开发人员来说相对友好,很多播放器都是默认支持DASH和HLS码流切换的。这种也是ExoPlayer支持本身支持的方式。
双播放器切换:
这种是一种相对来说比较原始的方案,正在播放的过程中,启动一个新的播放器播,并且将渲染画布alpha设置为透明,同时新的播放器Seek到比当前播放器播放位置更靠前的地方,直到播放位置大概相同时切换画布透明度,终止切一个播放器。相对来说,这种方案实现起来更加复杂,其次很多IOT设备对解码器数量有严格的限制,有的电视机上某种解码器只支持单个实例甚至更少的实例,多一个可能出现要么新的播放器播不起来,要么旧的黑屏或者Crash。不过作为一种原始的方案,并不意味它没有价值,后续的方案基本都是在这种原始的方案上进行了一系列创新。
双解码器切换:
上面说到,双播放器切换会受限于设备解码器数量限制,那是否可以在同一播放器中使用两种解码器?理论上说是可以的,但是却很少有人这样做,第一个原因是,如果要使用2种硬解码器,必然受到硬件制约,因为硬解码器在很多设备上作为DSP芯片的一部分,设备厂商不可能配置2个以上DSP芯片,特别对于IOT设备,尤其是TV,绝大部分成本在屏幕上,上个好点的CPU都很难;第二个原因如果使用软解码器 硬解码器,软解码器性能好的时候没有问题,但是性能差可能卡顿问题会相当多。那是不是没辙了呢?其实也不是,如果能保证不同封装和编码格式以及较低的清晰度的资源,使用不同的硬解码器,也能比较完美地实现,但是这个也会显著增大后台资源管理的难度。
重启播放器切换:
无论双播放器还是双解码器切换显然存在维护成本过高的问题,一种可行的方法,就是重启播放器,并Seek到当前播放点,这个过程相当于重播 Seek。好处是能避免很多问题,但问题也是显而易见的,第一就是就是需要在某些业务中,保留重启前的一些状态,在Seek完成之后再恢复回来。
重启解码器切换:
重启播放器既然可以,重启解码器也是可以的,当然首先要排除Android MediaPlayer这种播放器,不仅不支持码流切换,也不支持音频或者视频Track切换,仅支持字幕Track的切换,另外也不支持时钟同步。这种播放器只能使用重启播放器方式实现码流切换。ExoPlayer作为开源播放器,具备很好的可扩展性,既支持DASH/HLS切换,同时也支持解码器重启方式的切换。
三、ExoPlayer 如何实现多路流切换?
这里我们不说DASH、HLS部分,这部分其实有很多资料,ExoPlayer本身也是支持的。本篇主要分析一下另一种低成本的多路流切换方式——重启解码器实现多路流切换。
3.1 首先了解下多路流切换可以实现的功能。
- 原伴唱切换
- 音频品质切换
- 视频清晰度切换
- 其他渲染器资源切换
3.2 什么是多路流?
所谓多路流是指播放过程中,存在多个I/O相关的媒体资源。对于常见的Mp4而言,一般来说既包括音频轨道,又包括视频轨道,在解封装之后,一路进入音频渲染器中,一路进入视频渲染器中,属于典型的两路流。而ExoPlayer本质上是支持多路流的,可以同时支持多个Mp4、多个音频文件、多种语言版本的歌词。
3.3 MediaPlayer是否支持多路流
不支持,也没法切换
3.4 ExoPlayer如何将多路流输入到播放器中?
ExoPlayer 支持多种资源读取方式,以MediaSource 的子类开放给开发者使用,我们常用的有ProgressiveMediaSource、DashMediaSource、HlsMediaSource、ClippingMediaSoutce (片段流)、RtspMediaSource、MergingMediaSource等。其中,MergingMediaSource 可以实现多路流合并入到同一个MediaSource中。
代码语言:javascript复制 val mediaDataSourceFactory: DataSource.Factory = DefaultDataSource.Factory(this)
var array = ArrayList<MediaItem>()
var mediaSources = ArrayList<MediaSource>()
//加入480资源,包含音频和视频Track
array.add(MediaItem.fromUri("asset://android_asset/viNM94G2aJw000_G/Video@MV@480/data"))
//加入1080,包含音频和视频Track
array.add(MediaItem.fromUri("asset://android_asset/viNM94G2aJw000_G/Video@MV@1080/data"))
//再加入2组音频,可以实现音频切换效果,下面的ACC是高品质伴奏
array.add(MediaItem.fromUri("asset://android_asset/viNM94G2aJw000_G/Audio@ACC@Q_1/data"))
//加入原唱
array.add(MediaItem.fromUri("asset://android_asset/viNM94G2aJw000_G/Audio@ORI@Q_1/data"))
val mediaSourceFactory = DefaultMediaSourceFactory(mediaDataSourceFactory)
array.forEach {
mediaSources.add(mediaSourceFactory.createMediaSource(it))
}
var targetMergingMediaSource = MergingMediaSource(mediaSources[0],mediaSources[1],mediaSources[2])
3.5 ExoPlayer 如何实现多路流切换呢?
其实和很多博客中提到的原唱和伴唱切换一样,都是通过DefaultTrackSelector来实现,DefaultTrackSelector作为ExoPlayer Track流筛选的重要组件,可以通过我们设置的既定条件,实现码流切换,下面是一种切换分辨率的方式,我们通过视频尺寸切换视频Track。
代码语言:javascript复制public static void switchToOtherVideoTrack(ExoPlayer exoPlayer, @NotNull Tracks tracks, int width, int heigth) {
if (tracks == null || exoPlayer == null) return;
ImmutableList<Tracks.Group> groups = tracks.getGroups();
for (Tracks.Group group :
groups) {
if (group == null) continue;
if (group.getType() != C.TRACK_TYPE_VIDEO) {
continue; //非视频的不切换
}
boolean selected = group.isSelected();
if (selected) {
continue; //当前播的不切换
}
for (int trackIndex = 0; trackIndex < group.length; trackIndex ) {
//获取一种匹配的视频,理论上group.length一般是1
Format trackFormat = group.getTrackFormat(trackIndex);
if (trackFormat.width != width || trackFormat.height != heigth) {
continue;
}
TrackSelectionParameters trackSelectionParameters = exoPlayer.getTrackSelectionParameters();
TrackSelectionParameters selectionParameters = trackSelectionParameters
.buildUpon()
.setOverrideForType(
new TrackSelectionOverride(
group.getMediaTrackGroup(), ImmutableList.of(trackIndex) //设置目标媒体资源组和目标Track索引
)
)
.setTrackTypeDisabled(group.getType(), /* disabled= */ false) //保证改Track不被关闭
.build();
exoPlayer.setTrackSelectionParameters(selectionParameters);
Log.d("SelectTrackHelper", "--->group :" group ", selected=" selected ",group=" group.getType() "," trackFormat);
}
}
}
使用方式如下
代码语言:javascript复制SelectTrackHelper.switchToOtherVideoTrack(simpleExoPlayer,simpleExoPlayer.currentTracks,848,476)
3.6 切换过程
设置目标参数
- ExoPlayer#setTrackSelectionParameters
- DefaultTrackSelector#setParameters
- DefaultTrackSelector#invalidate
通知播放器更新
- ExoPlayerImplInternal#onTrackSelectionsInvalidated
- ExoPlayerImplInternl#reselectTracksInternal
核心方法实现,具体逻辑会在下面代码中进行注释。
代码语言:javascript复制 private void reselectTracksInternal() throws ExoPlaybackException {
float playbackSpeed = mediaClock.getPlaybackParameters().speed;
// Reselect tracks on each period in turn, until the selection changes.
MediaPeriodHolder periodHolder = queue.getPlayingPeriod();
MediaPeriodHolder readingPeriodHolder = queue.getReadingPeriod();
boolean selectionsChangedForReadPeriod = true;
TrackSelectorResult newTrackSelectorResult;
//查找匹配当前参数的periodHolder
while (true) {
if (periodHolder == null || !periodHolder.prepared) {
// The reselection did not change any prepared periods.
return;
}
//这里是重点,会调用到MappingTrackSelector#selectTracks方法,返回新的结果
newTrackSelectorResult = periodHolder.selectTracks(playbackSpeed, playbackInfo.timeline);
if (!newTrackSelectorResult.isEquivalent(periodHolder.getTrackSelectorResult())) {
// Selected tracks have changed for this period.
//判断新的结果和当前是不是一样,一样的话重新选择,不一样说明选择成功
break;
}
if (periodHolder == readingPeriodHolder) {
// The track reselection didn't affect any period that has been read.
selectionsChangedForReadPeriod = false;
}
periodHolder = periodHolder.getNext();
}
//重建流数组,如果匹配的解码位置比较靠前的话
if (selectionsChangedForReadPeriod) {
// Update streams and rebuffer for the new selection, recreating all streams if reading ahead.
MediaPeriodHolder playingPeriodHolder = queue.getPlayingPeriod();
boolean recreateStreams = queue.removeAfter(playingPeriodHolder);
boolean[] streamResetFlags = new boolean[renderers.length];
long periodPositionUs =
playingPeriodHolder.applyTrackSelection(
newTrackSelectorResult, playbackInfo.positionUs, recreateStreams, streamResetFlags);
playbackInfo =
handlePositionDiscontinuity(
playbackInfo.periodId, periodPositionUs, playbackInfo.requestedContentPositionUs);
if (playbackInfo.playbackState != Player.STATE_ENDED
&& periodPositionUs != playbackInfo.positionUs) {
playbackInfoUpdate.setPositionDiscontinuity(Player.DISCONTINUITY_REASON_INTERNAL);
resetRendererPosition(periodPositionUs);
}
//按照Renders顺序,分别对比每个Renderer和每个SampleStream,判断当前正在使用的渲染器Track流是否匹配
//注意:这里是循环,说明我们切换多路流时可以同时切换音频和视频等轨道
boolean[] rendererWasEnabledFlags = new boolean[renderers.length];
for (int i = 0; i < renderers.length; i ) {
Renderer renderer = renderers[i];
//获取第i轨道正在使用的渲染器,注意这里是可以渲染
rendererWasEnabledFlags[i] = isRendererEnabled(renderer);
//获取第i轨道当前正在使用的SampleStream
SampleStream sampleStream = playingPeriodHolder.sampleStreams[i];
//当前渲染器正在使用才会被检测
if (rendererWasEnabledFlags[i]) {
if (sampleStream != renderer.getStream()) {
// We need to disable the renderer.
//如果当前渲染器的码流和目标码流不匹配,则关闭当前渲染器
disableRenderer(renderer);
} else if (streamResetFlags[i]) {
// The renderer will continue to consume from its current stream, but needs to be reset.
renderer.resetPosition(rendererPositionUs);
//如果码流匹配,统一同步播放位置
}
}
}
//重新创建被关闭的渲染器
enableRenderers(rendererWasEnabledFlags);
} else {
//如果还没播放,则直接走启动逻辑
// Release and re-prepare/buffer periods after the one whose selection changed.
queue.removeAfter(periodHolder);
if (periodHolder.prepared) {
long loadingPeriodPositionUs =
max(periodHolder.info.startPositionUs, periodHolder.toPeriodTime(rendererPositionUs));
periodHolder.applyTrackSelection(newTrackSelectorResult, loadingPeriodPositionUs, false);
}
}
handleLoadingMediaPeriodChanged(/* loadingTrackSelectionChanged= */ true);
if (playbackInfo.playbackState != Player.STATE_ENDED) {
//这里会通过开始时间,查询SeekPoint,设置采样队列时间界值
maybeContinueLoading();
updatePlaybackPositions();
handler.sendEmptyMessage(MSG_DO_SOME_WORK);
}
}
3.7 效果评价
整个过程完全在ExoPlayer内部实现,正常网速切换速度也是很快的,当然相比HLS、DASH方式,整个切换过程有还是有明显的轻微的卡顿,不过对于人力本就不富裕的小团队来说,这个显然易见的方便。
四、对齐
4.1 对齐流程
本文所说的对齐和DASH、HLS有本质的区别,不存在切片,但是仍然要解决对齐问题,在ExoPlayer中对齐的过程中并没有直接去调用seek方法对齐,而是通过SeekPoint 音画同步实现了对齐逻辑,具体对齐步骤如下:
- 重置并统一所有渲染器的播放时间
- 利用起播时解析的Track信息,重新注册新的解码器
- 查找最接近且小于播放时间的SeekPoint ,这个播放点是一个GOP的开始位置,也是IDR帧的位置(IDR帧是I帧的一种);一般来说Mp4 文件头部有moov信息,从采样表(sample table)中可以查找出关键帧和关键帧所映射的文件位置信息,采样表会在起播阶段完成解析。
- 查找出位置后从SeekPoint 位置处加载媒体资源。
loadable.setLoadPosition(
checkNotNull(seekMap).getSeekPoints(pendingResetPositionUs).first.position,
pendingResetPositionUs);
- 设置所有采样队列的开始时间界值,解码出的inputBuffer如果pts小于这个时间的,一律加上BUFFER_FLAG_DECODE_ONLY标记,后续一旦带有这个标记的buffer被解码,如果使用的是SimpleDecoder解码,也会与之相对应的outputBuffer也加上这个标记,一律不予送显(渲染到Surface),直接跳帧处理。
下面代码表示重置所有采样队列的开始时间
代码语言:javascript复制 for (SampleQueue sampleQueue : sampleQueues) {
sampleQueue.setStartTimeUs(pendingResetPositionUs);
}
下面是对inputBuffer添加标记:
代码语言:javascript复制if (buffer.timeUs < startTimeUs) {
//这里对inputBuffer添加标记
buffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY);
}
下面是在VideoRenderer处理时,对带这个标记的InputBuffer解码后的outputBuffer一律跳帧处理。
代码语言:javascript复制if (isDecodeOnlyBuffer && !isLastBuffer) {
skipOutputBuffer(codec, bufferIndex, presentationTimeUs);
return true;
}
- 渲染器从数据数据源不断读取、解码、直到outputBuffer时间大于等于统一的播放时间点。
public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException {
if (bypassEnabled) {
TraceUtil.beginSection("bypassRender");
while (bypassRender(positionUs, elapsedRealtimeUs)) {}
TraceUtil.endSection();
} else if (codec != null) {
long renderStartTimeMs = SystemClock.elapsedRealtime();
TraceUtil.beginSection("drainAndFeed");
//消费InputBuffer数据
while (drainOutputBuffer(positionUs, elapsedRealtimeUs)
&& shouldContinueRendering(renderStartTimeMs)) {}
//读取InputBuffer数据
while (feedInputBuffer() && shouldContinueRendering(renderStartTimeMs)) {}
TraceUtil.endSection();
} else {
decoderCounters.skippedInputBufferCount = skipSource(positionUs);
// We need to read any format changes despite not having a codec so that drmSession can be
// updated, and so that we have the most recent format should the codec be initialized. We
// may also reach the end of the stream. Note that readSource will not read a sample into a
// flags-only buffer.
readToFlagsOnlyBuffer(/* requireFormat= */ false);
}
decoderCounters.ensureUpdated();
}
- 进入音画同步阶段,因为切换过程中无论是独立MediaClock还是Audio Master MediaClock,本身播放进度在变化,因为这视频可能还需要跳过几帧,被切换的解码器才能正式渲染。
boolean treatDroppedBuffersAsSkipped = joiningDeadlineMs != C.TIME_UNSET;
if (shouldDropBuffersToKeyframe(earlyUs, elapsedRealtimeUs, isLastBuffer)
&& maybeDropBuffersToKeyframe(
codec, bufferIndex, presentationTimeUs, positionUs, treatDroppedBuffersAsSkipped)) {
return false;
} else if (shouldDropOutputBuffer(earlyUs, elapsedRealtimeUs, isLastBuffer)) {
if (treatDroppedBuffersAsSkipped) {
skipOutputBuffer(codec, bufferIndex, presentationTimeUs);
} else {
dropOutputBuffer(codec, bufferIndex, presentationTimeUs);
}
updateVideoFrameProcessingOffsetCounters(earlyUs);
return true;
}
至此,整个对齐流程分析完成。
4.2 对齐结果补充
4.2.1 音频和视频对齐共同点:
- 音频和视频对齐时各自的渲染器都可能会有轻微的跳帧现象,当然这些调整和卡顿感也和IO速度、CPU负载网速也有一定的关系,磁盘、CPU运行效率越高,自然感知程度也会愈加自然减弱。
4.2.2 音频和视频对齐不同点:
- 相对来说,音频对齐要简单的多,音频解码后的数据是有规律地线性排列,在保证播放时间的准确的基础上,保证声音通道数、位深排列顺序正常就行(比如对齐之后,不能将左声道变为右声道),不需要考虑参考帧的问题,总体而言几乎没有卡顿感,甚至也不需要跳帧。
- 对齐过程中,ExoPlayer只要存在音频渲染器,那么音画同步的时间以音频为准。
- 对齐过程中,如果缺少音频,那么音画同步以独立时钟为主。
- 独立时钟相比音频时钟而言,由于线程的执行速度要慢且时间不可静止的问题,视频画面可能需要跳过很多帧,甚至会卡帧。
- 对于视频渲染器,ExoPlayer为了避免黑屏,内部会强制渲染首帧和部分关键帧。
五、总结
ExoPlayer 具备完善的多路流切换,高可扩展性,可以实现MediaClock扩展、Renderer裁剪、多路流切换、自定义解封装器,也方便很多人学习音视频知识。最后,本篇知识点总结如下:
- 利用MergingMediaSource可以实现多路流
- 利用DefaultTrackSelector可实现码流、原伴唱、音频品质切换
- 开发专业音视频应用,尽量不要使用MediaPlayer。