Android邊播放邊緩存視頻框架AndroidVideoCache詳解
一、背景
現(xiàn)在的移動應(yīng)用,視頻是一個非常重要的組成部分,好像里面不搞一點視頻就不是一個正常的移動App。在視頻開發(fā)方面,可以分為視頻錄制和視頻播放,視頻錄制的場景可能還比較少,這方面可以使用Google開源的 grafika。相比于視頻錄制,視頻播放可以選擇的方案就要多許多,比如Google的 ExoPlayer,B站的 ijkplayer,以及官方的MediaPlayer。
不過,我們今天要講的是視頻的緩存。最近,由于我們在開發(fā)視頻方面沒有考慮視頻的緩存問題,造成了流量的浪費,然后遭到用戶的投訴。在視頻播放中,一般有兩種兩種策略:先下載再播放和邊播放邊緩存。
通常,為了提高用戶的體驗,我們會選擇邊播放邊緩存的策略,不過市面上大多數(shù)的播放器都是只支持視頻播放,在視頻緩存這塊基本上沒啥好的方案,比如我們的App使用的是一個自己封裝的庫,類似于PlayerBase。PlayerBase是一種將解碼器和播放視圖組件化處理的解決方案框架,也即是一個對ExoPlayer、ijkplayer的包裝庫。
二、PlayerBase
PlayerBase是一種將解碼器和播放視圖組件化處理的解決方案框架。您需要什么解碼器實現(xiàn)框架定義的抽象引入即可,對于視圖,無論是播放器內(nèi)的控制視圖還是業(yè)務(wù)視圖,均可以做到組件化處理。并且,它支持視頻跨頁面無縫銜接的效果,也是我們選擇它的一個原因。
PlayerBase的使用也比較簡單,使用的時候需要單獨的添加解碼器,具體使用哪種解碼器,可以根據(jù)項目的需要自由的進行配置。
只使用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中添加如下混淆規(guī)則。
-keep public class * extends android.view.View{*;}
-keep public class * implements com.kk.taurus.playerbase.player.IPlayer{*;}添加完解碼器之后,接下來只需要在應(yīng)用的Application中初始化解碼器,然后就可以使用了。
public class App extends Application {
@Override
public void onCreate() {
//...
//如果您想使用默認(rèn)的網(wǎng)絡(luò)狀態(tài)事件生產(chǎn)者,請?zhí)砑哟诵信渲谩?
//并需要添加權(quán)限 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());
}
}然后,在業(yè)務(wù)代碼中開始播放即可。
ListPlayer.get().play(DataSource(url))
不過,有一個缺點是,PlayerBase并沒有提供緩存方案,即播放過的視頻再次播放的時候還是會消耗流量,這就違背了我們的設(shè)計初衷,那有沒有一種可以支持緩存,同時對PlayerBase侵入性比較小的方案呢?答案是有的,那就是AndroidVideoCache。
三、AndroidVideoCache
3.1 基本原理
AndroidVideoCache 通過代理的策略實現(xiàn)一個中間層,然后我們的網(wǎng)絡(luò)請求會轉(zhuǎn)移到本地實現(xiàn)的代理服務(wù)器上,這樣我們真正請求的數(shù)據(jù)就會被代理拿到,接著代理一邊向本地寫入數(shù)據(jù),一邊根據(jù)我們需要的數(shù)據(jù)看是讀網(wǎng)絡(luò)數(shù)據(jù)還是讀本地緩存數(shù)據(jù),從而實現(xiàn)數(shù)據(jù)的復(fù)用。
經(jīng)過實際測試,我發(fā)現(xiàn)它的流程如下:首次使用時使用的是網(wǎng)絡(luò)的數(shù)據(jù),后面再次使用相同的視頻時就會讀取本地的。由于,AndroidVideoCache可以配置緩存文件的大小,所以,再加載視頻前,它會重復(fù)前面的策略,工作原理圖如下。

