ExoPlayer 源码阅读小记--缓存模块及获取HLS已缓存大小

基于ExoPlayer 2.17.1源码分析,带着问题看代码,主要解决以下几点问题:

  • 缓存是如何存取管理的
  • 如何获取HLS的已缓存大小

首先回顾下上一篇文章《ExoPlayer 源码阅读小记–HLS播放带缓存加载M38U文件过程》里第一次涉及到缓存的地方:
调用StatsDataSource封装的CacheDataSource,这里会调用TeeDataSource 的open,Tees翻译就是三通的意思,这是个一进二出的三通,一个水龙头冷水进,一路进小厨宝缓存加热后出,一路直接水龙出

//TeeDataSource
public long open(DataSpec dataSpec) throws IOException {
    bytesRemaining = upstream.open(dataSpec);//调用OkHttpDataSource open打开冷水进水阀门,准备一路上出水到水龙头,一路到小厨宝缓存
    if (bytesRemaining == 0) {
      return 0;
    }
    if (dataSpec.length == C.LENGTH_UNSET && bytesRemaining != C.LENGTH_UNSET) {
      // Reconstruct dataSpec in order to provide the resolved length to the sink.
      dataSpec = dataSpec.subrange(0, bytesRemaining);
    }
    dataSinkNeedsClosing = true;
    dataSink.open(dataSpec);//dataSink由CacheDataSource设置,调用CacheDataSink open打开厨宝的进水阀,准备下出水到小厨宝缓存
    return bytesRemaining;
  }

open的流也没有立即读取而是缓存在DataSource里,当调用TeeDataSource read方法时才会写入缓存,这里可以看出缓存是播多少缓存多少,并不会提前预缓存,只有在读取到这个hls分段的时候才会去加载缓存,exoplayer缓存的目的也只是为了下次再加载时可以直接从缓存中获取数据

//TeeDataSource
  public int read(byte[] buffer, int offset, int length) throws IOException {
    if (bytesRemaining == 0) {
      return C.RESULT_END_OF_INPUT;
    }
    int bytesRead = upstream.read(buffer, offset, length);
    if (bytesRead > 0) {
      // TODO: Consider continuing even if writes to the sink fail.这里还有个TODO作者想要缓存写入失败的时候也不要影响读取
      dataSink.write(buffer, offset, bytesRead);
      if (bytesRemaining != C.LENGTH_UNSET) {
        bytesRemaining -= bytesRead;
      }
    }
    return bytesRead;
  }

TeeDataSource open是在CacheDataSource open函数中调用的,来看下CacheDataSource open函数,这里主要关注openNextSource这个函数,这里包含了缓存的整个初始化过程,详细看下面注释

//CacheDataSource 
public long open(DataSpec dataSpec) throws IOException {
 ....
      if (bytesRemaining > 0 || bytesRemaining == C.LENGTH_UNSET) {
        openNextSource(requestDataSpec, false);
      }
      return dataSpec.length != C.LENGTH_UNSET ? dataSpec.length : bytesRemaining;
  ....
  }

