美文网首页
ExoPlayer 源码分析 五 码率自适应

ExoPlayer 源码分析 五 码率自适应

作者: iBOBO | 来源:发表于2021-04-14 00:01 被阅读0次

ExoPlayer 源码分析 一 HLS 拉流及播放流程
ExoPlayer 源码分析 二 类图 & 名词解释
ExoPlayer 源码分析 三 变速播放
ExoPlayer 源码分析 四 缓存策略
ExoPlayer 源码分析 五 码率自适应

本文基于 ExoPlayer 2.13.2 。

带宽预估

DASH 和 Hls 都可以提供不同码率的流,ExoPlayer 使用 BandwidthMeter 来计算带宽,它的默认实现是 DefaultBandwidthMeter 。

public interface BandwidthMeter extends TransferListener {

  public interface EventListener {

    /**
     * Invoked periodically to indicate that bytes have been transferred.
     */
    void onBandwidthSample(int elapsedMs, long bytes, long bitrate);
  }

  /**
   * Indicates no bandwidth estimate is available.
   */
  final long NO_ESTIMATE = -1;

  /**
   * Gets the estimated bandwidth, in bits/sec.
   *
   * @return Estimated bandwidth in bits/sec, or {@link #NO_ESTIMATE} if no estimate is available.
   */
  long getBitrateEstimate();

}

public final class DefaultBandwidthMeter implements BandwidthMeter {

  public static final int DEFAULT_MAX_WEIGHT = 2000;

  private final Handler eventHandler;
  private final EventListener eventListener;
  private final Clock clock;
  // 计算码率的工具,使用 moving average 来计算
  private final SlidingPercentile slidingPercentile;
  // 一个计算周期接受的字节数
  private long bytesAccumulator;
  // 一个计算周期的开始时间
  private long startTimeMs;
  // 带宽估值
  private long bitrateEstimate;
  private int streamCount;

  public DefaultBandwidthMeter() {
    this(null, null);
  }

  public DefaultBandwidthMeter(Handler eventHandler, EventListener eventListener) {
    this(eventHandler, eventListener, new SystemClock());
  }

  public DefaultBandwidthMeter(Handler eventHandler, EventListener eventListener, Clock clock) {
    this(eventHandler, eventListener, clock, DEFAULT_MAX_WEIGHT);
  }

  public DefaultBandwidthMeter(Handler eventHandler, EventListener eventListener, int maxWeight) {
    this(eventHandler, eventListener, new SystemClock(), maxWeight);
  }

  public DefaultBandwidthMeter(Handler eventHandler, EventListener eventListener, Clock clock,
      int maxWeight) {
    this.eventHandler = eventHandler;
    this.eventListener = eventListener;
    this.clock = clock;
    this.slidingPercentile = new SlidingPercentile(maxWeight);
    bitrateEstimate = NO_ESTIMATE;
  }

  @Override
  public synchronized long getBitrateEstimate() {
    return bitrateEstimate;
  }

  @Override
  public synchronized void onTransferStart() {
    if (streamCount == 0) {
      startTimeMs = clock.elapsedRealtime();
    }
    streamCount++;
  }

  @Override
  public synchronized void onBytesTransferred(int bytes) {
    bytesAccumulator += bytes;
  }

  @Override
  public synchronized void onTransferEnd() {
    Assertions.checkState(streamCount > 0);
    long nowMs = clock.elapsedRealtime();
    int elapsedMs = (int) (nowMs - startTimeMs);
    if (elapsedMs > 0) {
      // 单位用的是字节和毫秒所以要 *8000
      float bitsPerSecond = (bytesAccumulator * 8000) / elapsedMs;
      slidingPercentile.addSample((int) Math.sqrt(bytesAccumulator), bitsPerSecond);
      // 使用 slidingPercentile.getPercentile 计算出带宽估值
      float bandwidthEstimateFloat = slidingPercentile.getPercentile(0.5f);
      bitrateEstimate = Float.isNaN(bandwidthEstimateFloat) ? NO_ESTIMATE
          : (long) bandwidthEstimateFloat;
      notifyBandwidthSample(elapsedMs, bytesAccumulator, bitrateEstimate);
    }
    streamCount--;
    if (streamCount > 0) {
      startTimeMs = nowMs;
    }
    bytesAccumulator = 0;
  }

  private void notifyBandwidthSample(final int elapsedMs, final long bytes, final long bitrate) {
    if (eventHandler != null && eventListener != null) {
      eventHandler.post(new Runnable()  {
        @Override
        public void run() {
          eventListener.onBandwidthSample(elapsedMs, bytes, bitrate);
        }
      });
    }
  }

}

