美文网首页
音视频开发之旅(50)-边缓存边播放之缓存分片(1)

音视频开发之旅(50)-边缓存边播放之缓存分片(1)

作者: yabin小站 | 来源:发表于2021-08-09 08:53 被阅读0次

    目录

    1. 什么是缓存分片
    2. 为什么要缓存分片
    3. 如何实现
    4. 资料
    5. 收获

    一、什么是缓存分片

    我们在上一篇介绍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 就是采用的这种方案
    方案三:解决拖动之后卡顿,也解决了只能顺序下载的问题。目前是最优的解决方案
    引用自: 头条都在用的边下边播方案

    能够想到有如下两种方式:

    1. 物理文件空洞的方式,进行缓存分片,无数据的部分被填充为0,有数据的部分记录start和end点 填充数据。—》这个方案会占用更多的空间(不和系统对文件的空洞方案不同)和内存;该方案要维护一个缓存分片信息文件,用于记录缓存的分片的start和end信息。
    2. 逻辑文件空洞的方式,进行缓存分片,把缓存文件分片成N个文件,如果某些文件没有数据就不创建,有数据的记录开始和结束点,如果相邻的两个文件start和end能够对接上,进行merge合并。该方案也可以采用缓存分片信息文件的方案,但是也可以直接从文件夹和文件的命名上进行区分。

    二、为什么要缓存分片

    通过上面一小节我们了解了AndroidVideoCache在Seek后不缓存的场景和原因,以及缓存分片的概念。这一小节我们来分析下为什么要用缓存分片

    缓存分片有如下好处:

    1. 把大的文件拆分成小的文件进行单独缓存,这样带来的好处是存储空间按需分配


    图片来自:十亿级视频播放技术优化揭秘王辉终稿2.key

    1. 为后面的seek缓存的实现奠定了基础,
    2. 可以提升缓存的命中率
    3. 降低由于seek过多余部分数据造成播放延迟
    4. 如果使用P2P策略节省了流量,每个小的分片可以作为一个单独的种源,提升P2P命中率

    三、如何实现

    要实现缓存分片,主要要解决如下两个问题

    1. 缓存分片文件的存储和合并等管理
    2. 缓存分片文件信息的管理

    下面我们来分析下一个实现缓存分片的开源项目 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作者的开源,
    下一篇我们再来分析缓存分片采用的逻辑文件空洞的方案,欢迎交流

    四、资料

    1. JeffVideoCache
    2. 头条都在用的边下边播方案
    3. 重点推荐-QQ空间十亿级视频播放技术优化揭秘王辉终稿2.key

    五、收获

    从本篇的学习分析

    1. 了解缓存分片的是什么,为什么,以及如何实现
    2. 分析了缓存分片物理文件空洞方案的实现。

    感谢你的阅读
    下一篇我们我们来分析缓存分片逻辑文件空洞方案的实现,欢迎关注公众号“音视频开发之旅”,一起学习成长。
    欢迎交流

    相关文章

      网友评论

          本文标题:音视频开发之旅(50)-边缓存边播放之缓存分片(1)

          本文链接:https://www.haomeiwen.com/subject/vcaavltx.html