AndroidVideoCache源码详解以及改造系列-源码篇

前言

    为什么写这个文章?因为之前做过一些短视频方面相关的应用,特别是在播放优化上面踩过一点坑。优化的主要目的为了让视频达到秒开,视频的预加载等,并在用户多次播放的过程中能减少流量的消耗。最初我们也做了一些播放器相关的优化,比如说我们优化播放器的内核,改变播放器起播的时机,使一有数据就开始起播。(Android自身的系统播放器需要满足一个GOP的大小才能起播);控制视频的编码与压缩方式,使视频能又小又清晰。但是还是有一些问题没有解决,比如初始化播放器setUrl并start后,播放器需要开启网络连接并从服务端下载数据,这本身是一个耗时的操作,而且播放器下载数据播放完就会把数据从缓冲区清除掉,每次重复播放视频都会重新连接网络下载,这显然是不可取的。那么现在我们就要解决两个问题,第一,重复播放的视频应该走缓存而不是重新下载,第二,提前下载视频,使视频能达到起播态。
    而重复的视频边播变缓存的策略,使视频loopping时播放器不需要重新下载数据。秉着不需要重新造轮子的思想,我们先直接采用国外大神提供的AndroidVideoCache这个开源库,它是一种透明代理,也称本地代理的方案,就是拦截掉播放器的网络请求,代理播放器的下载功能,将下载的文件保存到文件,然后将文件中的数据返回给播放器。后面的文章,我将改造这个库,使它支持我们类似于抖音和快手的预加载功能。

特性

1.目前AndroidVideoCache只适合url直连数据,例如短视频领域用的比较多的mp4链接。像HLS,m3u8等支持的不是很友好。
2.能离线加载资源
3.支持多个播放器共享一个url下载
4.缓存管理,支持设置最大缓存数和缓存数量限制

使用方式

我们需要先定义一个VideoProxyManager的单例类,在单例初始化的时候我们配置本地代理的各种参数,例如下图中的缓存的数量和大小。还可以配置缓存文件名生成的格式,缓存文件的路径配置等等。
然后我们在使用Ijk或者系统提供的VideoView的地方按照如下方式调用,这样播放器就会直接走我们代理进行缓存了。

    videoView.setVideoPath(VideoProxyManager. getInstance(). getProxyUrl(VIDEO_URL));
    public class VideoProxyManager {

    private HttpProxyCacheServer httpProxyCacheServer;
    private static final long DEFAULT_MAX_SIZE = 600 * 1024 * 1024; //最大缓存容量
    public static int DEFAULT_MAX_FILE_COUNT = 50; //最大缓存数量

    public static boolean isUseCache = true; // 全局是否使用缓存,Server端下发配置

    private VideoProxyManager() {
    }

    private static class VideoProxyManagerHolder {
        private static VideoProxyManager videoProxyManager = new VideoProxyManager();
    }

    public static VideoProxyManager getInstance() {
        return VideoProxyManagerHolder.videoProxyManager;
    }

    public void init(Context context) {
        httpProxyCacheServer = new HttpProxyCacheServer.Builder(context).maxCacheSize(DEFAULT_MAX_SIZE)
            .maxCacheFilesCount(DEFAULT_MAX_FILE_COUNT)
            .build();
    }
    
    /**
      * 传给播放器的url替换成代理的url
    **/

    public String getProxyUrl(String url) {
        if (TextUtils.isEmpty(url) || !isUseCache) {
            return url;
        }
        return httpProxyCacheServer.getProxyUrl(url);
    }

    /**
     * 需要非常小心,可能会误杀多播放器共享一个url的情况
     * @param url
     */
    public void shutdownOneClient(String url) {
        if (TextUtils.isEmpty(url)) {
            return;
        }
        httpProxyCacheServer.shutdownOneClient(url);
    }

    public void shutdown() {
        httpProxyCacheServer.shutdown();
    }
}

源码分析

在一般的播放器请求数据的模型中,播放器直接通过url连接到远程服务器,播放器下载后的数据直接交给播放器缓冲区,数据使用完了以后直接淘汰掉。