slidingPercentile.addSample((int) Math./sqrt/(bytesAccumulator), bitsPerSecond) 会将当前采样周期传输的字节开方以及传输速率封装成一个 Sample 放入 SlidingPercentile 中的集合里。

向集合中添加 Sample 的时候会保证当前所有 Sample 的总 weight 保持在设定的 maxWeight 以下,如果超出则移除最早添加的数据。

带宽的预估则采用如下方法:

/**
 * Compute the percentile by integration.
 *
 * @param percentile The desired percentile, expressed as a fraction in the range (0,1].
 * @return The requested percentile value or Float.NaN.
 */
public float getPercentile(float percentile) {
  // Sample 按 value 从小到大排列
  ensureSortedByValue();
  float desiredWeight = percentile * totalWeight;
  int accumulatedWeight = 0;
  for (int i = 0; i < samples.size(); i++) {
    Sample currentSample = samples.get(i);
    // weight 相加
    accumulatedWeight += currentSample.weight;
   // 向加后的 weight 达到 desiredWeight 时返回当前 sample 的value
    if (accumulatedWeight >= desiredWeight) {
      return currentSample.value;
    }
  }
  // Clamp to maximum value or NaN if no values.
  return samples.isEmpty() ? Float.NaN : samples.get(samples.size() - 1).value;
}

Hls 码率切换

切换时机 & 条件

Hls 码率切换的代码在 HlsChunkSource#getChunkOperation 中,它首先会通过 getNextVariantIndex 获得一个候选 Variant id,其主要步骤及条件如下:

  • 根据预估带宽获取候选 Variant id 即 idealIndex。
  • 切向码率更低的流,缓存数据的可用时间要小于 20 s。
  • 切向码率更高的流,缓存数据的可用时间要大于 5 s。

得到 Variant index 之后会回到 getChunkOperation 代码如下:

public void getChunkOperation(TsChunk previousTsChunk, long playbackPositionUs,
    ChunkOperationHolder out) {
  int previousChunkVariantIndex =
      previousTsChunk == null ? -1 : getVariantIndex(previousTsChunk.format);
  // 下一个 VariantIndex
  int nextVariantIndex = getNextVariantIndex(previousTsChunk, playbackPositionUs);
  boolean switchingVariant = previousTsChunk != null
      && previousChunkVariantIndex != nextVariantIndex;

  HlsMediaPlaylist mediaPlaylist = variantPlaylists[nextVariantIndex];
  if (mediaPlaylist == null) {
    // We don't have the media playlist for the next variant. Request it now.
    // 当前不存在特定的 media playlist 需要请求
    out.chunk = newMediaPlaylistChunk(nextVariantIndex);
    return;
  }

  selectedVariantIndex = nextVariantIndex;
  int chunkMediaSequence;
  // 根据 live 与否,计算下一个 Chunk 的 MediaSequence
  if (live) {
    if (previousTsChunk == null) {
      chunkMediaSequence = getLiveStartChunkSequenceNumber(selectedVariantIndex);
    } else {
      chunkMediaSequence = getLiveNextChunkSequenceNumber(previousTsChunk.chunkIndex,
          previousChunkVariantIndex, selectedVariantIndex);
      if (chunkMediaSequence < mediaPlaylist.mediaSequence) {
        fatalError = new BehindLiveWindowException();
        return;
      }
    }
  } else {
    // Not live.
    if (previousTsChunk == null) {
      chunkMediaSequence = Util.binarySearchFloor(mediaPlaylist.segments, playbackPositionUs,
          true, true) + mediaPlaylist.mediaSequence;
    } else if (switchingVariant) {
      chunkMediaSequence = Util.binarySearchFloor(mediaPlaylist.segments,
          previousTsChunk.startTimeUs, true, true) + mediaPlaylist.mediaSequence;
    } else {
      chunkMediaSequence = previousTsChunk.getNextChunkIndex();
    }
  }

  // 
  int chunkIndex = chunkMediaSequence - mediaPlaylist.mediaSequence;
  if (chunkIndex >= mediaPlaylist.segments.size()) {
    if (!mediaPlaylist.live) {
      out.endOfStream = true;
    } else if (shouldRerequestLiveMediaPlaylist(selectedVariantIndex)) {
      out.chunk = newMediaPlaylistChunk(selectedVariantIndex);
    }
    return;
  }

  ...

}


缓存切换

