基于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获取当前分段对应播放列表的结束时间点
}
}
}
}
好了缓存模块就分析到这里,默认的缓存还不够强大,大家可以思考下如何实现预加载更多的缓存甚至实现边下边播