Android VideoCache Analysis

1. Background

In today’s mobile applications, video is a very important part. It seems that it is not a normal mobile application without a little video in it. In terms of video development, it can be divided into video recording and video playback. The scene of video recording may be relatively small. In this regard, Google’s open source grafika can be used. Compared with video recording, there are many more options for video playback, such as Google’s ExoPlayer, Bilibili’s ijkplayer, and the official MediaPlayer.

However, what we’re going to talk about today is video caching. Recently, because we did not consider the video caching problem in the development of the video, the traffic was wasted, and then users complained. In video playback, there are generally two strategies: downloading first and then playing and caching while playing.

Usually, in order to improve the user experience, we will choose the strategy of caching while playing, but most players on the market only support video playback, and there is basically no good solution for video caching, such as our App. It uses a self-encapsulated library, similar to PlayerBase. PlayerBase is a solution framework for component processing of decoders and playback views, that is, a wrapper library for ExoPlayer and ijkplayer.

2. PlayerBase

PlayerBase is a solution framework that componentizes decoders and playback views. You can implement the abstract introduction of the framework defined by whatever decoder you need. For views, whether it is a control view in the player or a business view, component processing can be achieved. Moreover, it supports the seamless connection of videos across pages, which is one of the reasons why we choose it.

The use of PlayerBase is also relatively simple. When using it, you need to add a separate decoder. The specific decoder to be used can be freely configured according to the needs of the project.

Just use MediaPlayer:

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

Using ExoPlayer + MediaPlayer

 dependencies {
  //该依赖包含exoplayer解码和MediaPlayer解码
  //注意exoplayer的最小支持SDK版本为16
  implementation 'cn.jiajunhui:exoplayer:342_2132_019'
}

Use 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'
  
}

Use 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'
  
}

Finally, when code obfuscation is performed, the following obfuscation rules need to be added to proguard.

 -keep public class * extends android.view.View{*;}

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

After adding the decoder, you only need to initialize the decoder in the Application of the application, and then you can use it.

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

Then, start playing in the business code.

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

However, there is a disadvantage that PlayerBase does not provide a caching solution, that is, the played video will still consume traffic when it is played again, which goes against our original design intention. Is there any one that can support caching and invade PlayerBase at the same time? What about the less sexual option? The answer is yes, that is AndroidVideoCache .

3. AndroidVideoCache

3.1 Basic principles

AndroidVideoCache implements an intermediate layer through the proxy strategy, and then our network requests will be transferred to the locally implemented proxy server, so that the data we really request will be obtained by the proxy, and then the proxy will write data to the local, while according to our The required data depends on whether to read network data or read local cache data, so as to realize data multiplexing.

After actual testing, I found that its process is as follows: the network data is used for the first use, and the local data is read when the same video is used again later. Since AndroidVideoCache can configure the size of the cached file, it will repeat the previous strategy before loading the video. The working principle is as follows.

3.2 Basic use

Like other plugin usage processes, we first need to add AndroidVideoCache dependencies to the project.

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

Then, to initialize a local proxy server globally, we choose to initialize it globally in the implementation class of 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);
    }
}

Of course, the initialization code can also be written in other places, such as our public Module. After having a proxy server, we replace the web video url with the following method where we use it.

 @Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

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

Of course, AndroidVideoCache also provides a lot of custom rules, such as the size of the cached file, the number of files, and the cache location.

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

Of course, we can also use the MD5 method to generate a key as the file name.

 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()

In addition, AndroidVideoCache also supports adding a custom HeadersInjector to add custom request headers when requesting.

 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 Source code analysis

As we said earlier, Android VideoCache implements an intermediate layer through a proxy strategy, and then implements the real request through the local proxy service during network requests. The advantage of this operation is that no additional requests are generated, and in terms of caching strategy, Android VideoCache uses With the LruCache caching strategy algorithm, there is no need to manually maintain the size of the cache area, which truly liberates your hands. First, let’s take a look at the HttpProxyCacheServer class.

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

    }
}

It can be seen that the constructor first uses the local localhost address, creates a ServerSocket and randomly assigns a port, and then gets the server port through getLocalPort to communicate with the server. Next, create a thread WaitRequestsRunnable, which has a startSignal signal variable.

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

The entire proxy process of the server is to first build a global local proxy server ServerSocket, specify a random port, and then open a new thread. In the run method of the thread, use the accept() method to monitor the inbound connection of the server socket, accept() ) method will block until a client attempts to establish a connection.

With the code server, the next step is the client’s Socket. Let’s start with the proxy replacement url place:

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

Among them, the source code of the getProxyUrl() method in HttpProxyCacheServer is as follows.

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

It can be seen that the above code is the core function of Android VideoCache: if the local Uri is already cached, the local Uri is used directly, and the time is updated, because LruCache is sorted according to the time when the file is accessed, if the file is not accessed. The cache then calls the isAlive() method, and the isAlive() method pings the target url to ensure that the url is a valid one.

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

If the user accesses through a proxy, the ping will fail, so the original url is still used, and finally the appendToProxyUrl () method is entered.

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

Next, the socket will be wrapped into a runnable and allocated to the thread pool.

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

The processSocket() method will process all incoming Socket requests, including ping and VideoView.setVideoPath(proxyUrl) Sockets. Let’s focus on the code in the else statement. There is a ConcurrentHashMap in the getClients() method here, and repeated urls return the same 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;
        }
    }

If it is the url of the first request, HttpProxyCacheServerClients are put into ConcurrentHashMap. The real network requests are operated in the processRequest () method, and a GetRequest object needs to be passed, including a wrapper class for url, rangeoffset and partial.

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

Among them, the startProcessRequest method will get a new HttpProxyCache class object.

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

Here, we build a native url-based HttpUrlSource, this class object is responsible for holding the url, and opens HttpURLConnection to obtain an InputStream, so that the input stream can be used to read data, and a local temporary file is also created. , a temporary file ending in .download that is renamed in the complete method of the FileCache class after a successful download. After performing the above operations, then the HttpProxyCache object starts to call the processRequest() method.

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

After getting the output stream of an OutputStream, we can write data to the sd card. If we do not need to cache, we will follow the normal logic. Here we only look at the logic of the cache, that is, 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;
    }

In the while loop, a new thread sourceReaderThread is opened, which encapsulates a Runnable of SourceReaderRunnable. This asynchronous thread is used to write data to the cache, that is, the local file, and also update the current cache progress.

At the same time, another SourceReaderRunnable thread will read data from the cache. After the cache is over, a notification will be sent to notify that the cache is over, and the outside world can call it.

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

At this point, the core caching process of AndroidVideoCache is analyzed. In general, Android VideoCache first uses the local proxy method when requesting, then starts a series of caching logic, and sends a notification after the cache is completed. When requesting again, if the file has been cached locally, it will take priority. Use local data.