Hls 的 RollingSampleBuffer 缓存在 DefaultTrackOutput 中,码率切换时会创建新的 DefaultTrackOutput,解码时只需要选一个合适的时机切换 DefaultTrackOutput就好了。
具体做法是:

  • DefaultTrackOutput 中有个变量:spliceOutTimeUs 控制当前缓存需要切出时间。
  • 当有多个 HlsExtractorWrapper 时会计算这个时间。
  • 时间到了切换到下一个 HlsExtractorWrapper
DefaultTrackOutput. configureSpliceTo

public boolean configureSpliceTo(DefaultTrackOutput nextQueue) {
  if (spliceOutTimeUs != Long.MIN_VALUE) {
    // We've already configured the splice.
    return true;
  }
  long firstPossibleSpliceTime;
  if (rollingBuffer.peekSample(sampleInfoHolder)) {
    firstPossibleSpliceTime = sampleInfoHolder.timeUs;
  } else {
    firstPossibleSpliceTime = lastReadTimeUs + 1;
  }
  RollingSampleBuffer nextRollingBuffer = nextQueue.rollingBuffer;
  while (nextRollingBuffer.peekSample(sampleInfoHolder)
      && (sampleInfoHolder.timeUs < firstPossibleSpliceTime || !sampleInfoHolder.isSyncFrame())) {
    // Discard samples from the next queue for as long as they are before the earliest possible
    // splice time, or not keyframes.
    nextRollingBuffer.skipSample();
  }
  if (nextRollingBuffer.peekSample(sampleInfoHolder)) {
    // We've found a keyframe in the next queue that can serve as the splice point. Set the
    // splice point now.
    spliceOutTimeUs = sampleInfoHolder.timeUs;
    return true;
  }
  return false;
}


private boolean advanceToEligibleSample() {
  boolean haveNext = rollingBuffer.peekSample(sampleInfoHolder);
  if (needKeyframe) {
    while (haveNext && !sampleInfoHolder.isSyncFrame()) {
      rollingBuffer.skipSample();
      haveNext = rollingBuffer.peekSample(sampleInfoHolder);
    }
  }
  if (!haveNext) {
    return false;
  }
  // 如果sampleInfoHolder.timeUs 超过了spliceOutTimeUs 返回 false
  if (spliceOutTimeUs != Long.MIN_VALUE && sampleInfoHolder.timeUs >= spliceOutTimeUs) {
    return false;
  }
  return true;
}

public boolean isEmpty() {
  return !advanceToEligibleSample();
}

读取数据的时候会判断缓存是否为空,这时候就用到了上面计算的 spliceOutTimeUs 。

DASH 码率切换

切换时机

切换代码在 FormatEvaluator 中,代码如下:

@Override
public void evaluate(List<? extends MediaChunk> queue, long playbackPositionUs,
    Format[] formats, Evaluation evaluation) {
  long bufferedDurationUs = queue.isEmpty() ? 0
      : queue.get(queue.size() - 1).endTimeUs - playbackPositionUs;
  Format current = evaluation.format;
  // 根据带宽选择一个合适的 Format
  Format ideal = determineIdealFormat(formats, bandwidthMeter.getBitrateEstimate());
  // 是否码率提升
  boolean isHigher = ideal != null && current != null && ideal.bitrate > current.bitrate;
  // 是否码率降低
  boolean isLower = ideal != null && current != null && ideal.bitrate < current.bitrate;
  if (isHigher) {
    // 码率提升但是当前缓存小于 10s,放弃
    if (bufferedDurationUs < minDurationForQualityIncreaseUs) {
      // The ideal format is a higher quality, but we have insufficient buffer to
      // safely switch up. Defer switching up for now.
      ideal = current;
    } else if (bufferedDurationUs >= minDurationToRetainAfterDiscardUs) {
      // We're switching from an SD stream to a stream of higher resolution. Consider
      // discarding already buffered media chunks. Specifically, discard media chunks starting
      // from the first one that is of lower bandwidth, lower resolution and that is not HD.
      for (int i = 1; i < queue.size(); i++) {
        MediaChunk thisChunk = queue.get(i);
        long durationBeforeThisSegmentUs = thisChunk.startTimeUs - playbackPositionUs;
        if (durationBeforeThisSegmentUs >= minDurationToRetainAfterDiscardUs
            && thisChunk.format.bitrate < ideal.bitrate
            && thisChunk.format.height < ideal.height
            && thisChunk.format.height < 720
            && thisChunk.format.width < 1280) {
          // Discard chunks from this one onwards.
          evaluation.queueSize = i;
          break;
        }
      }
    }
  } else if (isLower && current != null
    && bufferedDurationUs >= maxDurationForQualityDecreaseUs) {
    // 向低码率转换,但是当前缓存足够,先不转
    // The ideal format is a lower quality, but we have sufficient buffer to defer switching
    // down for now.
    ideal = current;
  }
  if (current != null && ideal != current) {
    evaluation.trigger = Chunk.TRIGGER_ADAPTIVE;
  }
  evaluation.format = ideal;
}

