乐趣区

关于android:Android-边播放边缓存视频框架AndroidVideoCache简析

一、背景

当初的挪动利用,视频是一个十分重要的组成部分,如同外面不搞一点视频就不是一个失常的挪动 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 替换成上面的形式。

@Override
protected 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 在申请时回先应用本地的代理形式,而后开启一系列的缓存逻辑,并在缓存实现后发出通知,当再次申请的时候,如果本地曾经进行了文件缓存,就会优先应用本地的数据。

退出移动版