一、背景
二、常见的切码流方案
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资源,包含音频和视频Trackarray.add(MediaItem.fromUri("asset://android_asset/viNM94G2aJw000_G/Video@MV@480/data"))//加入1080,包含音频和视频Trackarray.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一般是1Format 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;//查找匹配当前参数的periodHolderwhile (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轨道当前正在使用的SampleStreamSampleStream 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。
推荐站内搜索:最好用的开发软件、免费开源系统、渗透测试工具云盘下载、最新渗透测试资料、最新黑客工具下载……




还没有评论,来说两句吧...