一、背景
二、常见的切码流方案
DASH/HLS 切换:
双播放器切换:
上面说到,双播放器切换会受限于设备解码器数量限制,那是否可以在同一播放器中使用两种解码器?理论上说是可以的,但是却很少有人这样做,第一个原因是,如果要使用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 什么是多路流?
3.4 ExoPlayer如何将多路流输入到播放器中?
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 如何实现多路流切换呢?
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);
}
}
}
使用方式如下
SelectTrackHelper.switchToOtherVideoTrack(simpleExoPlayer,simpleExoPlayer.currentTracks,848,476)
3.6 切换过程
ExoPlayer#setTrackSelectionParameters
DefaultTrackSelector#setParameters
DefaultTrackSelector#invalidate
ExoPlayerImplInternal#onTrackSelectionsInvalidated ExoPlayerImplInternl#reselectTracksInternal
核心方法实现,具体逻辑会在下面代码中进行注释。
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);
}
}
四、对齐
重置并统一所有渲染器的播放时间
利用起播时解析的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),直接跳帧处理。
下面代码表示重置所有采样队列的开始时间
for (SampleQueue sampleQueue : sampleQueues) {
sampleQueue.setStartTimeUs(pendingResetPositionUs);
}
下面是对inputBuffer添加标记:
if (buffer.timeUs < startTimeUs) {
//这里对inputBuffer添加标记
buffer.addFlag(C.BUFFER_FLAG_DECODE_ONLY);
}
下面是在VideoRenderer处理时,对带这个标记的InputBuffer解码后的outputBuffer一律跳帧处理。
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为了避免黑屏,内部会强制渲染首帧和部分关键帧。
五、总结
利用MergingMediaSource可以实现多路流
利用DefaultTrackSelector可实现码流、原伴唱、音频品质切换
开发专业音视频应用,尽量不要使用MediaPlayer。
推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……
还没有评论,来说两句吧...