目錄
- 什麼是緩存分片
- 爲什麼要緩存分片
- 如何實現
- 資料
- 收穫
一、什麼是緩存分片
我們在上一篇介紹AndroidVideoCache時,知道它會一直下載數據直到完全下載。這會帶來流量的浪費。比如一個5MB的視頻,碼率是2Mb/s,共有5Mx8/2=20秒。如果帶寬是5MB/s,一個5M的視頻1秒鐘就下載完了,但是用戶也許只看到了2秒鐘因爲不感興趣划走了,這樣就造成了兩個弊端 流量的浪費和LRU緩存策略的漏洞。
這個問題我們可以通過限速以及緩存LRU策略的調整來進行優化。
同時還存在另外一個問題, 如果採用斷點續傳的方案設置每次請求的range,如果AndroidVideoCache在拖動超過當前當前緩存的位置加上總長度的20%就不緩存了。
request.rangeOffset <= cacheAvailable + sourceLength * 0.2f
我們畫圖來分析下這個邏輯,看下如果緩存會存在什麼問題。
爲什麼要這樣設計吶?如果想要在超過該區域後想要能夠繼續緩存該怎麼辦吶?
我們來思考下seek後改如何進行數據的獲取。
有如下三種方案:
1.繼續沿着cached_position順序緩存下去
2.只要當前拖動的進度條超過cached_position,那就不繼續緩存,後續的數據完全是網絡請求。
3.拖動的進度條即使超過了cached_position, 從新的位置開始發起Range 請求
三種方式的優劣比較一下:
方案一:繼續沿着cached_position順序緩存下去,我拖動進度條的位置和cached_position相隔很> 遠,如果採用這種方案拖動進度條之後播放會很慢,所以方案一被斃掉了。
方案二:方案二的做法也是可以的,拖動進度條之後也不會卡,但是也有問題,就是無法做法真正的邊下邊播,只能順序下載。目前網絡上熱門的開源項目https://github.com/danikula/AndroidVideoCache 就是採用的這種方案
方案三:解決拖動之後卡頓,也解決了只能順序下載的問題。目前是最優的解決方案
引用自: 頭條都在用的邊下邊播方案
能夠想到有如下兩種方式:
- 物理文件空洞的方式,進行緩存分片,無數據的部分被填充爲0,有數據的部分記錄start和end點 填充數據。—》這個方案會佔用更多的空間(不和系統對文件的空洞方案不同)和內存;該方案要維護一個緩存分片信息文件,用於記錄緩存的分片的start和end信息。
- 邏輯文件空洞的方式,進行緩存分片,把緩存文件分片成N個文件,如果某些文件沒有數據就不創建,有數據的記錄開始和結束點,如果相鄰的兩個文件start和end能夠對接上,進行merge合併。該方案也可以採用緩存分片信息文件的方案,但是也可以直接從文件夾和文件的命名上進行區分。
二、爲什麼要緩存分片
通過上面一小節我們瞭解了AndroidVideoCache在Seek後不緩存的場景和原因,以及緩存分片的概念。這一小節我們來分析下爲什麼要用緩存分片
緩存分片有如下好處:
-
把大的文件拆分成小的文件進行單獨緩存,這樣帶來的好處是存儲空間按需分配
圖片來自:十億級視頻播放技術優化揭祕王輝終稿2.key
- 爲後面的seek緩存的實現奠定了基礎,
- 可以提升緩存的命中率
- 降低由於seek過多餘部分數據造成播放延遲
- 如果使用P2P策略節省了流量,每個小的分片可以作爲一個單獨的種源,提升P2P命中率
三、如何實現
要實現緩存分片,主要要解決如下兩個問題
- 緩存分片文件的存儲和合並等管理
- 緩存分片文件信息的管理
下面我們來分析下一個實現緩存分片的開源項目 JeffVideoCache
這個開源項目不僅實現了MP4的緩存分片,還增加了對m3u8的支持,在架構設計上相比較AndroidVideoCache也有很大的改變。
其中MP4的緩存採用了物理文件空洞的方式;而M3U8採用的是邏輯文件空洞的方式。
定一個VideoRange 數據結構, 用於記錄分片的位置信息
public class VideoRange {
private long mStart; //分片的起始位置
private long mEnd; //分片的結束位置
}
LinkedHashMap<Long, VideoRange> mVideoRangeMap; //已經緩存的video range結構,維護了一個VideoRange列表,key是VideoRange的開始位置,value是VideoRange的對象。
如果兩個VideoRange之間有部分重合,通過merge合成一個新的VideoRange。
這一篇我們來分析該開源項目針對MP4的物理文件空洞緩存分片的方案,下一篇我們再分析針對M3U8邏輯文件空洞緩存分片的方案。
下面我們從代碼看下主流程
3.1 LocalProxyVideoControl#startRequestVideoInfo
添加緩存listener,有開始緩存、緩存進度更新、緩存失敗、緩存成功的回調,觸發緩存分片信息, 接下來去獲取緩存分片信息文件,緩存分片信息中記錄了改了文件的每個分片的start和end信息。
//LocalProxyVideoControl#startRequestVideoInfo
public void startRequestVideoInfo(String videoUrl, Map<String, String> headers, Map<String, Object> extraParams) {
//待請求的url
mVideoUrl = videoUrl;
//添加緩存listener,有開始緩存、緩存進度更新、緩存失敗、緩存成功的回調
VideoProxyCacheManager.getInstance().addCacheListener(videoUrl, mListener);
VideoProxyCacheManager.getInstance().setPlayingUrlMd5(ProxyCacheUtils.computeMD5(videoUrl));
//重點分析startRequestVideoInfo
VideoProxyCacheManager.getInstance().startRequestVideoInfo(videoUrl, headers, extraParams);
}
public void startRequestVideoInfo(String videoUrl, Map<String, String> headers, Map<String, Object> extraParams) {
...
//拿到緩存分片信息後,開始觸發ranged邏輯
startNonM3U8Task(videoCacheInfo, headers);
...
}
3.2 startNonM3U8Task: 開始緩存MP4分片任務
//VideoProxyCacheManager#startNonM3U8Task
private void startNonM3U8Task(VideoCacheInfo cacheInfo, Map<String, String> headers) {
VideoCacheTask cacheTask = mCacheTaskMap.get(cacheInfo.getVideoUrl());
if (cacheTask == null) {
//創建mp4緩存任務
cacheTask = new Mp4CacheTask(cacheInfo, headers);
//加入到map中,
mCacheTaskMap.put(cacheInfo.getVideoUrl(), cacheTask);
}
startVideoCacheTask(cacheTask, cacheInfo);
}
private void startVideoCacheTask(VideoCacheTask cacheTask, VideoCacheInfo cacheInfo) {
...
//開始緩存任務
cacheTask.startCacheTask();
...
}
3.3 開啓線程進行緩存
//Mp4CacheTask#startCacheTask
public void startCacheTask() {
//如果文件緩存完(整個文件,而不是單個緩存分片文件),直接通知完成
if (mCacheInfo.isCompleted()) {
notifyOnTaskCompleted();
return;
}
notifyOnTaskStart();
LogUtils.i(TAG, "startCacheTask");
//獲取緩存分片的對象(start 、end)
VideoRange requestRange = getRequestRange(0L);
//啓動線程(線程池方式)進行緩存(下載)
startVideoCacheThread(requestRange);
}
private void startVideoCacheThread(VideoRange requestRange) {
mRequestRange = requestRange;
//saveDir 是videocacheinfo存儲的目錄
mVideoCacheThread = new Mp4VideoCacheThread(mVideoUrl, mHeaders, requestRange, mTotalSize, mSaveDir.getAbsolutePath(), mCacheThreadListener);
//通過線程池來執行
VideoProxyThreadUtils.submitRunnableTask(mVideoCacheThread);
}
3.4 下面我們看下Mp4VideoCacheThread的實現
public class Mp4VideoCacheThread implements Runnable {
...
private VideoRange mRequestRange;//當前請求的video range
private boolean mIsRunning = true; //是否增長運行,該任務可以pause
private String mMd5; //緩存文件的md5
...
public void run() {
//該緩存任務可以pause,如果沒有在running直接返回
if (!mIsRunning) {
return;
}
//支持OKHttp和HttpUrlConnection兩種方式進行網絡請求
if (ProxyCacheUtils.getConfig().useOkHttp()) {
downloadVideoByOkHttp();
} else {
//使用HttpUrlConnection
downloadVideo();
}
}
}
3.5 我們來分析HttpUrlConnection的方式進行網絡請求
可以看到這裏採用了物理文件空洞的方案,有數據的進行填充。至於緩存緩存信息文件(記錄所有的start和end信息)在notifyOnCacheRangeCompleted等中進行更新
/**
* 通過HttpUrlConnection下載緩存片段
*/
private void downloadVideo() {
File videoFile;
try {
//mSaveDir是存儲緩存片段的文件夾,該文件夾下有videocacheinfo和各個緩存片段;
videoFile = new File(mSaveDir, mMd5 + StorageUtils.NON_M3U8_SUFFIX);
if (!videoFile.exists()) {
videoFile.createNewFile();
}
} catch (Exception e) {
notifyOnCacheFailed(new VideoCacheException("Cannot create video file, exception="+e));
return;
}
long requestStart = mRequestRange.getStart();
long requestEnd = mRequestRange.getEnd();
mHeaders.put("Range", "bytes=" + requestStart + "-" + requestEnd);
HttpURLConnection connection = null;
InputStream inputStream = null;
RandomAccessFile randomAccessFile = null;
try {
//這裏採用了物理文件空洞的方案。有數據的進行填充,並通過緩存信息文件記錄所有的start和end信息
randomAccessFile = new RandomAccessFile(videoFile.getAbsolutePath(), "rw");
randomAccessFile.seek(requestStart);
//這裏爲什麼要把requestStart賦值給cachedSize??這裏的命名不好改爲cachedOffset更合適
long cachedOffset = requestStart;
LogUtils.i(TAG, "Start request : " + mRequestRange + ", CurrentCachedSize="+cachedOffset);
connection = HttpUtils.getConnection(mVideoUrl, mHeaders);
inputStream = connection.getInputStream();
LogUtils.i(TAG, "Receive response");
byte[] buffer = new byte[StorageUtils.DEFAULT_BUFFER_SIZE];
int readLength;
while(mIsRunning && (readLength = inputStream.read(buffer)) != -1) {
if (cachedOffset >= requestEnd) {
cachedOffset = requestEnd;
}
if (cachedOffset + readLength > requestEnd) {
long read = requestEnd - cachedOffset;
randomAccessFile.write(buffer, 0, (int)read);
cachedOffset = requestEnd;
} else {
randomAccessFile.write(buffer, 0, readLength);
cachedOffset += readLength;
}
//更新緩存進度
notifyOnCacheProgress(cachedOffset);
if (cachedOffset >= requestEnd) {
//緩存好了一段,通知回調
notifyOnCacheRangeCompleted();
}
}
mIsRunning = false;
} catch (Exception e) {
notifyOnCacheFailed(e);
} finally {
mIsRunning = false;
ProxyCacheUtils.close(inputStream);
ProxyCacheUtils.close(randomAccessFile);
HttpUtils.closeConnection(connection);
}
}
3.6 通知更新緩存分片信息
//Mp4CacheTask#notifyOnCacheRangeCompleted
/**
* @param startPosition :上一個緩存分片的 end
*/
private void notifyOnCacheRangeCompleted(long startPosition) {
//這時候已經緩存好了一段分片,可以更新一下video range數據結構了
updateVideoRangeInfo();
if (mCacheInfo.isCompleted()) {
notifyOnTaskCompleted();
} else {
if (startPosition == mTotalSize) {
//說明已經緩存好,但是整視頻中間還有一些洞,但是不影響,可以忽略
} else {
//開啓下一段視頻分片的緩存
VideoRange requestRange = getRequestRange(startPosition);
//是否開啓下一緩存分片的下載。
// 這裏可以再精準的控制下,按需下載
startVideoCacheThread(requestRange);
}
}
}
3.7 更新緩存分片信息
這個方法比較關鍵,針對緩存分片信息進行整合,重疊的部分進行合併,重新生成videoRange列表。更新後把其更新到文件中
//Mp4CacheTask#updateVideoRangeInfo
private synchronized void updateVideoRangeInfo() {
if (mVideoRangeMap.size() > 0) {
long finalStart = -1;
long finalEnd = -1;
long requestStart = mRequestRange.getStart();
long requestEnd = mRequestRange.getEnd();
for(Map.Entry<Long, VideoRange> entry : mVideoRangeMap.entrySet()) {
VideoRange videoRange = entry.getValue();
long startResult = VideoRangeUtils.determineVideoRangeByPosition(videoRange, requestStart);
long endResult = VideoRangeUtils.determineVideoRangeByPosition(videoRange, requestEnd);
if (finalStart == -1) {
if (startResult == 1) {
//如果requestStart小於遍歷的一個片段的start位置,取requestStart
finalStart = requestStart;
} else if (startResult == 2) {
//如果requestStart在遍歷的一個片段的start和end中,取該片段的start
finalStart = videoRange.getStart();
} else {
//如果超出繼續遍歷其他片段,進行對比
//先別急着賦值,還要看下一個videoRange
}
}
if (finalEnd == -1) {
if (endResult == 1) {
finalEnd = requestEnd;
} else if (endResult == 2) {
finalEnd = videoRange.getEnd();
} else {
//先別急着賦值,還要看下一個videoRange
}
}
//該循環的目的是確定finalStart和finalEnd,用於確定VideoRange
if (finalStart != -1 && finalEnd != -1) {
break;
}
}
if (finalStart == -1) {
finalStart = requestStart;
}
if (finalEnd == -1) {
finalEnd = requestEnd;
}
VideoRange finalVideoRange = new VideoRange(finalStart, finalEnd);
LogUtils.i(TAG, "updateVideoRangeInfo--->finalVideoRange: " + finalVideoRange);
LinkedHashMap<Long, VideoRange> tempVideoRangeMap = new LinkedHashMap<>();
for(Map.Entry<Long, VideoRange> entry : mVideoRangeMap.entrySet()) {
VideoRange videoRange = entry.getValue();
if (VideoRangeUtils.containsVideoRange(finalVideoRange, videoRange)) {
//如果finalVideoRange包含videoRange
tempVideoRangeMap.put(finalVideoRange.getStart(), finalVideoRange);
} else if (VideoRangeUtils.compareVideoRange(finalVideoRange, videoRange) == 1) {
//如果兩個沒有交集,且finalVideoRange的end 小於videoRange的start,則map先加入finalVideoRange再加入videoRange
tempVideoRangeMap.put(finalVideoRange.getStart(), finalVideoRange);
tempVideoRangeMap.put(videoRange.getStart(), videoRange);
} else if (VideoRangeUtils.compareVideoRange(finalVideoRange, videoRange) == 2) {
//如果兩個沒有交集,且finalVideoRange的start 大於videoRange的end,則map先加入videoRange再加入finalVideoRange
tempVideoRangeMap.put(videoRange.getStart(), videoRange);
tempVideoRangeMap.put(finalVideoRange.getStart(), finalVideoRange);
}
}
mVideoRangeMap.clear();
mVideoRangeMap.putAll(tempVideoRangeMap);
} else {
LogUtils.i(TAG, "updateVideoRangeInfo--->mRequestRange : " + mRequestRange);
mVideoRangeMap.put(mRequestRange.getStart(), mRequestRange);
}
LinkedHashMap<Long, Long> tempSegMap = new LinkedHashMap<>();
//進行了merge?
for(Map.Entry<Long, VideoRange> entry : mVideoRangeMap.entrySet()) {
VideoRange videoRange = entry.getValue();
LogUtils.i(TAG, "updateVideoRangeInfo--->Result videoRange : " + videoRange);
tempSegMap.put(videoRange.getStart(), videoRange.getEnd());
}
//最小化鎖的作用範圍
synchronized (mSegMapLock) {
mVideoSegMap.clear();
mVideoSegMap.putAll(tempSegMap);
}
mCacheInfo.setVideoSegMap(mVideoSegMap);
// 當mVideoRangeMap只有一個片段,並且該ranged是完整的這個那個緩存文件(不是某個子片段),則標記爲completed
if (mVideoRangeMap.size() == 1) {
VideoRange videoRange = mVideoRangeMap.get(0L);
LogUtils.i(TAG, "updateVideoRangeInfo---> videoRange : " + videoRange);
if (videoRange != null && videoRange.equals(new VideoRange(0, mTotalSize))) {
LogUtils.i(TAG, "updateVideoRangeInfo--->Set completed");
mCacheInfo.setIsCompleted(true);
}
}
//子線程中,更新緩存信息文件
saveVideoInfo();
}
public static void saveVideoCacheInfo(VideoCacheInfo info, File dir) {
File file = new File(dir, INFO_FILE);
ObjectOutputStream fos = null;
try {
synchronized (sInfoFileLock) {
fos = new ObjectOutputStream(new FileOutputStream(file));
fos.writeObject(info);
}
} catch (Exception e) {
} finally {
ProxyCacheUtils.close(fos);
}
}
緩存分片物理文件空洞的方案分析分析到這裏基本上就結束了,感謝JeffVideoCache作者的開源,
下一篇我們再來分析緩存分片採用的邏輯文件空洞的方案,歡迎交流
四、資料
五、收穫
從本篇的學習分析
- 瞭解緩存分片的是什麼,爲什麼,以及如何實現
- 分析了緩存分片物理文件空洞方案的實現。
感謝你的閱讀
下一篇我們我們來分析緩存分片邏輯文件空洞方案的實現,歡迎關注公衆號“音視頻開發之旅”,一起學習成長。
歡迎交流