开源视频播放框架学习——AndroidVideoCache
github:AndroidVideoCache
该框架的思想就是在本地构建一个ServerSocket作为代理服务器,将对Mp4地址进行封装,从而拦截到本地ServerSocket,拦截之后解析出url和请求头进行真正的网络请求。而视频播放控件例如MediaPlay、VideoView就相当于客户端了,最后将真正的网络请求通过Socket的方式写入到客户端,这样视频控件就可以播放了。
听不懂没关系,看原理图:
UML图:
具体如何使用可以直接看github上面的连接,作者讲的很详细,本篇文章主要是分析一下源码。
原理图看不懂没关系,看代码:
Builder类
public static final class Builder {
private static final long DEFAULT_MAX_SIZE = 512 * 1024 * 1024;
private File cacheRoot;
private FileNameGenerator fileNameGenerator;
private DiskUsage diskUsage;
private SourceInfoStorage sourceInfoStorage;
private HeaderInjector headerInjector;
public Builder(Context context) {
this.sourceInfoStorage = SourceInfoStorageFactory.newSourceInfoStorage(context);
this.cacheRoot = StorageUtils.getIndividualCacheDirectory(context);
this.diskUsage = new TotalSizeLruDiskUsage(DEFAULT_MAX_SIZE);
this.fileNameGenerator = new Md5FileNameGenerator();
this.headerInjector = new EmptyHeadersInjector();
}
}
- StorageUtils.getIndividualCacheDirectory(context):主要是视频文件下载的目录,这里作者分析的很全面,以后写项目可以应用到。
- SourceInfoStorageFactory.newSourceInfoStorage(context);:主要是数据库,对url对应的信息进行存储,包括url地址、视频的长度、mime信息。
这里分两种url:
a:一种是作为ping的url,类似于心跳包。格式为:http://127.0.01:port/ping 主要是查看本地是否能ping通。
b:另一种是作为真正的网络请求的url,也就是拦截的url。格式为: http://127.0.01:port/url。 - TotalSizeLruDiskUsage:主要对文件进行lrucache算法,将下载的视频进行lastmodify排序,当超过规定的内存大小之后,对之前下载的视频进行删除操作。
- Md5FileNameGenerator:对视频的url进行md5算法,用于生成文件名。
- EmptyHeadersInjector:用于添加请求的header。
设置直连代理
private static final String PROXY_HOST = "127.0.0.1";
InetAddress inetAddress = InetAddress.getByName(PROXY_HOST);
this.serverSocket = new ServerSocket(0, 8, inetAddress);
this.port = serverSocket.getLocalPort();
IgnoreHostProxySelector.install(PROXY_HOST, port);
定义一个host:127.0.0.1
并且生成一个匿名端口号
class IgnoreHostProxySelector extends ProxySelector {
private static final List<Proxy> NO_PROXY_LIST = Arrays.asList(Proxy.NO_PROXY);
private final ProxySelector defaultProxySelector;
private final String hostToIgnore;
private final int portToIgnore;
IgnoreHostProxySelector(ProxySelector defaultProxySelector, String hostToIgnore, int portToIgnore) {
this.defaultProxySelector = checkNotNull(defaultProxySelector);
this.hostToIgnore = checkNotNull(hostToIgnore);
this.portToIgnore = portToIgnore;
}
static void install(String hostToIgnore, int portToIgnore) {
ProxySelector defaultProxySelector = ProxySelector.getDefault();
ProxySelector ignoreHostProxySelector = new IgnoreHostProxySelector(defaultProxySelector, hostToIgnore, portToIgnore);
ProxySelector.setDefault(ignoreHostProxySelector);
}
@Override
public List<Proxy> select(URI uri) {
boolean ignored = hostToIgnore.equals(uri.getHost()) && portToIgnore == uri.getPort();
return ignored ? NO_PROXY_LIST : defaultProxySelector.select(uri);
}
@Override
public void connectFailed(URI uri, SocketAddress address, IOException failure) {
defaultProxySelector.connectFailed(uri, address, failure);
}
}
如果对ProxySelector不了解可以参考 Java 通过Proxy和ProxySelector实现代理访问网络
主要是选择判断选择的代理,如果是以127.0.0.1并且是ServerSocket生成的端口号则采用直连的方式,其他的url和端口号使用系统默认的请求代理。
生成客户端Socket
this.waitConnectionThread = new Thread(new WaitRequestsRunnable(startSignal));
this.waitConnectionThread.start();
private final class WaitRequestsRunnable implements Runnable {
private final CountDownLatch startSignal;
public WaitRequestsRunnable(CountDownLatch startSignal) {
this.startSignal = startSignal;
}
@Override
public void run() {
startSignal.countDown();
waitForRequest();
}
}
}
private void waitForRequest() {
try {
while (!Thread.currentThread().isInterrupted()) {
Socket socket = serverSocket.accept();
socketProcessor.submit(new SocketProcessorRunnable(socket));
}
} catch (IOException e) {
onError(new ProxyCacheException("Error during waiting connection", e));
}
}
生成了一个Socket,用于将视频的流写入到客户端(所谓的MediaPlayer、VideoView)
查看本地心跳包是否ping通
当我们使用这个框架时作者提供了一个这个方法 String proxyUrl = proxy.getProxyUrl(url);
videoView.setVideoPath(proxyUrl);
videoView.start();
生成了一个代理url
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;
}
进入到这个方法,首先查看这个文件是否下载完毕,如果下载完毕就返回本地的url地址,可以离线播放。
如果本地文件没有下载完毕,则进入isAlive()
boolean ping(int maxAttempts, int startTimeout) {
checkArgument(maxAttempts >= 1);
checkArgument(startTimeout > 0);
int timeout = startTimeout;
int attempts = 0;
while (attempts < maxAttempts) {
try {
Future<Boolean> pingFuture = pingExecutor.submit(new PingCallable());
boolean pinged = pingFuture.get(timeout, MILLISECONDS);
if (pinged) {
return true;
}
} catch (TimeoutException e) {
LOG.warn("Error pinging server (attempt: " + attempts + ", timeout: " + timeout + "). ");
} catch (InterruptedException | ExecutionException e) {
LOG.error("Error pinging server due to unexpected error", e);
}
attempts++;
timeout *= 2;
}
String error = String.format(Locale.US, "Error pinging server (attempts: %d, max timeout: %d). " +
"If you see this message, please, report at https://github.com/danikula/AndroidVideoCache/issues/134. " +
"Default proxies are: %s"
, attempts, timeout / 2, getDefaultProxies());
LOG.error(error, new ProxyCacheException(error));
return false;
}
首先利用pingExecutor开辟了一个线程池
private class PingCallable implements Callable<Boolean> {
@Override
public Boolean call() throws Exception {
return pingServer();
}
}
private boolean pingServer() throws ProxyCacheException {
String pingUrl = getPingUrl();
HttpUrlSource source = new HttpUrlSource(pingUrl);
try {
byte[] expectedResponse = PING_RESPONSE.getBytes();
source.open(0);
byte[] response = new byte[expectedResponse.length];
source.read(response);
boolean pingOk = Arrays.equals(expectedResponse, response);
LOG.info("Ping response: `" + new String(response) + "`, pinged? " + pingOk);
return pingOk;
} catch (ProxyCacheException e) {
LOG.error("Error reading ping response", e);
return false;
} finally {
source.close();
}
}
这里getPingUrl()
生成了一个http://127.0.0.1:port/ping的url地址。
接着创建了一个HttpUrlSource的对象,这个对象主要是处理网络请求有关的。
跳入了一个只有url参数的构造函数
public HttpUrlSource(String url) {
this(url, SourceInfoStorageFactory.newEmptySourceInfoStorage());
}
注意这里面使用的是SourceInfoStorageFactory.newEmptySourceInfoStorage()
public static SourceInfoStorage newEmptySourceInfoStorage() {
return new NoSourceInfoStorage();
}
public class NoSourceInfoStorage implements SourceInfoStorage {
@Override
public SourceInfo get(String url) {
return null;
}
@Override
public void put(String url, SourceInfo sourceInfo) {
}
@Override
public void release() {
}
}
这个数据库对象是一个空实现,也就是对http://127.0.0.1:port/ping这种连接的地址没有必要去存储他们的信息。 byte[] expectedResponse = PING_RESPONSE.getBytes();
生成了ping ok的字节数组 source.open(0);
打开一个HttpURLConnection。
具体代码:
@Override
public void open(long offset) throws ProxyCacheException {
try {
connection = openConnection(offset, -1);
String mime = connection.getContentType();
inputStream = new BufferedInputStream(connection.getInputStream(), DEFAULT_BUFFER_SIZE);
long length = readSourceAvailableBytes(connection, offset, connection.getResponseCode());
this.sourceInfo = new SourceInfo(sourceInfo.url, length, mime);
this.sourceInfoStorage.put(sourceInfo.url, sourceInfo);
} catch (IOException e) {
throw new ProxyCacheException("Error opening connection for " + sourceInfo.url + " with offset " + offset, e);
}
}
private HttpURLConnection openConnection(long offset, int timeout) throws IOException, ProxyCacheException {
HttpURLConnection connection;
boolean redirected;
int redirectCount = 0;
String url = this.sourceInfo.url;
System.out.println("OOOOOurl = " + url);
do {
LOG.debug("Open connection " + (offset > 0 ? " with offset " + offset : "") + " to " + url);
connection = (HttpURLConnection) new URL(url).openConnection();
injectCustomHeaders(connection, url);
if (offset > 0) {
connection.setRequestProperty("Range", "bytes=" + offset + "-");
}
if (timeout > 0) {
connection.setConnectTimeout(timeout);
connection.setReadTimeout(timeout);
}
int code = connection.getResponseCode();
System.out.println("offset = " + offset);
redirected = code == HTTP_MOVED_PERM || code == HTTP_MOVED_TEMP || code == HTTP_SEE_OTHER;
if (redirected) {
url = connection.getHeaderField("Location");
redirectCount++;
connection.disconnect();
}
if (redirectCount > MAX_REDIRECTS) {
throw new ProxyCacheException("Too many redirects: " + redirectCount);
}
} while (redirected);
return connection;
}
上面的代码得到HttpURLConnection,通过offset设置请求的Rang,通过timeout设置超时的时间,得到输入流inputStream,视频的mime,视频的长度。并将请求的url、视频长度、mime封装成SourceInfo对象,存储到数据库之中。
注意的是:long contentLength = getContentLength(connection);
Content-Length返回的是响应头中返回的长度,不一定等价于整个视频的长度。
本地代理Socket进行Write写入字节流
前面提到过这样的代码
private void waitForRequest() {
try {
while (!Thread.currentThread().isInterrupted()) {
Socket socket = serverSocket.accept();
socketProcessor.submit(new SocketProcessorRunnable(socket));
}
} catch (IOException e) {
onError(new ProxyCacheException("Error during waiting connection", e));
}
}
ServerSocket调用了accept方法则一直阻塞,直到有新的客户端接入才生成Socket。
ServerSocket的构造采用127.0.0.1和匿名端口号。
而在上面我们生成了一个http://127.0.01:port/ping的连接,去生成了一个HttpURLConnection,并且调用了getResponseCode()方法得到响应码,这时候就相当于发出一个请求,同时accept就会唤醒,生成一个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);
System.out.println("clients = " + clients + " socket = " + socket);
clients.processRequest(request, socket);
}
} catch (SocketException e) {
// There is no way to determine that client closed connection http://*.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);
System.out.println("Opend Connections: = " + getClientsCount());
}
}
boolean isPingRequest(String request) {
return PING_REQUEST.equals(request);
}
void responseToPing(Socket socket) throws IOException {
OutputStream out = socket.getOutputStream();
out.write("HTTP/1.1 200 OK\n\n".getBytes());
out.write(PING_RESPONSE.getBytes());
}
这时候就会按照如上流程去运行,最后 out.write("HTTP/1.1 200 OK\n\n".getBytes());
写入响应头
out.write(PING_RESPONSE.getBytes());
然后
source.read(response);
boolean pingOk = Arrays.equals(expectedResponse, response);
LOG.info("Ping response: `" + new String(response) + "`, pinged? " + pingOk);
return pingOk;
发现相等,返回true,这时证明本地可以Ping通。
视频Url请求
当isAlive()返回true的时候,证明本地的代理可以Ping通。
这时候会运行appendToProxyUrl(url)
方法,跟上面相似,也是将一个url转换成为http://127.0.0.1:port/url 但是这个url是真正的视频网络请求地址。
当得到这个地址之后,只需要videoView.setVideoPath(proxyUrl);
即可。
后面的事情都是由播放器(MediaPlayer、VideoView:称他们为客户端)来处理。
你可以放入一个视频地址,用Fildder进行抓包,你就能清楚的查看到,播放一个视频地址,播放器会发出很多次的视频请求,多个请求头,多个响应头。
当每次发送请求的时候ServerSocket的accept方法都会被唤醒,从而生成一个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://*.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);
System.out.println("Opend Connections: = " + getClientsCount());
}
}
当视频发送请求的时候会走else里面的方法。 HttpProxyCacheServerClients clients = getClients(url);
得到一个HttpProxyCacheServerClients对象。
public void processRequest(GetRequest request, Socket socket) throws ProxyCacheException, IOException {
startProcessRequest();
try {
clientsCount.incrementAndGet();
proxyCache.processRequest(request, socket);
} finally {
finishProcessRequest();
}
}
private synchronized void startProcessRequest() throws ProxyCacheException {
proxyCache = proxyCache == null ? newHttpProxyCache() : proxyCache;
}
private HttpProxyCache newHttpProxyCache() throws ProxyCacheException {
HttpUrlSource source = new HttpUrlSource(url, config.sourceInfoStorage, config.headerInjector);
FileCache cache = new FileCache(config.generateCacheFile(url), config.diskUsage);
HttpProxyCache httpProxyCache = new HttpProxyCache(source, cache);
httpProxyCache.registerCacheListener(uiCacheListener);
return httpProxyCache;
}
得到一个HttpProxyCache对象,里面引用了HttpUrlSource和FileCache对象。
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对象
private String newResponseHeaders(GetRequest request) throws IOException, ProxyCacheException {
String mime = source.getMime();
boolean mimeKnown = !TextUtils.isEmpty(mime);
long length = cache.isCompleted() ? cache.available() : source.length();
boolean lengthKnown = length >= 0;
long contentLength = request.partial ? length - request.rangeOffset : length;
boolean addRange = lengthKnown && request.partial;
return new StringBuilder()
.append(request.partial ? "HTTP/1.1 206 PARTIAL CONTENT\n" : "HTTP/1.1 200 OK\n")
.append("Accept-Ranges: bytes\n")
.append(lengthKnown ? format("Content-Length: %d\n", contentLength) : "")
.append(addRange ? format("Content-Range: bytes %d-%d/%d\n", request.rangeOffset, length - 1, length) : "")
.append(mimeKnown ? format("Content-Type: %s\n", mime) : "")
.append("\n") // headers end
.toString();
}
newResponseHeaders方法主要是根据视频的请求头,设置对应的响应头。
跟前面检查心跳包一个概念。
private boolean isUseCache(GetRequest request) throws ProxyCacheException {
long sourceLength = source.length();
boolean sourceLengthKnown = sourceLength > 0;
long cacheAvailable = cache.available();
// do not use cache for partial requests which too far from available cache. It seems user seek video.
boolean j = !sourceLengthKnown || !request.partial || request.rangeOffset <= cacheAvailable + sourceLength * NO_CACHE_BARRIER;
return j;
}
这个方法是判断是否使用缓存,当不使用缓存的时候会运行:
private void responseWithoutCache(OutputStream out, long offset) throws ProxyCacheException, IOException {
HttpUrlSource newSourceNoCache = new HttpUrlSource(this.source);
try {
boolean flag = false;
newSourceNoCache.open((int) offset);
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
int readBytes;
while ((readBytes = newSourceNoCache.read(buffer)) != -1) {
out.write(buffer, 0, readBytes);
offset += readBytes;
}
out.flush();
flag = true;
} finally {
newSourceNoCache.close();
}
}
当不使用缓存的时候,进行真正的网络请求后直接写入到Socket中。
我们重点看一下使用缓存的代码
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(offset);
waitForSourceData();
checkReadSourceErrorsCount();
}
int read = cache.read(buffer, offset, length);
if (cache.isCompleted() && percentsAvailable != 100) {
percentsAvailable = 100;
onCachePercentsAvailableChanged(100);
}
return read;
}
private void readSource() {
long sourceAvailable = -1;
long 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();
} catch (Throwable e) {
readSourceErrorsCount.incrementAndGet();
onError(e);
} finally {
closeSource();
notifyNewCacheDataAvailable(offset, sourceAvailable);
}
}
这里read用于读取远程文件返回输入流,appendwrite用于将流写入文件中。 int read = cache.read(buffer, offset, length);
再从文件中读取流,然后在用OutputStream的write将文件流写入Socket,这样视频就可以播放了。
微信公众号:
QQ群:365473065