Android邊播放邊緩存視頻框架AndroidVideoCache詳解
一、背景
現(xiàn)在的移動(dòng)應(yīng)用,視頻是一個(gè)非常重要的組成部分,好像里面不搞一點(diǎn)視頻就不是一個(gè)正常的移動(dòng)App。在視頻開(kāi)發(fā)方面,可以分為視頻錄制和視頻播放,視頻錄制的場(chǎng)景可能還比較少,這方面可以使用Google開(kāi)源的 grafika。相比于視頻錄制,視頻播放可以選擇的方案就要多許多,比如Google的 ExoPlayer,B站的 ijkplayer,以及官方的MediaPlayer。
不過(guò),我們今天要講的是視頻的緩存。最近,由于我們?cè)陂_(kāi)發(fā)視頻方面沒(méi)有考慮視頻的緩存問(wèn)題,造成了流量的浪費(fèi),然后遭到用戶(hù)的投訴。在視頻播放中,一般有兩種兩種策略:先下載再播放和邊播放邊緩存。
通常,為了提高用戶(hù)的體驗(yàn),我們會(huì)選擇邊播放邊緩存的策略,不過(guò)市面上大多數(shù)的播放器都是只支持視頻播放,在視頻緩存這塊基本上沒(méi)啥好的方案,比如我們的App使用的是一個(gè)自己封裝的庫(kù),類(lèi)似于PlayerBase。PlayerBase是一種將解碼器和播放視圖組件化處理的解決方案框架,也即是一個(gè)對(duì)ExoPlayer、ijkplayer的包裝庫(kù)。
二、PlayerBase
PlayerBase是一種將解碼器和播放視圖組件化處理的解決方案框架。您需要什么解碼器實(shí)現(xiàn)框架定義的抽象引入即可,對(duì)于視圖,無(wú)論是播放器內(nèi)的控制視圖還是業(yè)務(wù)視圖,均可以做到組件化處理。并且,它支持視頻跨頁(yè)面無(wú)縫銜接的效果,也是我們選擇它的一個(gè)原因。
PlayerBase的使用也比較簡(jiǎn)單,使用的時(shí)候需要單獨(dú)的添加解碼器,具體使用哪種解碼器,可以根據(jù)項(xiàng)目的需要自由的進(jìn)行配置。
只使用MediaPlayer:
dependencies { //該依賴(lài)僅包含MediaPlayer解碼 implementation 'com.kk.taurus.playerbase:playerbase:3.4.2' }
使用ExoPlayer + MediaPlayer
dependencies { //該依賴(lài)包含exoplayer解碼和MediaPlayer解碼 //注意exoplayer的最小支持SDK版本為16 implementation 'cn.jiajunhui:exoplayer:342_2132_019' }
使用ijkplayer + MediaPlayer
dependencies { //該依賴(lài)包含ijkplayer解碼和MediaPlayer解碼 implementation 'cn.jiajunhui:ijkplayer:342_088_012' //ijk官方的解碼庫(kù)依賴(lài),較少格式版本且不支持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 { //該依賴(lài)包含exoplayer解碼和MediaPlayer解碼 //注意exoplayer的最小支持SDK版本為16 implementation 'cn.jiajunhui:exoplayer:342_2132_019' //該依賴(lài)包含ijkplayer解碼和MediaPlayer解碼 implementation 'cn.jiajunhui:ijkplayer:342_088_012' //ijk官方的解碼庫(kù)依賴(lài),較少格式版本且不支持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' }
最后,在進(jìn)行代碼混淆時(shí),還需要在proguard中添加如下混淆規(guī)則。
-keep public class * extends android.view.View{*;} -keep public class * implements com.kk.taurus.playerbase.player.IPlayer{*;}
添加完解碼器之后,接下來(lái)只需要在應(yīng)用的Application中初始化解碼器,然后就可以使用了。
public class App extends Application { @Override public void onCreate() { //... //如果您想使用默認(rèn)的網(wǎng)絡(luò)狀態(tài)事件生產(chǎn)者,請(qǐng)?zhí)砑哟诵信渲谩? //并需要添加權(quán)限 android.permission.ACCESS_NETWORK_STATE PlayerConfig.setUseDefaultNetworkEventProducer(true); //初始化庫(kù) PlayerLibrary.init(this); //如果添加了'cn.jiajunhui:exoplayer:xxxx'該依賴(lài) ExoMediaPlayer.init(this); //如果添加了'cn.jiajunhui:ijkplayer:xxxx'該依賴(lài) IjkPlayer.init(this); //播放記錄的配置 //開(kāi)啟播放記錄 PlayerConfig.playRecord(true); PlayRecordManager.setRecordConfig( new PlayRecordManager.RecordConfig.Builder() .setMaxRecordCount(100) //.setRecordKeyProvider() //.setOnRecordCallBack() .build()); } }
然后,在業(yè)務(wù)代碼中開(kāi)始播放即可。
ListPlayer.get().play(DataSource(url))
不過(guò),有一個(gè)缺點(diǎn)是,PlayerBase并沒(méi)有提供緩存方案,即播放過(guò)的視頻再次播放的時(shí)候還是會(huì)消耗流量,這就違背了我們的設(shè)計(jì)初衷,那有沒(méi)有一種可以支持緩存,同時(shí)對(duì)PlayerBase侵入性比較小的方案呢?答案是有的,那就是AndroidVideoCache。
三、AndroidVideoCache
3.1 基本原理
AndroidVideoCache 通過(guò)代理的策略實(shí)現(xiàn)一個(gè)中間層,然后我們的網(wǎng)絡(luò)請(qǐng)求會(huì)轉(zhuǎn)移到本地實(shí)現(xiàn)的代理服務(wù)器上,這樣我們真正請(qǐng)求的數(shù)據(jù)就會(huì)被代理拿到,接著代理一邊向本地寫(xiě)入數(shù)據(jù),一邊根據(jù)我們需要的數(shù)據(jù)看是讀網(wǎng)絡(luò)數(shù)據(jù)還是讀本地緩存數(shù)據(jù),從而實(shí)現(xiàn)數(shù)據(jù)的復(fù)用。
經(jīng)過(guò)實(shí)際測(cè)試,我發(fā)現(xiàn)它的流程如下:首次使用時(shí)使用的是網(wǎng)絡(luò)的數(shù)據(jù),后面再次使用相同的視頻時(shí)就會(huì)讀取本地的。由于,AndroidVideoCache可以配置緩存文件的大小,所以,再加載視頻前,它會(huì)重復(fù)前面的策略,工作原理圖如下。
3.2 基本使用
和其他的插件使用流程一樣,首先需要我們?cè)陧?xiàng)目中添加AndroidVideoCache依賴(lài)。
dependencies { compile 'com.danikula:videocache:2.7.1' }
然后,在全局初始化一個(gè)本地代理服務(wù)器,我們選擇在Application的實(shí)現(xiàn)類(lèi)中進(jì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)然,初始化的代碼也可以寫(xiě)到其他的地方,比如我們的公共Module。有了代理服務(wù)器之后,我們?cè)谑褂玫牡胤桨丫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ī)則,比如緩存文件的大小、文件的個(gè)數(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方式生成一個(gè)key作為文件的名稱(chēng)。
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還支持添加一個(gè)自定義的HeadersInjector,用來(lái)在請(qǐng)求時(shí)候添加自定義的請(qǐng)求頭。
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 源碼分析
前面我們說(shuō)過(guò),AndroidVideoCache 通過(guò)代理的策略實(shí)現(xiàn)一個(gè)中間層,然后再網(wǎng)絡(luò)請(qǐng)求時(shí)通過(guò)本地代理服務(wù)去實(shí)現(xiàn)真正的請(qǐng)求,這樣操作的好處是不會(huì)產(chǎn)生額外的請(qǐng)求,并且在緩存策略上,AndroidVideoCache使用了LruCache緩存策略算法,不用去手動(dòng)維護(hù)緩存區(qū)的大小,真正做到解放雙手。
首先,我們來(lái)看一下HttpProxyCacheServer類(lèi)。
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)建一個(gè) ServerSocket 并隨機(jī)分配了一個(gè)端口,然后通過(guò) getLocalPort 拿到服務(wù)器端口,用來(lái)和服務(wù)器進(jìn)行通信。接著,創(chuàng)建了一個(gè)線(xiàn)程 WaitRequestsRunnable,里面有一個(gè)startSignal信號(hào)變量。
@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è)代理的流程是,先構(gòu)建一個(gè)全局的本地代理服務(wù)器 ServerSocket,指定一個(gè)隨機(jī)端口,然后新開(kāi)一個(gè)線(xiàn)程,在線(xiàn)程的 run 方法里通過(guò)accept() 方法監(jiān)聽(tīng)服務(wù)器socket的入站連接,accept() 方法會(huì)一直阻塞,直到有一個(gè)客戶(hù)端嘗試建立連接。
有了代碼服務(wù)器之后,接下來(lái)就是客戶(hù)端的Socket。我們先從代理替換url地方開(kāi)始看:
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,并且把時(shí)間更新下,因?yàn)長(zhǎng)ruCache是根據(jù)文件被訪問(wèn)的時(shí)間進(jìn)行排序的,如果文件沒(méi)有被緩存那么就調(diào)用isAlive() 方法,isAlive()方法會(huì)ping一下目標(biāo)url,確保url是一個(gè)有效的。
private boolean isAlive() { return pinger.ping(3, 70); // 70+140+280=max~500ms }
如果用戶(hù)是通過(guò)代理訪問(wèn)的話(huà),就會(huì)ping不通,這樣就還是使用原生的url,最后進(jìn)入appendToProxyUrl ()方法里面。
private String appendToProxyUrl(String url) { return String.format(Locale.US, "http://%s:%d/%s", PROXY_HOST, port, ProxyCacheUtils.encode(url)); }
接著,socket會(huì)被包裹成一個(gè)runnable,發(fā)配給線(xiàn)程池。
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()方法會(huì)處理所有的請(qǐng)求進(jìn)來(lái)的Socket,包括ping的和VideoView.setVideoPath(proxyUrl)的Socket,我們重點(diǎn)看一下 else語(yǔ)句里面的代碼。這里的 getClients()方法里面有一個(gè)ConcurrentHashMap,重復(fù)url返回的是同一個(gè)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; } }
如果是第一次請(qǐng)求的url,HttpProxyCacheServerClients并被put到ConcurrentHashMap中。而真正的網(wǎng)絡(luò)請(qǐng)求都在 processRequest ()方法中進(jìn)行操作,并且需要傳遞過(guò)去一個(gè)GetRequest 對(duì)象,包括是一個(gè)url和rangeoffset以及partial的包裝類(lèi)。
public void processRequest(GetRequest request, Socket socket) throws ProxyCacheException, IOException { startProcessRequest(); try { clientsCount.incrementAndGet(); proxyCache.processRequest(request, socket); } finally { finishProcessRequest(); } }
其中,startProcessRequest 方法會(huì)得到一個(gè)新的HttpProxyCache 類(lèi)對(duì)象。
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)建一個(gè)基于原生url的HttpUrlSource ,這個(gè)類(lèi)對(duì)象負(fù)責(zé)持有url,并開(kāi)啟HttpURLConnection來(lái)獲取一個(gè)InputStream,這樣就可以使用這個(gè)輸入流來(lái)讀取數(shù)據(jù)了,同時(shí)也創(chuàng)建了一個(gè)本地的臨時(shí)文件,一個(gè)以.download結(jié)尾的臨時(shí)文件,這個(gè)文件在成功下載完后的 FileCache 類(lèi)中的 complete 方法中被更名。
執(zhí)行完上面的操作之后,然后這個(gè)HttpProxyCache 對(duì)象就開(kāi)始 調(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); } }
拿到一個(gè)OutputStream的輸出流后,我們就可以往sd卡中寫(xiě)數(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)里面,開(kāi)啟了一個(gè)新的線(xiàn)程sourceReaderThread,其中封裝了一個(gè)SourceReaderRunnable的Runnable,這個(gè)異步線(xiàn)程用來(lái)給cache,也就是本地文件寫(xiě)數(shù)據(jù),同時(shí)還更新一下當(dāng)前的緩存進(jìn)度。
同時(shí),另一個(gè)SourceReaderRunnable線(xiàn)程會(huì)從cache中去讀數(shù)據(jù),在緩存結(jié)束后會(huì)發(fā)送一個(gè)通知通知緩存完了,外界可以去調(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的核心緩存流程就分析完了??偟膩?lái)說(shuō),AndroidVideoCache在請(qǐng)求時(shí)回先使用本地的代理方式,然后開(kāi)啟一系列的緩存邏輯,并在緩存完成后發(fā)出通知,當(dāng)再次請(qǐng)求的時(shí)候,如果本地已經(jīng)進(jìn)行了文件緩存,就會(huì)優(yōu)先使用本地的數(shù)據(jù)。
更多關(guān)于Android 播放緩存視頻框架的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android 動(dòng)態(tài)顯示和隱藏狀態(tài)欄詳解及實(shí)例
這篇文章主要介紹了Android 動(dòng)態(tài)顯示和隱藏狀態(tài)欄的相關(guān)資料,需要的朋友可以參考下2017-06-06Android Studio綁定下拉框數(shù)據(jù)詳解
這篇文章主要為大家詳細(xì)介紹了Android Studio綁定下拉框數(shù)據(jù),Android Studio綁定網(wǎng)絡(luò)JSON數(shù)據(jù),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-10-10Android Studio打包H5網(wǎng)址頁(yè)面,封裝成APK
大家好,本篇文章主要講的是Android Studio打包H5網(wǎng)址頁(yè)面,封裝成APK,感興趣的同學(xué)趕快來(lái)看一看吧,對(duì)你有幫助的話(huà)記得收藏一下,方便下次瀏覽2021-12-12Android List刪除重復(fù)數(shù)據(jù)
這篇文章主要介紹了Android List刪除重復(fù)數(shù)據(jù)的實(shí)例代碼,非常不錯(cuò),具有參考借鑒價(jià)值,需要的朋友參考下吧2017-06-06Android音樂(lè)播放器制作 點(diǎn)擊歌曲實(shí)現(xiàn)播放(二)
這篇文章主要為大家詳細(xì)介紹了Android音樂(lè)播放器的制作方法,點(diǎn)擊歌曲實(shí)現(xiàn)播放,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-02-02Android實(shí)現(xiàn)動(dòng)畫(huà)效果的自定義下拉菜單功能
這篇文章主要介紹了Android實(shí)現(xiàn)動(dòng)畫(huà)效果的自定義下拉菜單功能,本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2021-02-02android studio 4.0 新建類(lèi)沒(méi)有修飾符的方法
這篇文章主要介紹了android studio 4.0 新建類(lèi)沒(méi)有修飾符的方法,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2020-10-10Android實(shí)現(xiàn)USB掃碼槍獲取掃描內(nèi)容
這篇文章主要為大家詳細(xì)介紹了Android實(shí)現(xiàn)USB掃碼槍獲取掃描內(nèi)容,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-09-09