ExoPlayer 多路流切换

2023-09-19 16:09:18 浏览数 (3)

一、背景

国内互联网的发展的过程中,无论是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 位置处加载媒体资源。
代码语言:javascript复制
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时间大于等于统一的播放时间点。
代码语言:javascript复制
   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,本身播放进度在变化,因为这视频可能还需要跳过几帧,被切换的解码器才能正式渲染。
代码语言:javascript复制
  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。

0 人点赞