小结

  • 向高码率切换,当前缓存需要大于 10s。
  • 向低码率切换,当前缓存需要小于 25s。

缓存切换

与 Hls 会使用多个 DefaultTrackOutput 来缓存数据不同,DASH 仅使用一个(音频、视频、字幕流各有一个) DefaultTrackOutput 来保存数据。在码率切换的时候如果是码率提升切已缓存数据大于 25 s,那就要丢掉一部分数据然后加载更高码率的数据。
这个做法需要三步:

  • 计算出从哪块开始加载新的数据
  • 这块之后的缓存数据丢弃。
  • 从这块开始加载更高码率的数据

代码中记录这「块」的索引的变量是 queueSize。

1、计算在上面贴出 FormatEvaluator evaluate 方法中,这里再贴一遍。

    // 码率提升但是当前缓存小于 10s,放弃
    if (bufferedDurationUs < minDurationForQualityIncreaseUs) {
      // The ideal format is a higher quality, but we have insufficient buffer to
      // safely switch up. Defer switching up for now.
      ideal = current;
    } else if (bufferedDurationUs >= minDurationToRetainAfterDiscardUs) {
      // 如果已经缓存的数据很多,这时候我们只要保留一部分数据来满足当前观看,然后缓存更高质量的数据
        // minDurationToRetainAfterDiscardUs(25s) 是一个时间,只需要保留这段时间的数据,后边的丢弃
      // We're switching from an SD stream to a stream of higher resolution. Consider
      // discarding already buffered media chunks. Specifically, discard media chunks starting
      // from the first one that is of lower bandwidth, lower resolution and that is not HD.
      for (int i = 1; i < queue.size(); i++) {
        MediaChunk thisChunk = queue.get(i);
        long durationBeforeThisSegmentUs = thisChunk.startTimeUs - playbackPositionUs;
        // 根据 minDurationToRetainAfterDiscardUs 计算从哪块开始丢弃
        if (durationBeforeThisSegmentUs >= minDurationToRetainAfterDiscardUs
            && thisChunk.format.bitrate < ideal.bitrate
            && thisChunk.format.height < ideal.height
            && thisChunk.format.height < 720
            && thisChunk.format.width < 1280) {
          // Discard chunks from this one onwards.
          evaluation.queueSize = i;
          break;
        }
      }
    }
  }

2、丢数据代码:

private boolean discardUpstreamMediaChunks(int queueLength) {
  if (mediaChunks.size() <= queueLength) {
    return false;
  }
  long startTimeUs = 0;
  long endTimeUs = mediaChunks.getLast().endTimeUs;

  BaseMediaChunk removed = null;
  while (mediaChunks.size() > queueLength) {
    removed = mediaChunks.removeLast();
    startTimeUs = removed.startTimeUs;
    loadingFinished = false;
  }
  // getFirstSampleIndex 是当前 chunk 缓存的第一个 sample 的索引
  // 从这个索引往后的数据都要丢掉
  sampleQueue.discardUpstreamSamples(removed.getFirstSampleIndex());

  notifyUpstreamDiscarded(startTimeUs, endTimeUs);
  return true;
}

3、构建新的 Chunk,重新加载数据。

out.queueSize = evaluation.queueSize;

MediaChunk previous = queue.get(out.queueSize - 1);
long nextSegmentStartTimeUs = previous.endTimeUs;

int segmentNum = queue.isEmpty() ? representationHolder.getSegmentNum(playbackPositionUs)
      : startingNewPeriod ? representationHolder.getFirstAvailableSegmentNum()
      : queue.get(out.queueSize - 1).getNextChunkIndex();

Chunk nextMediaChunk = newMediaChunk(periodHolder, representationHolder, dataSource,
    mediaFormat, enabledTrack, segmentNum, evaluation.trigger, mediaFormat != null);
lastChunkWasInitialization = false;
out.chunk = nextMediaChunk;

如果切向低码率的流,第二步是不需要的。

小结

  • DASH 一个视频流的数据缓存在一个,RollingBuffer 里。
  • 码率切换就是选择一个时间点,在这个时间之后缓存改变码率后的数据。
  • 如果是码率提升且已缓存数据可播放时间大于 25s,需要把 25s 以后的缓存丢弃,然后缓存更高码率的数据。

相关文章

网友评论

      本文标题:ExoPlayer 源码分析 五 码率自适应

      本文链接:https://www.haomeiwen.com/subject/yomxlltx.html