一、背景

当初的挪动利用,视频是一个十分重要的组成部分,如同外面不搞一点视频就不是一个失常的挪动App。在视频开发方面,能够分为视频录制和视频播放,视频录制的场景可能还比拟少,这方面能够应用Google开源的 grafika。相比于视频录制,视频播放能够抉择的计划就要多许多,比方Google的 ExoPlayer,B站的 ijkplayer,以及官网的MediaPlayer。

不过,咱们明天要讲的是视频的缓存。最近,因为咱们在开发视频方面没有思考视频的缓存问题,造成了流量的节约,而后受到用户的投诉。在视频播放中,个别有两种两种策略:先下载再播放和边播放边缓存。

通常,为了进步用户的体验,咱们会抉择边播放边缓存的策略,不过市面上大多数的播放器都是只反对视频播放,在视频缓存这块基本上没啥好的计划,比方咱们的App应用的是一个本人封装的库,相似于PlayerBase。PlayerBase是一种将解码器和播放视图组件化解决的解决方案框架,也即是一个对ExoPlayer、ijkplayer的包装库。

二、PlayerBase

PlayerBase是一种将解码器和播放视图组件化解决的解决方案框架。您须要什么解码器实现框架定义的形象引入即可,对于视图,无论是播放器内的管制视图还是业务视图,均能够做到组件化解决。并且,它反对视频跨页面无缝连接的成果,也是咱们抉择它的一个起因。

PlayerBase的应用也比较简单,应用的时候须要独自的增加解码器,具体应用哪种解码器,能够依据我的项目的须要自在的进行配置。

只应用MediaPlayer:

dependencies {  //该依赖仅蕴含MediaPlayer解码  implementation 'com.kk.taurus.playerbase:playerbase:3.4.2'}

应用ExoPlayer + MediaPlayer

dependencies {  //该依赖蕴含exoplayer解码和MediaPlayer解码  //留神exoplayer的最小反对SDK版本为16  implementation 'cn.jiajunhui:exoplayer:342_2132_019'}

应用ijkplayer + MediaPlayer

dependencies {    //该依赖蕴含ijkplayer解码和MediaPlayer解码  implementation 'cn.jiajunhui:ijkplayer:342_088_012'  //ijk官网的解码库依赖,较少格局版本且不反对HTTPS。  implementation 'tv.danmaku.ijk.media:ijkplayer-armv7a:0.8.8'  # Other ABIs: optional  implementation 'tv.danmaku.ijk.media:ijkplayer-armv5:0.8.8'  implementation 'tv.danmaku.ijk.media:ijkplayer-arm64:0.8.8'  implementation 'tv.danmaku.ijk.media:ijkplayer-x86:0.8.8'  implementation 'tv.danmaku.ijk.media:ijkplayer-x86_64:0.8.8'  }

应用ijkplayer + ExoPlayer + MediaPlayer

dependencies {    //该依赖蕴含exoplayer解码和MediaPlayer解码  //留神exoplayer的最小反对SDK版本为16  implementation 'cn.jiajunhui:exoplayer:342_2132_019'  //该依赖蕴含ijkplayer解码和MediaPlayer解码  implementation 'cn.jiajunhui:ijkplayer:342_088_012'  //ijk官网的解码库依赖,较少格局版本且不反对HTTPS。  implementation 'tv.danmaku.ijk.media:ijkplayer-armv7a:0.8.8'  # Other ABIs: optional  implementation 'tv.danmaku.ijk.media:ijkplayer-armv5:0.8.8'  implementation 'tv.danmaku.ijk.media:ijkplayer-arm64:0.8.8'  implementation 'tv.danmaku.ijk.media:ijkplayer-x86:0.8.8'  implementation 'tv.danmaku.ijk.media:ijkplayer-x86_64:0.8.8'  }

最初,在进行代码混同时,还须要在proguard中增加如下混同规定。

-keep public class * extends android.view.View{*;}-keep public class * implements com.kk.taurus.playerbase.player.IPlayer{*;}

增加完解码器之后,接下来只须要在利用的Application中初始化解码器,而后就能够应用了。

public class App extends Application {    @Override    public void onCreate() {        //...                //如果您想应用默认的网络状态事件生产者,请增加此行配置。        //并须要增加权限 android.permission.ACCESS_NETWORK_STATE        PlayerConfig.setUseDefaultNetworkEventProducer(true);        //初始化库        PlayerLibrary.init(this);                //如果增加了'cn.jiajunhui:exoplayer:xxxx'该依赖        ExoMediaPlayer.init(this);                //如果增加了'cn.jiajunhui:ijkplayer:xxxx'该依赖        IjkPlayer.init(this);                //播放记录的配置        //开启播放记录        PlayerConfig.playRecord(true);        PlayRecordManager.setRecordConfig(                        new PlayRecordManager.RecordConfig.Builder()                                .setMaxRecordCount(100)                                //.setRecordKeyProvider()                                //.setOnRecordCallBack()                                .build());            }   }

而后,在业务代码中开始播放即可。

ListPlayer.get().play(DataSource(url))

不过,有一个毛病是,PlayerBase并没有提供缓存计划,即播放过的视频再次播放的时候还是会耗费流量,这就违反了咱们的设计初衷,那有没有一种能够反对缓存,同时对PlayerBase侵入性比拟小的计划呢?答案是有的,那就是AndroidVideoCache。

三、AndroidVideoCache

3.1 基本原理

AndroidVideoCache 通过代理的策略实现一个中间层,而后咱们的网络申请会转移到本地实现的代理服务器上,这样咱们真正申请的数据就会被代理拿到,接着代理一边向本地写入数据,一边依据咱们须要的数据看是读网络数据还是读本地缓存数据,从而实现数据的复用。

通过理论测试,我发现它的流程如下:首次应用时应用的是网络的数据,前面再次应用雷同的视频时就会读取本地的。因为,AndroidVideoCache能够配置缓存文件的大小,所以,再加载视频前,它会反复后面的策略,工作原理图如下。

3.2 根本应用

和其余的插件应用流程一样,首先须要咱们在我的项目中增加AndroidVideoCache依赖。

dependencies {    compile 'com.danikula:videocache:2.7.1'}

而后,在全局初始化一个本地代理服务器,咱们抉择在Application的实现类中进行全局初始化。

public class App extends Application {    private HttpProxyCacheServer proxy;    public static HttpProxyCacheServer getProxy(Context context) {        App app = (App) context.getApplicationContext();        return app.proxy == null ? (app.proxy = app.newProxy()) : app.proxy;    }    private HttpProxyCacheServer newProxy() {        return new HttpProxyCacheServer(this);    }}

当然,初始化的代码也能够写到其余的中央,比方咱们的公共Module。有了代理服务器之后,咱们在应用的中央把网络视频url替换成上面的形式。

@Overrideprotected void onCreate(Bundle savedInstanceState) {super.onCreate(savedInstanceState);    HttpProxyCacheServer proxy = getProxy();    String proxyUrl = proxy.getProxyUrl(VIDEO_URL);    videoView.setVideoPath(proxyUrl);}

当然,AndroidVideoCache还提供了很多的自定义规定,比方缓存文件的大小、文件的个数,以及缓存地位等。

private HttpProxyCacheServer newProxy() {    return new HttpProxyCacheServer.Builder(this)            .maxCacheSize(1024 * 1024 * 1024)                   .build();}private HttpProxyCacheServer newProxy() {    return new HttpProxyCacheServer.Builder(this)            .maxCacheFilesCount(20)            .build();} private HttpProxyCacheServer newProxy() {        return new HttpProxyCacheServer.Builder(this)                .cacheDirectory(getVideoFile())                .maxCacheSize(512 * 1024 * 1024)                .build();    }  /*** 缓存门路**/    public File getVideoFile() {        String path = getExternalCacheDir().getPath() + "/video";        File file = new File(path);        if (!file.exists()) {            file.mkdir();        }        return file;    }

当然,咱们还能够应用的MD5形式生成一个key作为文件的名称。

public class MyFileNameGenerator implements FileNameGenerator {    public String generate(String url) {        Uri uri = Uri.parse(url);        String videoId = uri.getQueryParameter("videoId");        return videoId + ".mp4";    }}...HttpProxyCacheServer proxy = HttpProxyCacheServer.Builder(context)    .fileNameGenerator(new MyFileNameGenerator())    .build()

除此之外,AndroidVideoCache还反对增加一个自定义的HeadersInjector,用来在申请时候增加自定义的申请头。

public class UserAgentHeadersInjector implements HeaderInjector {    @Override    public Map<String, String> addHeaders(String url) {        return Maps.newHashMap("User-Agent", "Cool app v1.1");    }}private HttpProxyCacheServer newProxy() {    return new HttpProxyCacheServer.Builder(this)            .headerInjector(new UserAgentHeadersInjector())            .build();}

3.3 源码剖析

后面咱们说过,AndroidVideoCache 通过代理的策略实现一个中间层,而后再网络申请时通过本地代理服务去实现真正的申请,这样操作的益处是不会产生额定的申请,并且在缓存策略上,AndroidVideoCache应用了LruCache缓存策略算法,不必去手动保护缓存区的大小,真正做到解放双手。首先,咱们来看一下HttpProxyCacheServer类。

public class HttpProxyCacheServer {    private static final Logger LOG = LoggerFactory.getLogger("HttpProxyCacheServer");    private static final String PROXY_HOST = "127.0.0.1";    private final Object clientsLock = new Object();    private final ExecutorService socketProcessor = Executors.newFixedThreadPool(8);    private final Map<String, HttpProxyCacheServerClients> clientsMap = new ConcurrentHashMap<>();    private final ServerSocket serverSocket;    private final int port;    private final Thread waitConnectionThread;    private final Config config;    private final Pinger pinger;    public HttpProxyCacheServer(Context context) {        this(new Builder(context).buildConfig());    }    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.info("Proxy cache server started. Is it alive? " + isAlive());        } catch (IOException | InterruptedException e) {            socketProcessor.shutdown();            throw new IllegalStateException("Error starting local proxy server", e);        }    }  ...  public static final class Builder {        /**         * Builds new instance of {@link HttpProxyCacheServer}.         *         * @return proxy cache. Only single instance should be used across whole app.         */        public HttpProxyCacheServer build() {            Config config = buildConfig();            return new HttpProxyCacheServer(config);        }        private Config buildConfig() {            return new Config(cacheRoot, fileNameGenerator, diskUsage, sourceInfoStorage, headerInjector);        }    }}

能够看到,构造函数首先应用本地的localhost地址,创立一个 ServerSocket 并随机调配了一个端口,而后通过 getLocalPort 拿到服务器端口,用来和服务器进行通信。接着,创立了一个线程 WaitRequestsRunnable,外面有一个startSignal信号变量。

 @Override        public void run() {            startSignal.countDown();            waitForRequest();        }    private void waitForRequest() {        try {            while (!Thread.currentThread().isInterrupted()) {                Socket socket = serverSocket.accept();                LOG.debug("Accept new socket " + socket);                socketProcessor.submit(new SocketProcessorRunnable(socket));            }        } catch (IOException e) {            onError(new ProxyCacheException("Error during waiting connection", e));        }    }

服务器的整个代理的流程是,先构建一个全局的本地代理服务器 ServerSocket,指定一个随机端口,而后新开一个线程,在线程的 run 办法里通过accept() 办法监听服务器socket的入站连贯,accept() 办法会始终阻塞,直到有一个客户端尝试建设连贯。

有了代码服务器之后,接下来就是客户端的Socket。咱们先从代理替换url中央开始看:

    HttpProxyCacheServer proxy = getProxy();    String proxyUrl = proxy.getProxyUrl(VIDEO_URL);    videoView.setVideoPath(proxyUrl);

其中,HttpProxyCacheServer 中的 getProxyUrl()办法源码如下。

public String getProxyUrl(String url, boolean allowCachedFileUri) {        if (allowCachedFileUri && isCached(url)) {            File cacheFile = getCacheFile(url);            touchFileSafely(cacheFile);            return Uri.fromFile(cacheFile).toString();        }        return isAlive() ? appendToProxyUrl(url) : url;    }

能够看到,下面的代码就是AndroidVideoCache的外围的性能:如果本地曾经缓存了,就间接应用本地的Uri,并且把工夫更新下,因为LruCache是依据文件被拜访的工夫进行排序的,如果文件没有被缓存那么就调用isAlive() 办法,isAlive()办法会ping一下指标url,确保url是一个无效的。

 private boolean isAlive() {        return pinger.ping(3, 70);   // 70+140+280=max~500ms    }

如果用户是通过代理拜访的话,就会ping不通,这样就还是应用原生的url,最初进入appendToProxyUrl ()办法外面。

    private String appendToProxyUrl(String url) {        return String.format(Locale.US, "http://%s:%d/%s", PROXY_HOST, port, ProxyCacheUtils.encode(url));    }

接着,socket会被包裹成一个runnable,发配给线程池。

 socketProcessor.submit(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);        }    }    private void processSocket(Socket socket) {        try {            GetRequest request = GetRequest.read(socket.getInputStream());            LOG.debug("Request to cache proxy:" + request);            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.debug("Closing socket… Socket is closed by client.");        } catch (ProxyCacheException | IOException e) {            onError(new ProxyCacheException("Error processing request", e));        } finally {            releaseSocket(socket);            LOG.debug("Opened connections: " + getClientsCount());        }    }

processSocket()办法会解决所有的申请进来的Socket,包含ping的和VideoView.setVideoPath(proxyUrl)的Socket,咱们重点看一下 else语句外面的代码。这里的 getClients()办法外面有一个ConcurrentHashMap,反复url返回的是同一个HttpProxyCacheServerClients。

private HttpProxyCacheServerClients getClients(String url) throws ProxyCacheException {        synchronized (clientsLock) {            HttpProxyCacheServerClients clients = clientsMap.get(url);            if (clients == null) {                clients = new HttpProxyCacheServerClients(url, config);                clientsMap.put(url, clients);            }            return clients;        }    }

如果是第一次申请的url,HttpProxyCacheServerClients并被put到ConcurrentHashMap中。而真正的网络申请都在 processRequest ()办法中进行操作,并且须要传递过来一个GetRequest 对象,包含是一个url和rangeoffset以及partial的包装类。

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

其中,startProcessRequest 办法会失去一个新的HttpProxyCache 类对象。

    private synchronized void startProcessRequest() throws ProxyCacheException {        proxyCache = proxyCache == null ? newHttpProxyCache() : proxyCache;    }    private HttpProxyCache newHttpProxyCache() throws ProxyCacheException {        HttpUrlSource source = new HttpUrlSource(url, config.sourceInfoStorage);        FileCache cache = new FileCache(config.generateCacheFile(url), config.diskUsage);        HttpProxyCache httpProxyCache = new HttpProxyCache(source, cache);        httpProxyCache.registerCacheListener(uiCacheListener);        return httpProxyCache;    }

此处,咱们构建一个基于原生url的HttpUrlSource ,这个类对象负责持有url,并开启HttpURLConnection来获取一个InputStream,这样就能够应用这个输出流来读取数据了,同时也创立了一个本地的临时文件,一个以.download结尾的临时文件,这个文件在胜利下载完后的 FileCache 类中的 complete 办法中被更名。执行完下面的操作之后,而后这个HttpProxyCache 对象就开始 调用processRequest()办法。

    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);        }    }

拿到一个OutputStream的输入流后,咱们就能够往sd卡中写数据了,如果不必缓存就走惯例逻辑,这里咱们只看走缓存的逻辑,即responseWithCache()。

    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) {            out.write(buffer, 0, readBytes);            offset += readBytes;        }        out.flush();    }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;    }

在while循环外面,开启了一个新的线程sourceReaderThread,其中封装了一个SourceReaderRunnable的Runnable,这个异步线程用来给cache,也就是本地文件写数据,同时还更新一下以后的缓存进度。

同时,另一个SourceReaderRunnable线程会从cache中去读数据,在缓存完结后会发送一个告诉告诉缓存完了,外界能够去调用了。

        int sourceAvailable = -1;        int 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();

到此,AndroidVideoCache的外围缓存流程就剖析完了。总的来说,AndroidVideoCache在申请时回先应用本地的代理形式,而后开启一系列的缓存逻辑,并在缓存实现后发出通知,当再次申请的时候,如果本地曾经进行了文件缓存,就会优先应用本地的数据。