3.2 基本使用
和其他的插件使用流程一樣,首先需要我們在項目中添加AndroidVideoCache依賴。
dependencies {
compile 'com.danikula:videocache:2.7.1'
}然后,在全局初始化一個本地代理服務(wù)器,我們選擇在Application的實現(xiàn)類中進行全局初始化。
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);
}
}當(dāng)然,初始化的代碼也可以寫到其他的地方,比如我們的公共Module。有了代理服務(wù)器之后,我們在使用的地方把網(wǎng)絡(luò)視頻url替換成下面的方式。
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
HttpProxyCacheServer proxy = getProxy();
String proxyUrl = proxy.getProxyUrl(VIDEO_URL);
videoView.setVideoPath(proxyUrl);
}當(dāng)然,AndroidVideoCache還提供了很多的自定義規(guī)則,比如緩存文件的大小、文件的個數(shù),以及緩存位置等。
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;
}當(dāng)然,我們還可以使用的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 通過代理的策略實現(xiàn)一個中間層,然后再網(wǎng)絡(luò)請求時通過本地代理服務(wù)去實現(xiàn)真正的請求,這樣操作的好處是不會產(chǎn)生額外的請求,并且在緩存策略上,AndroidVideoCache使用了LruCache緩存策略算法,不用去手動維護緩存區(qū)的大小,真正做到解放雙手。
首先,我們來看一下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);
}
}
}可以看到,構(gòu)造函數(shù)首先使用本地的localhost地址,創(chuàng)建一個 ServerSocket 并隨機分配了一個端口,然后通過 getLocalPort 拿到服務(wù)器端口,用來和服務(wù)器進行通信。接著,創(chuàng)建了一個線程 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));
}
}服務(wù)器的整個代理的流程是,先構(gòu)建一個全局的本地代理服務(wù)器 ServerSocket,指定一個隨機端口,然后新開一個線程,在線程的 run 方法里通過accept() 方法監(jiān)聽服務(wù)器socket的入站連接,accept() 方法會一直阻塞,直到有一個客戶端嘗試建立連接。
有了代碼服務(wù)器之后,接下來就是客戶端的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的核心的功能:如果本地已經(jīng)緩存了,就直接使用本地的Uri,并且把時間更新下,因為LruCache是根據(jù)文件被訪問的時間進行排序的,如果文件沒有被緩存那么就調(diào)用isAlive() 方法,isAlive()方法會ping一下目標(biāo)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,發(fā)配給線程池。
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,重復(fù)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中。而真正的網(wǎng)絡(luò)請求都在 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;
}此處,我們構(gòu)建一個基于原生url的HttpUrlSource ,這個類對象負(fù)責(zé)持有url,并開啟HttpURLConnection來獲取一個InputStream,這樣就可以使用這個輸入流來讀取數(shù)據(jù)了,同時也創(chuàng)建了一個本地的臨時文件,一個以.download結(jié)尾的臨時文件,這個文件在成功下載完后的 FileCache 類中的 complete 方法中被更名。
執(zhí)行完上面的操作之后,然后這個HttpProxyCache 對象就開始 調(diào)用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卡中寫數(shù)據(jù)了,如果不用緩存就走常規(guī)邏輯,這里我們只看走緩存的邏輯,即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循環(huán)里面,開啟了一個新的線程sourceReaderThread,其中封裝了一個SourceReaderRunnable的Runnable,這個異步線程用來給cache,也就是本地文件寫數(shù)據(jù),同時還更新一下當(dāng)前的緩存進度。
同時,另一個SourceReaderRunnable線程會從cache中去讀數(shù)據(jù),在緩存結(jié)束后會發(fā)送一個通知通知緩存完了,外界可以去調(diào)用了。
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的核心緩存流程就分析完了??偟膩碚f,AndroidVideoCache在請求時回先使用本地的代理方式,然后開啟一系列的緩存邏輯,并在緩存完成后發(fā)出通知,當(dāng)再次請求的時候,如果本地已經(jīng)進行了文件緩存,就會優(yōu)先使用本地的數(shù)據(jù)。
更多關(guān)于Android 播放緩存視頻框架的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android 動態(tài)顯示和隱藏狀態(tài)欄詳解及實例
這篇文章主要介紹了Android 動態(tài)顯示和隱藏狀態(tài)欄的相關(guān)資料,需要的朋友可以參考下2017-06-06
Android Studio綁定下拉框數(shù)據(jù)詳解
這篇文章主要為大家詳細(xì)介紹了Android Studio綁定下拉框數(shù)據(jù),Android Studio綁定網(wǎng)絡(luò)JSON數(shù)據(jù),具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-10-10
Android Studio打包H5網(wǎng)址頁面,封裝成APK
大家好,本篇文章主要講的是Android Studio打包H5網(wǎng)址頁面,封裝成APK,感興趣的同學(xué)趕快來看一看吧,對你有幫助的話記得收藏一下,方便下次瀏覽2021-12-12
Android List刪除重復(fù)數(shù)據(jù)
這篇文章主要介紹了Android List刪除重復(fù)數(shù)據(jù)的實例代碼,非常不錯,具有參考借鑒價值,需要的朋友參考下吧2017-06-06
Android音樂播放器制作 點擊歌曲實現(xiàn)播放(二)
這篇文章主要為大家詳細(xì)介紹了Android音樂播放器的制作方法,點擊歌曲實現(xiàn)播放,具有一定的參考價值,感興趣的小伙伴們可以參考一下2017-02-02
android studio 4.0 新建類沒有修飾符的方法
這篇文章主要介紹了android studio 4.0 新建類沒有修飾符的方法,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-10-10
Android實現(xiàn)USB掃碼槍獲取掃描內(nèi)容
這篇文章主要為大家詳細(xì)介紹了Android實現(xiàn)USB掃碼槍獲取掃描內(nèi)容,文中示例代碼介紹的非常詳細(xì),具有一定的參考價值,感興趣的小伙伴們可以參考一下2021-09-09