一般的播放器与远程服务器的交互

如果我们在播放器与远程server中间插入一个本地透明代理,这样透明代理就可以接管播放器的请求,透明代理从远程server下载完数据就可以先保存在本地,然后把所需要的数据交给播放器。类似于我们Charles抓包这样。下一次播放器请求相同的数据,就可以在本地代理这里找到对应的缓存文件,直接返回。


在播放器和远程服务器中间插入透明代理,拦截下载

我们首先从入口函数这里开始分析,首先是HttpProxyCacheServer这个类,这是一个入口类。我们也通过VideoProxyManager对其进行了单例的包装。下面的代码主要是完成视频信息的数据库和缓存的设置。

public Builder(Context context) {
            this.sourceInfoStorage = SourceInfoStorageFactory.newSourceInfoStorage(context);//数据库,存储视频原始url、mine信息、视频length
            this.cacheRoot = StorageUtils.getIndividualCacheDirectory(context);//缓存文件的存储路径
            this.diskUsage = new TotalSizeLruDiskUsage(DEFAULT_MAX_SIZE); //LRU缓存设置,设置最大缓存数量和总大小
            this.fileNameGenerator = new Md5FileNameGenerator(); //文件缓存名
            this.headerInjector = new EmptyHeadersInjector(); //在请求中增加head信息
        }

接下来我们再来分析HttpProxyCacheServer的初始化:

 private HttpProxyCacheServer(Config config) {
        this.config = checkNotNull(config);
        try {
            InetAddress inetAddress = InetAddress.getByName(PROXY_HOST);
            this.serverSocket = new ServerSocket(0, 8, inetAddress);
            this.port = serverSocket.getLocalPort();
            IgnoreHostProxySelector.install(PROXY_HOST, port);
            CountDownLatch startSignal = new CountDownLatch(1);
            this.waitConnectionThread = new Thread(new WaitRequestsRunnable(startSignal));
            this.waitConnectionThread.start();
            startSignal.await(); // freeze thread, wait for server starts
            this.pinger = new Pinger(PROXY_HOST, port);
            Log.e(TAG,"HttpProxyCacheServer Proxy cache server started. Is it alive? " + isAlive());
        } catch (IOException | InterruptedException e) {
            socketProcessor.shutdown();
            Log.e(TAG,"HttpProxyCacheServer 线程池关闭 ");
            throw new IllegalStateException("Error starting local proxy server", e);
        }
    }

上面代码主要是建立一个本地的服务器,地址为127.0.0.1,端口为获取的一个本地的可用端口。注意这个地使用了CountDownLatch来保证线程之间的顺序执行、我们重点分析WaitRequestsRunnable

private final class WaitRequestsRunnable implements Runnable {

        private final CountDownLatch startSignal;

        public WaitRequestsRunnable(CountDownLatch startSignal) {
            this.startSignal = startSignal;
        }
        @Override
        public void run() {
            startSignal.countDown();
            waitForRequest();
        }
    }

继续跟进waitForRequest()这个方法

 private void waitForRequest() {
        try {
            while (!Thread.currentThread().isInterrupted()) {
                Socket socket = serverSocket.accept();
               Log.e(TAG,"HttpProxyCacheServer Accept new socket " + socket);
                socketProcessor.submit(new SocketProcessorRunnable(socket));
            }
        } catch (IOException e) {
            onError(new ProxyCacheException("HttpProxyCacheServer Error during waiting connection", e));
        }
    }

当播放器通过proxyUrl连接到代理服务器时,serverSocket.accept()就会建立一个可用的Socket连接。socketProcessor是一个固定线程池,我们重点关注new SocketProcessorRunnable(socket)

private final class SocketProcessorRunnable implements Runnable {

        private final Socket socket;

        public SocketProcessorRunnable(Socket socket) {
            this.socket = socket;
        }

        @Override
        public void run() {
            processSocket(socket);
        }
    }

