https://github.com/sigma18/ExoPlayer
https://github.com/sigma18/MediaData
1. player 不关心数据来源
player 完全不想关心数据的来源,不想关心是来自网络还是磁盘
内存中维护一个队列 MemoryCacheManager(每一个元素包装为 BufferItem),player 只管从 MemoryCacheManager 读取数据(从队列中取出一个 BufferItem),当 MemoryCacheManager 中没有数据时,player 读取数据的线程被挂起,当 MemoryCacheManager 有数据时会被唤醒
2. MemoryCacheManager 队列的数据来源
启动一个线程(DataFetcher)循环读取数据,优先从磁盘读取,磁盘没有则从网络读取,每次从网络读到的数据写入磁盘。每次读到的数据包装为 BufferItem 然后存入 MemoryCacheManager
3、用户体验和流量浪费的平衡
给 MemoryCacheManager 设置一个占用内存的上限(mCacheSizeLimit),当 MemoryCacheManager 队列占用的内存达到我们设置的上限时,线程 DataFetcher 被挂起,当 MemoryCacheManager 有数据被取走时会被唤醒
通过调整 mCacheSizeLimit 从而在用户体验和服务器流量、用户手机流量之间找到一个平衡点
4、磁盘缓存碎片
4.1 碎片的产生
当播放一个未缓存过的视频时,假设已从网络读取数据 10000 字节,此时拖动进度条,player 请求数据的 position 发生改变,假设 position = 20000,然后继续播放到结束,假设又从从网络读取数据 5000 字节。那么磁盘应该存在 2 段缓存,第一段是从 0~10000,第二段是从 20000~25000
4.2 碎片记录
为记录这些磁盘缓存碎片,设计了2个数据库表:主表 Video 和 子表 VideoPart
Video 主要包含:id, url, size, cache_dir, last_use_time
VideoPart 主要包含:id, video_id, start, end, cache_name
cache_dir 对应磁盘上为此 video 创建的文件夹名字,cache_name 对应缓存碎片的文件名
4.3 碎片合并
假设当前待写入的碎片是 videoPart,待写入的数据长度为 writeLength,每次要写入的时候,查询数据库找出存在叠加区域的碎片 nextPart(如果有的话)
long end = videoPart.getEnd() + writeLength;
if (nextPart.getStart() <= end && end < nextPart.getEnd())
如果找到 nextPart,算出叠加的区域
int redundantLength = (int) (end - nextPart.getStart());
然后写入有用的数据再合并 2 个文件
writeLength = writeLength - redundantLength;
FileUtils.write(accessFile, buffer, position, writeLength);
FileUtils.merge(accessFile, nextFile);
5. 无缝衔接从网络和磁盘读取数据
- 判断磁盘上是否有包含 position 的碎片,如果没有则进入 3,有则进入 2
- 从磁盘读取,直到这一段缓存到结尾,position += x(假设这一段缓存有 x 字节)
- 建立网络连接,设置 RANGE bytes=position
- 从网络读取(假设请求到 y 字节),position += y
- 判断磁盘上是否有包含 position 的碎片,没有则进入 4,有则进入 2
6. 移动网络
每次从网络读取时,如果当前是移动网络
如果是不被允许的,则会发出通知并挂起线程(DataFetcher),也就停止读数据
7. 脏数据
在一些特殊情况下,可能会产生无效的缓存,比如
- 在播放过程中,进程被杀死
- 用户删掉 SD 卡的缓存文件
脏数据包括
- 无效的数据库记录,因为对应的文件不存在,或者 end - start ≠ file.length()
- 无效的缓存文件,因为没有数据库记录使用它
所以在每次应用启动的时候,对所有 Video 记录进行校验,删掉脏数据
在每次新开一个 Video 读写磁盘缓存时,再对这个 Video 记录进行一次校验
[DiskCacheManager.java]
public synchronized void open(String url) {
...
mVideo = mDbHelper.queryVideoByUrl(mUrl);
if (mVideo != null) {
...
checkCache(mVideo);
}
}
8. 磁盘缓存 LRU
每次写入磁盘后都要判断缓存文件的大小总和是否超出上限,如果超出则删掉 last_use_time 最小的那个 Video 记录和文件,但是至少保留当前正在使用的这个 Video
[DiskCacheManager.java]
public synchronized void open(String url) {
....
mVideo = mDbHelper.queryVideoByUrl(mUrl);
if (mVideo != null) {
....
mVideo.setLastUseTime(System.currentTimeMillis());
mDbHelper.update(mVideo);
checkCache(mVideo);
}
}
private void trimToSize(VideoPart videoPart) {
while (mCacheSize > MAX_CACHE_SIZE) {
Video video = mDbHelper.queryEldestVideo();
if (video.getId() == videoPart.getVideoId()) {
return;
}
File videoDir = new File(VideoDataSource.getInstance().getCacheDir(), video.getCacheDir());
if (videoDir.exists()) {
File[] videoPartFiles = videoDir.listFiles();
if (videoPartFiles != null) {
for (File videoPartFile : videoPartFiles) {
mCacheSize -= videoPartFile.length();
videoPartFile.delete();
}
}
videoDir.delete();
}
mDbHelper.deleteVideo(video.getId());
}
}
9. 数据库快照
很显然在这个应用场景里,数据库增删改的次数远小于查询,所以没必要每次都去查询数据库,只需要查询一次并放在内存即可。但要确保两者的一致性。
[DiskCacheManager.java]
private List<VideoPart> mVideoPartList;
每次读写大概率是使用上一次的 VideoPart,不必每次都遍历 mVideoPartList 进行查找
[DiskCacheManager.java]
private VideoPart mReadVideoPart;
private RandomAccessFile mReadFile;
private VideoPart mWriteVideoPart;
private RandomAccessFile mWriteFile;
网友评论