目录
- 一、背景
- 二、PlayerBase
- 三、AndroidVideoCache
- 3.1 基本原理
- 3.2 基本使用
- 3.3 源码分析
一、背景
现在的移动应用,视频是一个非常重要的组成部分,好像里面不搞一点视频就不是一个正常的移动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' | |
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' | |
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替换成下面的方式。
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 { | |
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信号变量。
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; | |
} | |
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在请求时回先使用本地的代理方式,然后开启一系列的缓存逻辑,并在缓存完成后发出通知,当再次请求的时候,如果本地已经进行了文件缓存,就会优先使用本地的数据。