继续跟进processSocket(socket)

   private void processSocket(Socket socket) {
        try {
            
            GetRequest request = GetRequest.read(socket.getInputStream());
            String url = ProxyCacheUtils.decode(request.uri);
            if (pinger.isPingRequest(url)) {
                pinger.responseToPing(socket);
            } else {
                HttpProxyCacheServerClients clients = getClients(url);
                clients.processRequest(request, socket);
            }
        } catch (SocketException e) {
            // There is no way to determine that client closed connection http://stackoverflow.com/a/10241044/999458
            // So just to prevent log flooding don't log stacktrace
            Log.e("TAG","Closing socket… Socket is closed by client.");
        } catch (ProxyCacheException | IOException e) {
            onError(new ProxyCacheException("Error processing request", e));
        } finally {
            releaseSocket(socket);
            Log.e("TAG","Opened connections: " + getClientsCount());
        }
    }

这个地又冒出了两个类GetRequestHttpProxyCacheServerClients,GetRequest主要是根据Socket中InputStream来构建我们请求的。它里面主要保存着我们对应的url,请求的起始位置rangeOffset,和是否是分段下载partial。HttpProxyCacheServerClients可以理解为对应一个具体url视频的客户端,视频的下载,缓存,以及最后将数据交给播放器,都是在这里面处理的。上面的代码中,我们重点关注clients.processRequest(request, socket),所有的秘密都藏在这里面。

    public void processRequest(GetRequest request, Socket socket) throws ProxyCacheException, IOException {
        startProcessRequest();
        try {
            clientsCount.incrementAndGet();
            proxyCache.processRequest(request, socket);
        } finally {
            finishProcessRequest();
        }
    }

上图中,startProcessRequest()主要是创建一个HttpProxyCache,我们来看看HttpProxyCache是如何创建的

private HttpProxyCache newHttpProxyCache() throws ProxyCacheException {
        HttpUrlSource source = new HttpUrlSource(url,this, config.sourceInfoStorage, config.headerInjector);
        FileCache cache = new FileCache(config.generateCacheFile(url), config.diskUsage);
        HttpProxyCache httpProxyCache = new HttpProxyCache(source, cache);
        httpProxyCache.registerCacheListener(uiCacheListener);
        return httpProxyCache;
    }

可以看出HttpProxyCache里面主要由两部分组成,一个为HttpUrlSource即网络下载部分,二为FileCache即文件缓存部分。我们继续跟踪上上图的proxyCache.processRequest(request, socket)

    public void processRequest(GetRequest request, Socket socket) throws IOException, ProxyCacheException {
        OutputStream out = new BufferedOutputStream(socket.getOutputStream());
        String responseHeaders = newResponseHeaders(request);
        out.write(responseHeaders.getBytes("UTF-8"));

        long offset = request.rangeOffset;
        if (isUseCache(request)) {
            responseWithCache(out, offset);
        } else {
            responseWithoutCache(out, offset);
        }
    }

上图中,代码的前三行主要是向播放器返回 Head信息,我们来看看isUseCache(request)这个方法

private boolean isUseCache(GetRequest request) throws ProxyCacheException {
        long sourceLength = source.length();
        boolean sourceLengthKnown = sourceLength > 0;
        long cacheAvailable = cache.available();
        // do not use cache for partial requests which too far from available cache. It seems user seek video.
        return !sourceLengthKnown || !request.partial || request.rangeOffset <= cacheAvailable + sourceLength * NO_CACHE_BARRIER;
    }

这个方法其实挺重要的,它决定了我们是否要使用缓存。之前有人问我,为啥我从前面一下子seek到后面,没走缓存啊。答案就在这里,request.rangeOffset <= cacheAvailable + sourceLength * NO_CACHE_BARRIER。说的更加直白点,就是seek超过视频总长的20%
则跳过缓存。若是seek在20%总长以内,则会把seek部分的全部下载完全后再把对应的部分交给播放器。至于作者为啥这么设计,我后面总结的时候会说。
我们重点关注responseWithCache(out, offset)的情况,因为responseWithoutCache(out, offset)只是少了一个缓存,其它的逻辑都一样

    private void responseWithCache(OutputStream out, long offset) throws ProxyCacheException, IOException {
        byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
        int readBytes;
        while ((readBytes = read(buffer, offset, buffer.length)) != -1) {
            if(out!=null) {
                out.write(buffer, 0, readBytes);
            }
            offset += readBytes;
        }
        if(out!=null) {
            out.flush();
        }
    }

