一、背景

现在的移动应用,视频是一个非常重要的组成部分,好像里面不搞一点视频就不是一个正常的移动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版本为16implementation '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: optionalimplementation '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版本为16implementation '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: optionalimplementation '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 {@Overridepublic void onCreate() {//...//如果您想使用默认的网络状态事件生产者,请添加此行配置。//并需要添加权限 android.permission.ACCESS_NETWORK_STATEPlayerConfig.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 {@Overridepublic 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 startsthis.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信号变量。

 @Overridepublic 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;}@Overridepublic 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 stacktraceLOG.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在请求时回先使用本地的代理方式,然后开启一系列的缓存逻辑,并在缓存完成后发出通知,当再次请求的时候,如果本地已经进行了文件缓存,就会优先使用本地的数据。

Android 边播放边缓存视频框架:AndroidVideoCache简析相关推荐

  1. Android手机下载的缓存视频如何找到?

    一.Android手机下载的缓存视频查找方式 1.Android 应用的缓存文件夹 从手机少可以打开 2.从Windows电脑上也可以打开. 插上数据线,USB选择[传输文件]即可. 二.手机[文件管 ...

  2. Android - 手机下载的缓存视频在文件管理怎么找不到?

    方法 / 步骤 1.找到[文件管理]图标,点击进入文件管理界面 Ps:或者数据线连接到电脑上也同理. 2.在[文件管理]中找到[所有文件],点击进去 3.在[所有文件]中找到[Android]文件夹, ...

  3. Retrofit网络请求框架使用简析——Android网络请求框架(四)

    题记:-- 很累,累到想要放弃,但是放弃之后将会是一无所有,又不能放弃, 唯有坚持,唯有给自忆打气,才能更勇敢的走下去,因为无路可退,只能前行, 时光一去不复返,每一天都不可追回,所以要更珍惜每一存光 ...

  4. android 内存播放视频播放器,视频流媒体播放器EasyPlayer-RTSP安卓版在RK3399上运行APP崩溃问题...

    原标题:视频流媒体播放器EasyPlayer-RTSP安卓版在RK3399上运行APP崩溃问题 我们的流媒体服务器现在都已经支持H.265编码视频的播放,流媒体播放器EasyPlayer就是目前比较稳 ...

  5. Android自动播放U盘视频

    在网上看到很多类似的Demo,我刚入行很多都看不懂,最后自己弄出来这个,可以自动播放视频,但是只能播放名字为"video.mp4"的视频,也是得先打开Apk再插入U盘才能跳转播放, ...

  6. EasyPlayer RTSP Android安卓播放器实现视频源快速切换

    EasyPlayer现在支持多视频源快速切换了,我们介绍一下是如何实现的. 这个需求通常应用在一个客户端需要查看多个视频源的情况,比如多个监控场景轮播. 由于EasyPlayer的播放端已经放在Fra ...

  7. Android完美播放优酷视频

    把优酷视频的id 先截取出来,然后拼成:http://v.youku.com/player/getRealM3U8/vid/XNjMwNjQzOTYw/type/video.m3u8格式 ,然后用an ...

  8. android 定时播放wav,Android MediaPlayer播放音频与视频

    播放音频 首先创建出一个MediaPlayer对象 MediaPlayer mMediaPlayer = new MediaPlayer(); 设置声音源 public static final St ...

  9. android ota 版本校验,OTA升级签名校验简析

    1. 概要 如果进行过OTA升级的开发者,都或多或少有这样的疑问,如何确定该OTA升级包是可以信任的呢?这其中其实涉及到一个签名验证的流程. 2. 签名生成 在生成正规的固件时,一般会运行生成新key ...

最新文章

  1. poj1716(差分约束+SPFA)
  2. linux access函数_构建一个即时消息应用(九):Conversation 页面 | Linux 中国
  3. 原型模式Prototype,constructor,__proto__详解
  4. android应用程序开发_Kotlin与Flutter:Android跨平台应用程序开发,到底选择哪个?...
  5. 读取工作流程中的表单物件
  6. [JOI2012春季合宿]Constellation (凸包)
  7. 利用Spring框架封装的JavaMail现实同步或异步邮件发送
  8. Parse Too complex in xxxx.cpp --------source insight
  9. MySQL 中NULL和空值的区别
  10. KAFKA SpringBoot2 Nacos 消息异步发送和消费消息(进阶篇)
  11. frameset和iframe的区别
  12. OpenShift 4 Hands-on Lab (11) 用户身份认证和资源访问限制
  13. 德赛西威280b升级_车载导航刷机:德赛西威NAV280H固件升级
  14. varchar和varchar2的联系与区别
  15. linux系统下使用gcc编译C++程序出现XXX未定义的引用的处理
  16. Windows11安装Android子系统——安装篇
  17. 牛郎织女都见面,而你却在吃狗粮---男士星座脱单指南
  18. 【惯性导航姿态仪】 04 -Mini AHRS 姿态解算说明
  19. 测试工程师职业要求汇总(转)
  20. UEFI开发历程2—基于SuperIO芯片的寄存器读写

热门文章

  1. eNSP抓包看PPP协议
  2. 独立说携手百度传课在人大举办投行讲座取得圆满成功!
  3. 安卓逆向005之实现APK分身
  4. CRSLab:构建会话推荐系统的开源工具包
  5. setuptools打包安装工具的使用
  6. 2022年百度竞价推广效果不好了?怎么做?
  7. 蓝桥杯EDA赛前总结
  8. 通信原理与MATLAB(一):AM的调制解调
  9. excel图表美化:设置标记样式让拆线图精巧有趣
  10. 双旗科技亮相2010年上海国际数字标牌展