private void openNextSource(DataSpec requestDataSpec, boolean checkCache) throws IOException {
    @Nullable CacheSpan nextSpan;
....
//这里如果是首次会将当前url加入到索引,但只是缓存在内存中,并未持久化到文件,如果非首次会直接读取之前的索引返回Span,
//索引文件包含唯一id和文件的url,当初始化的时候会线读取索引文件中所有的id和url
//然后扫描缓存目录将通过文件名中的id将索引和对应的文件建立起映射关系,进而可以通过索引文件查询到所有的缓存文件
      nextSpan = cache.startReadWriteNonBlocking(key, readPosition, bytesRemaining);
    }

    DataSpec nextDataSpec;
    DataSource nextDataSource;
    if (nextSpan == null) {
      // The data is locked in the cache, or we're ignoring the cache. Bypass the cache and read
      // from upstream.
      nextDataSource = upstreamDataSource;
      nextDataSpec =
          requestDataSpec.buildUpon().setPosition(readPosition).setLength(bytesRemaining).build();
    } else if (nextSpan.isCached) {//如果获取到的Span是已有的已经缓存的走这里
      // Data is cached in a span file starting at nextSpan.position.
      Uri fileUri = Uri.fromFile(castNonNull(nextSpan.file));
      long filePositionOffset = nextSpan.position;
      long positionInFile = readPosition - filePositionOffset;
      long length = nextSpan.length - positionInFile;
      if (bytesRemaining != C.LENGTH_UNSET) {
        length = min(length, bytesRemaining);
      }
      nextDataSpec =
          requestDataSpec
              .buildUpon()
              .setUri(fileUri)
              .setUriPositionOffset(filePositionOffset)
              .setPosition(positionInFile)
              .setLength(length)
              .build();//创建供缓存文件读取Datasource的Spec
              //这里使用DefaultDataSource,它会根据文件的SCHEME使用合适的DataSoucrce打开读取文件,
              //如果是本地缓存文件会使用FileDataSource来读取缓存
      nextDataSource = cacheReadDataSource;
    } else {//首次的情况为被缓存会走这里
      // Data is not cached, and data is not locked, read from upstream with cache backing.
      long length;
      if (nextSpan.isOpenEnded()) {
        length = bytesRemaining;
      } else {
        length = nextSpan.length;
        if (bytesRemaining != C.LENGTH_UNSET) {
          length = min(length, bytesRemaining);
        }
      }
      nextDataSpec =
          requestDataSpec.buildUpon().setPosition(readPosition).setLength(length).build();
      if (cacheWriteDataSource != null) {
        nextDataSource = cacheWriteDataSource;//这里设置使用TeeDataSource获取数据
....
    currentDataSource = nextDataSource;
    currentDataSpec = nextDataSpec;
    currentDataSourceBytesRead = 0;
    long resolvedLength = nextDataSource.open(nextDataSpec);//调用事先设定的DataSource去open资源
....
  }

当第一次访问没有被缓存的资源时候会调用TeeDataSource的open,最终会调用dataSink.open(dataSpec)我看看下做了什么

//CacheDataSink
public void open(DataSpec dataSpec) throws CacheDataSinkException {
 ....
    try {
      openNextOutputStream(dataSpec);
    } catch (IOException e) {
      throw new CacheDataSinkException(e);
    }
  }

private void openNextOutputStream(DataSpec dataSpec) throws IOException {
   ....
   //这里主要就是创建缓存的文件并打开文件输入流,这个文件对缓存目录下的一个随机数字文件夹下的一个.exo文件里
    file =
        cache.startFile(
            castNonNull(dataSpec.key), dataSpec.position + dataSpecBytesWritten, length);
    FileOutputStream underlyingFileOutputStream = new FileOutputStream(file);
    if (bufferSize > 0) {
      if (bufferedOutputStream == null) {
        bufferedOutputStream =
            new ReusableBufferedOutputStream(underlyingFileOutputStream, bufferSize);
      } else {
        bufferedOutputStream.reset(underlyingFileOutputStream);
      }
      outputStream = bufferedOutputStream;
    } else {
      outputStream = underlyingFileOutputStream;
    }
    outputStreamBytesWritten = 0;
  }
//SimpleCache
  public synchronized File startFile(String key, long position, long length) throws CacheException {
....
 //contentIndex是一个所有已通过url查询索引,在open前会
 //调用SimpleCache的startReadWriteNonBlocking方法将当前url添加到索引中同时生成一个唯一ID供下面使用,但只是缓存在内存中,并未持久化到文件
    CachedContent cachedContent = contentIndex.get(key);
   
....
    if (!cacheDir.exists()) {//检测缓存目录是否存在
      // The cache directory has been deleted from underneath us. Recreate it, and remove in-memory
      // spans corresponding to cache files that no longer exist.
      createCacheDirectories(cacheDir);
      removeStaleSpans();
    }
    evictor.onStartFile(this, key, position, length);
    // Randomly distribute files into subdirectories with a uniform distribution.
    File cacheSubDir = new File(cacheDir, Integer.toString(random.nextInt(SUBDIRECTORY_COUNT)));//创建随机数字文件夹
    if (!cacheSubDir.exists()) {
      createCacheDirectories(cacheSubDir);
    }
    long lastTouchTimestamp = System.currentTimeMillis();
    return SimpleCacheSpan.getCacheFile(
        cacheSubDir, cachedContent.id, position, lastTouchTimestamp);//构建文件名
  }
//SimpleCacheSpan
  public static File getCacheFile(File cacheDir, int id, long position, long timestamp) {
  //文件名规则是索引的ID+资源读取位置+当前时间戳
    return new File(cacheDir, id + "." + position + "." + timestamp + SUFFIX);
  }

到这里/sdcard/Android/data/com.xxxx.xxx/cache/exo/0/0.0.1653091779124.v3.exo(各个项目会不同,此处为举例)文件已经创建流已打开,下面来看读数据同时写入缓存的逻辑还是从CacheDataSource看起

//CacheDataSource
  public int read(byte[] buffer, int offset, int length) throws IOException {
    if (length == 0) {
      return 0;
    }
    if (bytesRemaining == 0) {
      return C.RESULT_END_OF_INPUT;
    }
    DataSpec requestDataSpec = checkNotNull(this.requestDataSpec);
    DataSpec currentDataSpec = checkNotNull(this.currentDataSpec);
    try {
      if (readPosition >= checkCachePosition) {
        openNextSource(requestDataSpec, true);
      }
      //currentDataSource在上面open时设置和上面一样如果是首次会使用TeeDataSource,已缓存会使用FileDataSource
      int bytesRead = checkNotNull(currentDataSource).read(buffer, offset, length);
      ....
      return bytesRead;
    } catch (Throwable e) {
      handleBeforeThrow(e);
      throw e;
    }
  }

如果是首次的情况会使用TeeDataSource获取OkhttpDataSource里的网络数据,同时通过dataSink.write(buffer, offset, bytesRead);写入缓存,代码一开始已经贴过,这里直接进入到dataSink.write

//CacheDataSink
public void write(byte[] buffer, int offset, int length) throws CacheDataSinkException {
    @Nullable DataSpec dataSpec = this.dataSpec;
    if (dataSpec == null) {
      return;
    }
    try {
      int bytesWritten = 0;
      while (bytesWritten < length) {
        if (outputStreamBytesWritten == dataSpecFragmentSize) {
          closeCurrentOutputStream();
          openNextOutputStream(dataSpec);
        }
        int bytesToWrite =
            (int) min(length - bytesWritten, dataSpecFragmentSize - outputStreamBytesWritten);
            //这里的outputStream就是上面open时打开的文件流
        castNonNull(outputStream).write(buffer, offset + bytesWritten, bytesToWrite);
        bytesWritten += bytesToWrite;
        outputStreamBytesWritten += bytesToWrite;
        dataSpecBytesWritten += bytesToWrite;
      }
    } catch (IOException e) {
      throw new CacheDataSinkException(e);
    }
  }

写入完成后会关闭当前流开始请求下一个片段,关闭流的时候会同时将内存中的索引同步到本地索引文件中,位置在/sdcard/Android/data/com.xxxx.xxx/cache/exo/cached_content_index.exi,这个索引文件里包含当前url的索引和ID,通过缓存文件名中的ID建立起关联

通过上面的分析我们知道一个ts文件缓存时会在cached_content_index.exi中建立索引,通过ID关联到缓存的文件,当获取数据时会查询索引文件进而获取到对应的缓存文件读取,一段缓存在EXO里对应一个Span,一个ts文件可能有多个Span缓存,好了第一个问题已经解决,下面学以致用来解决第二个问题

如何如何获取HLS的已缓存大小?

由这篇文章知道,一个m3u8文件每个分段都有各自的url,这些分段在缓存后会将url和生成的唯一ID记录到索引文件中,而缓存文件通过ID和索引建立联系,文件名中除了ID+postion+时间戳,就没有其他信息了,索引文件也只记录了ID和分段的URL,所以各个分段之间是独立的,并不知道他们属于哪个m3u8,所以要回答上面的问题需要先获取m3u8和各分段的关系,也就是获取到解析后当前播放的m3u8列表(EXO叫snapshots快照),通过上一篇文章可知HlsPlaylistTracker就是管理列表的类,好了问题变为如何获取到HlsPlaylistTracker?

可以在设置HlsMediaSource时通过setPlaylistTrackerFactory时缓存当前的HlsPlaylistTracker:

mediaSource = new HlsMediaSource.Factory(getDataSourceFactoryCache(mAppContext, cacheEnable, preview, cacheDir, uerAgent)).setPlaylistTrackerFactory(new HlsPlaylistTracker.Factory() {
                        @Override
                        public HlsPlaylistTracker createTracker(final HlsDataSourceFactory hlsDataSourceFactory,
                            final LoadErrorHandlingPolicy loadErrorHandlingPolicy, final HlsPlaylistParserFactory playlistParserFactory) {

                            currentHlsTracker = new DefaultHlsPlaylistTracker(hlsDataSourceFactory,
                                loadErrorHandlingPolicy, playlistParserFactory);
                            return currentHlsTracker;
                        }
                    })
                    .createMediaSource(mediaItem);

下面就可以利用这些来查询缓存了,这里列下大致代码:


//使用上面缓存的currentHlsTracker获取所有的播放列表
        final HlsMultivariantPlaylist multivariantPlaylist = currentHlsTracker.getMultivariantPlaylist();
        //获取第一个m3u8的url地址
        final Uri url = multivariantPlaylist.variants.get(0).url;
        //获取当前播放的列表快照
        final HlsMediaPlaylist playlistSnapshot = currentHlsTracker.getPlaylistSnapshot(multivariantPlaylist.variants.get(0).url, false);
        //获取到所有的分段开始循环
        for (final Segment segment : playlistSnapshot.segments) {
           //cacheSingleInstance为创建CacheDataSource时setCache使用的SimpleCache单例
           //buildCacheKey通过CacheKeyFactory.DEFAULT.buildCacheKey获取,其实就是当前分段的url全路径
                NavigableSet<CacheSpan> cachedSpans = cacheSingleInstance.getCachedSpans(buildCacheKey);
                //获取到当前分段url对应的所有缓存Span,通过isCached判断是否已缓存
                if (cachedSpans.size() != 0) {
                    for (CacheSpan cachedSpan : cachedSpans) {
                        if (cachedSpan.isCached) {
                          ....
                             //判断完成后可以通过
                             //segment.relativeStartTimeUs获取当前分段对应播放列表的开始时间点
                             //segment.relativeStartTimeUs + segment.durationUs获取当前分段对应播放列表的结束时间点
                        }
                    }
                }
             
        }

好了缓存模块就分析到这里,默认的缓存还不够强大,大家可以思考下如何实现预加载更多的缓存甚至实现边下边播

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值