我们重点看 read(buffer, offset, buffer.length)这个方法

 public int read(byte[] buffer, long offset, int length) throws ProxyCacheException {
        ProxyCacheUtils.assertBuffer(buffer, offset, length);
        while (!cache.isCompleted() && cache.available() < (offset + length) && !stopped) {
            readSourceAsync();
            waitForSourceData();
            checkReadSourceErrorsCount();
        }
        int read = cache.read(buffer, offset, length);
        if (cache.isCompleted() && percentsAvailable != 100) {
            percentsAvailable = 100;
            onCachePercentsAvailableChanged(100);
        }
        return read;
    }

其中readSourceAsync()主要是判断缓存中是否存在,若不存在则去下载。cache.read(buffer, offset, length)主要是往文件中存数据。我们来看readSourceAsync()的实现

    private synchronized void readSourceAsync() throws ProxyCacheException {
        boolean readingInProgress = sourceReaderThread != null && sourceReaderThread.getState() != Thread.State.TERMINATED;
        if (!stopped && !cache.isCompleted() && !readingInProgress) {
            sourceReaderThread = new Thread(new SourceReaderRunnable(), "Source reader for " + source);
            sourceReaderThread.start();
        }
    }

继续跟进SourceReaderRunnable()

    private class SourceReaderRunnable implements Runnable {

        @Override
        public void run() {
            readSource();
        }
    }

继续跟进readSource()

    private void readSource() {
        long sourceAvailable = -1;
        long offset = 0;
        try {
            offset = cache.available();
            source.open(offset);
            sourceAvailable = source.length();
            byte[] buffer = new byte[ProxyCacheUtils.DEFAULT_BUFFER_SIZE];
            int readBytes;
            while ((readBytes = source.read(buffer)) != -1) {
                synchronized (stopLock) {
                    if (isStopped()) {
                        return;
                    }
                    cache.append(buffer, readBytes);
                }
                offset += readBytes;
                notifyNewCacheDataAvailable(offset, sourceAvailable);
            }
            tryComplete();
            onSourceRead();
        } catch (Throwable e) {
            readSourceErrorsCount.incrementAndGet();
            onError(e);
        } finally {
            closeSource();
            notifyNewCacheDataAvailable(offset, sourceAvailable);
        }
    }

上图中分别调用了 HttpUrlSourcesource.open(offset),source.read(buffer)source.open(offset)主要是打开HttpURLConnection连接,获取视频的mine信息,视频的length信息,并存到数据库中。

public void open(long offset) throws ProxyCacheException {
        try {
            connection = openConnection(offset, -1);
            String mime = connection.getContentType();
            inputStream = new BufferedInputStream(connection.getInputStream(), DEFAULT_BUFFER_SIZE);
            long length = readSourceAvailableBytes(connection, offset, connection.getResponseCode());
            this.sourceInfo = new SourceInfo(sourceInfo.url, length, mime);
            this.sourceInfoStorage.put(sourceInfo.url, sourceInfo);
        } catch (IOException e) {
            throw new ProxyCacheException("Error opening connection for " + sourceInfo.url + " with offset " + offset, e);
        }
    }
