ExoPlayer 源碼分析 五 碼率自適應(yīng)

ExoPlayer 源碼分析 一 HLS 拉流及播放流程
ExoPlayer 源碼分析 二 類圖 & 名詞解釋
ExoPlayer 源碼分析 三 變速播放
ExoPlayer 源碼分析 四 緩存策略
ExoPlayer 源碼分析 五 碼率自適應(yīng)

本文基于 ExoPlayer 2.13.2 。

帶寬預(yù)估

DASH 和 Hls 都可以提供不同碼率的流望浩,ExoPlayer 使用 BandwidthMeter 來計(jì)算帶寬悴晰,它的默認(rèn)實(shí)現(xiàn)是 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;
  // 計(jì)算碼率的工具,使用 moving average 來計(jì)算
  private final SlidingPercentile slidingPercentile;
  // 一個(gè)計(jì)算周期接受的字節(jié)數(shù)
  private long bytesAccumulator;
  // 一個(gè)計(jì)算周期的開始時(shí)間
  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) {
      // 單位用的是字節(jié)和毫秒所以要 *8000
      float bitsPerSecond = (bytesAccumulator * 8000) / elapsedMs;
      slidingPercentile.addSample((int) Math.sqrt(bytesAccumulator), bitsPerSecond);
      // 使用 slidingPercentile.getPercentile 計(jì)算出帶寬估值
      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) 會(huì)將當(dāng)前采樣周期傳輸?shù)淖止?jié)開方以及傳輸速率封裝成一個(gè) Sample 放入 SlidingPercentile 中的集合里悴务。

向集合中添加 Sample 的時(shí)候會(huì)保證當(dāng)前所有 Sample 的總 weight 保持在設(shè)定的 maxWeight 以下,如果超出則移除最早添加的數(shù)據(jù)。

帶寬的預(yù)估則采用如下方法:

/**
 * 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 達(dá)到 desiredWeight 時(shí)返回當(dāng)前 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 碼率切換

切換時(shí)機(jī) & 條件

Hls 碼率切換的代碼在 HlsChunkSource#getChunkOperation 中箱玷,它首先會(huì)通過 getNextVariantIndex 獲得一個(gè)候選 Variant id,其主要步驟及條件如下:

  • 根據(jù)預(yù)估帶寬獲取候選 Variant id 即 idealIndex陌宿。
  • 切向碼率更低的流锡足,緩存數(shù)據(jù)的可用時(shí)間要小于 20 s。
  • 切向碼率更高的流壳坪,緩存數(shù)據(jù)的可用時(shí)間要大于 5 s舶得。

得到 Variant index 之后會(huì)回到 getChunkOperation 代碼如下:

public void getChunkOperation(TsChunk previousTsChunk, long playbackPositionUs,
    ChunkOperationHolder out) {
  int previousChunkVariantIndex =
      previousTsChunk == null ? -1 : getVariantIndex(previousTsChunk.format);
  // 下一個(gè) 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.
    // 當(dāng)前不存在特定的 media playlist 需要請求
    out.chunk = newMediaPlaylistChunk(nextVariantIndex);
    return;
  }

  selectedVariantIndex = nextVariantIndex;
  int chunkMediaSequence;
  // 根據(jù) live 與否,計(jì)算下一個(gè) 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 中爽蝴,碼率切換時(shí)會(huì)創(chuàng)建新的 DefaultTrackOutput沐批,解碼時(shí)只需要選一個(gè)合適的時(shí)機(jī)切換 DefaultTrackOutput就好了。
具體做法是:

  • DefaultTrackOutput 中有個(gè)變量:spliceOutTimeUs 控制當(dāng)前緩存需要切出時(shí)間蝎亚。
  • 當(dāng)有多個(gè) HlsExtractorWrapper 時(shí)會(huì)計(jì)算這個(gè)時(shí)間珠插。
  • 時(shí)間到了切換到下一個(gè) 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();
}

讀取數(shù)據(jù)的時(shí)候會(huì)判斷緩存是否為空,這時(shí)候就用到了上面計(jì)算的 spliceOutTimeUs 颖对。

DASH 碼率切換

切換時(shí)機(jī)

切換代碼在 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;
  // 根據(jù)帶寬選擇一個(gè)合適的 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) {
    // 碼率提升但是當(dāng)前緩存小于 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) {
    // 向低碼率轉(zhuǎn)換缤底,但是當(dāng)前緩存足夠顾患,先不轉(zhuǎn)
    // 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;
}

小結(jié)

  • 向高碼率切換,當(dāng)前緩存需要大于 10s个唧。
  • 向低碼率切換江解,當(dāng)前緩存需要小于 25s。

緩存切換

與 Hls 會(huì)使用多個(gè) DefaultTrackOutput 來緩存數(shù)據(jù)不同徙歼,DASH 僅使用一個(gè)(音頻犁河、視頻、字幕流各有一個(gè)) DefaultTrackOutput 來保存數(shù)據(jù)魄梯。在碼率切換的時(shí)候如果是碼率提升切已緩存數(shù)據(jù)大于 25 s桨螺,那就要丟掉一部分?jǐn)?shù)據(jù)然后加載更高碼率的數(shù)據(jù)。
這個(gè)做法需要三步:

  • 計(jì)算出從哪塊開始加載新的數(shù)據(jù)
  • 這塊之后的緩存數(shù)據(jù)丟棄酿秸。
  • 從這塊開始加載更高碼率的數(shù)據(jù)

代碼中記錄這「塊」的索引的變量是 queueSize灭翔。

1、計(jì)算在上面貼出 FormatEvaluator evaluate 方法中辣苏,這里再貼一遍肝箱。

    // 碼率提升但是當(dāng)前緩存小于 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) {
      // 如果已經(jīng)緩存的數(shù)據(jù)很多,這時(shí)候我們只要保留一部分?jǐn)?shù)據(jù)來滿足當(dāng)前觀看煌张,然后緩存更高質(zhì)量的數(shù)據(jù)
        // minDurationToRetainAfterDiscardUs(25s) 是一個(gè)時(shí)間呐赡,只需要保留這段時(shí)間的數(shù)據(jù),后邊的丟棄
      // 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;
        // 根據(jù) minDurationToRetainAfterDiscardUs 計(jì)算從哪塊開始丟棄
        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骏融、丟數(shù)據(jù)代碼:

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 是當(dāng)前 chunk 緩存的第一個(gè) sample 的索引
  // 從這個(gè)索引往后的數(shù)據(jù)都要丟掉
  sampleQueue.discardUpstreamSamples(removed.getFirstSampleIndex());

  notifyUpstreamDiscarded(startTimeUs, endTimeUs);
  return true;
}

3链嘀、構(gòu)建新的 Chunk,重新加載數(shù)據(jù)绎谦。

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;

如果切向低碼率的流管闷,第二步是不需要的粥脚。

小結(jié)

  • DASH 一個(gè)視頻流的數(shù)據(jù)緩存在一個(gè)窃肠,RollingBuffer 里。
  • 碼率切換就是選擇一個(gè)時(shí)間點(diǎn)刷允,在這個(gè)時(shí)間之后緩存改變碼率后的數(shù)據(jù)冤留。
  • 如果是碼率提升且已緩存數(shù)據(jù)可播放時(shí)間大于 25s,需要把 25s 以后的緩存丟棄树灶,然后緩存更高碼率的數(shù)據(jù)纤怒。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市天通,隨后出現(xiàn)的幾起案子泊窘,更是在濱河造成了極大的恐慌,老刑警劉巖像寒,帶你破解...
    沈念sama閱讀 217,277評論 6 503
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件烘豹,死亡現(xiàn)場離奇詭異,居然都是意外死亡诺祸,警方通過查閱死者的電腦和手機(jī)携悯,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,689評論 3 393
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來筷笨,“玉大人憔鬼,你說我怎么就攤上這事∥赶模” “怎么了轴或?”我有些...
    開封第一講書人閱讀 163,624評論 0 353
  • 文/不壞的土叔 我叫張陵,是天一觀的道長仰禀。 經(jīng)常有香客問我侮叮,道長,這世上最難降的妖魔是什么悼瘾? 我笑而不...
    開封第一講書人閱讀 58,356評論 1 293
  • 正文 為了忘掉前任囊榜,我火速辦了婚禮审胸,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘卸勺。我一直安慰自己砂沛,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,402評論 6 392
  • 文/花漫 我一把揭開白布曙求。 她就那樣靜靜地躺著碍庵,像睡著了一般。 火紅的嫁衣襯著肌膚如雪悟狱。 梳的紋絲不亂的頭發(fā)上静浴,一...
    開封第一講書人閱讀 51,292評論 1 301
  • 那天,我揣著相機(jī)與錄音挤渐,去河邊找鬼苹享。 笑死,一個(gè)胖子當(dāng)著我的面吹牛浴麻,可吹牛的內(nèi)容都是我干的得问。 我是一名探鬼主播,決...
    沈念sama閱讀 40,135評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼软免,長吁一口氣:“原來是場噩夢啊……” “哼宫纬!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起膏萧,我...
    開封第一講書人閱讀 38,992評論 0 275
  • 序言:老撾萬榮一對情侶失蹤漓骚,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后榛泛,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體蝌蹂,經(jīng)...
    沈念sama閱讀 45,429評論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,636評論 3 334
  • 正文 我和宋清朗相戀三年挟鸠,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了叉信。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,785評論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡艘希,死狀恐怖硼身,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情覆享,我是刑警寧澤佳遂,帶...
    沈念sama閱讀 35,492評論 5 345
  • 正文 年R本政府宣布,位于F島的核電站撒顿,受9級(jí)特大地震影響丑罪,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,092評論 3 328
  • 文/蒙蒙 一吩屹、第九天 我趴在偏房一處隱蔽的房頂上張望跪另。 院中可真熱鬧,春花似錦煤搜、人聲如沸免绿。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,723評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽嘲驾。三九已至,卻和暖如春迹卢,著一層夾襖步出監(jiān)牢的瞬間辽故,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,858評論 1 269
  • 我被黑心中介騙來泰國打工腐碱, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留誊垢,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 47,891評論 2 370
  • 正文 我出身青樓喻杈,卻偏偏與公主長得像彤枢,于是被迫代替她去往敵國和親狰晚。 傳聞我的和親對象是個(gè)殘疾皇子筒饰,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,713評論 2 354

推薦閱讀更多精彩內(nèi)容