private HttpURLConnection openConnection(long offset, int timeout) throws IOException, ProxyCacheException {
        HttpURLConnection connection;
        boolean redirected;
        int redirectCount = 0;
        String url = this.sourceInfo.url;
        do {
            LOG.debug("Open connection " + (offset > 0 ? " with offset " + offset : "") + " to " + url);
            connection = (HttpURLConnection) new URL(url).openConnection();
            injectCustomHeaders(connection, url);
            if (offset > 0) {
                connection.setRequestProperty("Range", "bytes=" + offset + "-");
            }
            if (timeout > 0) {
                connection.setConnectTimeout(timeout);
                connection.setReadTimeout(timeout);
            }
            int code = connection.getResponseCode();
            redirected = code == HTTP_MOVED_PERM || code == HTTP_MOVED_TEMP || code == HTTP_SEE_OTHER;
            if (redirected) {
                url = connection.getHeaderField("Location");
                redirectCount++;
                connection.disconnect();
            }
            if (redirectCount > MAX_REDIRECTS) {
                throw new ProxyCacheException("Too many redirects: " + redirectCount);
            }
        } while (redirected);
        return connection;
    }

上面处理了重定向,并根据connection.setRequestProperty("Range", "bytes=" + offset + "-")来建立连接。然后我们回到上上上图的readSource()source.read(buffer)这个方法

@Override
    public int read(byte[] buffer) throws ProxyCacheException {
        if (inputStream == null) {
            throw new ProxyCacheException("Error reading data from " + sourceInfo.url + ": connection is absent!");
        }
        try {
            return inputStream.read(buffer, 0, buffer.length);
        } catch (InterruptedIOException e) {
            throw new InterruptedProxyCacheException("Reading source " + sourceInfo.url + " is interrupted", e);
        } catch (IOException e) {
            throw new ProxyCacheException("Error reading data from " + sourceInfo.url, e);
        }

这个方法就比较简单了,直接从inputStream中取数据。取完数据我们再回到 前面的readSource()中调用cache.append(buffer, readBytes)将数据存到本地文件中。此时我们继续回到responseWithCache(out, offset)

    private void responseWithCache(OutputStream out, long offset) throws ProxyCacheException, IOException {
        byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
        int readBytes;
        while ((readBytes = read(buffer, offset, buffer.length)) != -1) {
            if(out!=null) {
                out.write(buffer, 0, readBytes);
            }
            offset += readBytes;
        }
        if(out!=null) {
            out.flush();
        }
    }

上图中通过out.write(buffer, 0, readBytes)将数据返回给播放器。至此源码分析告一段落。

总结&问题

1.如果只是想做一个简单的mp4视频缓存的,AndroidVideoCache显然是足够的。
2.我们可以看见这种缓存策略都是依赖于播放器的,在类似于快手和抖音的feed里面,脱离播放器的下载还做不到。
3.AndroidVideoCache会一直连接网络下载数据,直到把数据下载完全。这肯定是不可取的。倘若一个5分钟100M的视频,我只看了20s就要把整个视频下载了,没必要吧。根据播放器的播放进度按需加载才是最优的。
4.之前给大家讲过如果seek的超过总长的20%(前提是seek后的文件还不在缓存时seek),播放器会不走缓存直接下载。其实我们可以脑补一下,假设一个1G的文件,我一下子seek到最后面,AndroidVideoCache怎么存哇。目前他的实现是一个RandomAccessFile,它不可能建一个1G空文件后然后只存最后一部分啊,否则得占用多少存储空间。看样子只能本地虚拟分片了。我目前正在思考这个功能的实现,完成后再分享给大家。

后面我的文章会重点解决上面的遗留下来的问题,敬请期待哦~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容

  • 用两张图告诉你,为什么你的 App 会卡顿? - Android - 掘金 Cover 有什么料? 从这篇文章中你...
    hw1212阅读 12,680评论 2 59
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,417评论 25 707
  • 他时常梦到自己的第一百个孩子 他们没有笑容 永远都是绿颜色的 他们在漫天雪花的季节里融化 春天 长出漂亮的妈妈
    董林菽阅读 187评论 0 0
  • 11月14日读完此书。作者是英国作家彼得弗兰科潘。早在两个星期之前便看完了这部关于世界史的书,书评却一直拖到了今天...
    故纸旧人阅读 551评论 0 0
  • 说好带你流浪 而我却半路返航 坠落自责的海洋 发现离不开你 我开始决定回去 你已不在原地 我可以接受你的所有 所有...
    失欲阅读 286评论 0 0