如今更新文章的速度愈來愈慢了......😄,就是太懶了......html
由於最近項目正好涉及到音樂播放器的音頻緩存,固然咱們要作的第一步固然是百度或者谷歌經常使用的緩存庫,起碼我是不肯意本身寫的,多麻煩!!! 百度之後:git
出來的結果都是關於這個庫的,恩,那就直接用這個庫了: AndroidVideoCachegithub
固然咱們雖然不去本身寫,簡單使用,可是咱們仍是要懂原理,這個可不能偷懶,並且看了源碼也方便咱們本身去針對性定製化。web
咱們先來看簡單的使用:緩存
//'通常咱們會在Application裏面初始化, 主要的目的是爲了經過單例模式使用HttpProxyCacheServer:'
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() {
//'咱們實例化HttpProxyCacheServer的對象'
return new HttpProxyCacheServer(this);
}
}
//'而後業務代碼中使用,把原始的url傳入,得到到通過代理的url'
HttpProxyCacheServer proxy = getProxy();
String proxyUrl = proxy.getProxyUrl(url);
//'把代理url給你的代碼使用:'
//好比給exoplayer使用
MediaSource mediaSource = buildMediaSource(Uri.parse(proxyUrl));
mExoPlayer.prepare(mediaSource);
//好比給videoView使用
videoView.setVideoPath(proxyUrl);
複製代碼
沒錯,就是這麼簡單,你只要傳的url每次都是同樣的,就能夠直接去獲取本地緩存文件,而後去播放,不須要再去浪費流量請求了。bash
須要定製化的地方:服務器
可是咱們能夠看到咱們只傳入了一個url,說明內部是把url做爲Key去獲取對應的內容的。通常的歌曲都沒問題,好比你要播放XXX歌,獲取大家公司的服務器的某個內容,url通常不會改變,可是咱們公司和和其餘公司音樂CP方合做,因此獲取具體的音樂的url每次獲取都會改變,就是同一首歌,相隔一秒請求,請求的播放歌曲的url也會改變,好比http://歌曲id號/23213213213123/xxxx.mp3
,好比中間的數字是根據請求的時間的時間戳拼接返回的,因此每次都會不一樣,這時候你直接使用這個庫,就會出問題,你會發現緩存沒有任何軟用。websocket
因此針對這種狀況:cookie
http://歌曲id號
),這樣去比較本地是否有緩存文件的時候,就不會有問題了。無論用哪一種,咱們均可以藉機去了解源碼。網絡
咱們看到了咱們要使用這個庫,其實就是實例化這個HttpProxyCacheServer
類,而後使用它的getProxyUrl
方法獲取代理url
便可。
那咱們就知道了這個是代理總的管理類了。咱們看下實例化它對象的時候,內部有什麼:
public HttpProxyCacheServer(Context context) {
//'默認使用特定的Config配置'
this(new Builder(context).buildConfig());
}
private HttpProxyCacheServer(Config config) {
//'Config配置'
this.config = checkNotNull(config);
try {
//'建立了本地的ServerSocket,做爲中轉做用'
InetAddress inetAddress = InetAddress.getByName(PROXY_HOST);
this.serverSocket = new ServerSocket(0, 8, inetAddress);
this.port = serverSocket.getLocalPort();
//'建立代理選擇器,從而若是是其餘網絡請求,繼續通過原來的代理,若是是本身設置的ServerSocker,則不通過代理'
IgnoreHostProxySelector.install(PROXY_HOST, port);
CountDownLatch startSignal = new CountDownLatch(1);
//'啓動了本地的ServerSocket,開啓接受外部訪問的Socket'
this.waitConnectionThread = new Thread(new WaitRequestsRunnable(startSignal));
this.waitConnectionThread.start();
startSignal.await(); // freeze thread, wait for server starts
//'自定義的Pinger類主要用來等會模擬訪問本地ServerSocket請求,確保請求網絡沒問題'
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);
}
}
複製代碼
題外話開始!!:
不少人和可能對於ServerSocket , ProxySelector等都比較迷糊,由於不少人網絡請求直接使用了Okhttp等直接封裝好的東西,因此對於Socket,Proxy/ProxySelector等反而比較模糊。
對於網絡基礎能夠看我之前寫的文章:
Android技能樹 — 網絡小結(3)之HTTP/HTTPS
Android技能樹 — 網絡小結(4)之socket/websocket/webservice
相關網絡知識點小結- cookie/session/token(待寫)
Android技能樹 — 網絡小結(6)之 OkHttp超超超超超超超詳細解析
Android技能樹 — 網絡小結(7)之 Retrofit源碼詳細解析
當前簡單的想知道 Socket和ServerSocket和兩者的使用,也能夠看下面這篇:
其中代理相關的Proxy和ProxySelector,能夠看下面這篇:
代理服務器:Proxy(代理鏈接)、ProxySelector(自動代理選擇器)、默認代理選擇器
題外話結束!!
咱們來看下Config都包含了什麼:
class Config {
//緩存文件的目錄
public final File cacheRoot;
//緩存文件的命名
public final FileNameGenerator fileNameGenerator;
//磁盤使用統計類(好比設置的緩存文件數仍是緩存文件空間)
public final DiskUsage diskUsage;
//存了資源的相關信息:url/length/mime
public final SourceInfoStorage sourceInfoStorage;
//網絡請求,能夠插入Header信息
public final HeaderInjector headerInjector;
......
......
......
}
複製代碼
咱們看了實例化對象,接下去就是經過調用getProxyUrl獲取代理url了,咱們看下具體作了什麼事。
public String getProxyUrl(String url) {
return getProxyUrl(url, true);
}
public String getProxyUrl(String url, boolean allowCachedFileUri) {
//'Boolean參數是否容許緩存 && isCached判斷傳入的url是否已經有緩存了'
if (allowCachedFileUri && isCached(url)) {
//'獲取緩存文件'
File cacheFile = getCacheFile(url);
//'由於取了這個緩存文件,因此把這個緩存文件的修改時間改成當前時間,'
//'同時對全部的緩存文件從新根據修改時間進行排序'
touchFileSafely(cacheFile);
//'把本地文件的url返回給上層使用'
return Uri.fromFile(cacheFile).toString();
}
//'isAlive()的做用是建立http://127.0.0.1:port/ping 網絡請求,判斷本地的ServerSocket是否還能鏈接訪問, //若是能夠鏈接,就把傳進來的url變爲代理服務器ServerSokcet的url (http://127.0.0.1:port/[url]), //若是本地ServerSocket已經掛了,就直接把原來的url返回給上層'
return isAlive() ? appendToProxyUrl(url) : url;
}
複製代碼
這裏要注意一個小細節,就是
appendToProxyUrl
裏面不是單純的把咱們實際的url拼接在http://127.0.0.1:port
後面。好比咱們的實際url是http://xxxxxx.mp3
,不是拼接成:http://127.0.0.1:port/http://xxxxx.mp3
,這樣一看這個網址就是有問題的。而是把咱們的實際url先經過utf-8轉移成其餘字符:URLEncoder.encode(url, "utf-8");
而後拼接上去,最後結果爲:http://127.0.0.1:50544/http%3A%2F%2Fxxxxxx.mp3
。要使用實際url的時候,拿出來再反過來解析就行:URLDecoder.decode(url, "utf-8");
那咱們確定着重看下第二種狀況,也就是本地沒有緩衝,你這個url是第一次傳進來的時候的狀況。
咱們上面已經經過getProxyUrl
獲取到了新的而且過的url: http://127.0.0.1:port/[實際訪問的url]
, 這時候咱們用ExoPlayer 或者VideoView等去訪問這個網址的時候,變成了訪問本地服務器ServerSocket
了(本地ServerSocket
就是建立的127.0.0.1
)。
咱們來詳細看ServerSocket
接受請求的相關代碼:
private void waitForRequest() {
try {
while (!Thread.currentThread().isInterrupted()) {
//'咱們拿到的proxyUrl訪問後,ServerSocket接受到了咱們的請求Socket'
Socket socket = serverSocket.accept();
//'咱們能夠看到拿着咱們的請求Socket去執行Runnable'
socketProcessor.submit(new SocketProcessorRunnable(socket));
}
} catch (IOException e) {
onError(new ProxyCacheException("Error during waiting connection", e));
}
}
複製代碼
咱們來看這個Runaable
作了什麼:
private final class SocketProcessorRunnable implements Runnable {
private final Socket socket;
public SocketProcessorRunnable(Socket socket) {
this.socket = socket;
}
@Override
public void run() {
//'這個Runnable執行了下面的processSocket方法'
processSocket(socket);
}
}
private void processSocket(Socket socket) {
try {
//'獲取咱們的Socket的InputStream,而後傳入獲取GetRequest對象'
GetRequest request = GetRequest.read(socket.getInputStream());
//'獲取到咱們的Socket請求的url: http://127.0.0.1:port/[實際訪問的url]'
String url = ProxyCacheUtils.decode(request.uri);
//'這個url是不是用來ping的請求地址 (是否記得咱們前面isAlive()方法,ping一下本地ServerSocket,看是否存活)'
if (pinger.isPingRequest(url)) {
//'若是隻是簡單的ping的請求,就簡單的處理回覆'
pinger.responseToPing(socket);
} else {
//'進入這裏,說明這個url是http://127.0.0.1:port/[實際訪問的url], 咱們根據url來獲取HttpProxyCacheServerClients對象,而後執行接下去的步驟'
HttpProxyCacheServerClients clients = getClients(url);
clients.processRequest(request, socket);
}
} catch (SocketException e) {
......
......
} catch (ProxyCacheException | IOException e) {
......
......
} finally {
//'釋放咱們的請求Socket'
releaseSocket(socket);
}
}
複製代碼
咱們分步來看上面的操做後,確定有這些疑問:
在分析接下去流程以前,咱們先來看看這二個類是作什麼的。
'假設實際的網絡請求是 http://xxxxxx.mp3 代理後的是http://127.0.0.1:50544/http%3A%2F%2Fxxxxxx.mp3'
//'類的代碼不多,其實就是咱們的Socket請求本地ServerSocket的時候, //獲取到的請求Socket的InputStream中能夠讀取到如下的請求內容(): GET /http%3A%2F%2Fxxxxxx.mp3 HTTP/1.1 (PS:前面咱們還記得alive()方法來進行ping的時候,那麼這裏的就會是GET /ping HTTP/1.1) User-Agent: Dalvik/2.1.0 (Linux; U; Android 6.0.1; MuMu Build/V417IR) Host: 127.0.0.1:50276 Connection: Keep-Alive Accept-Encoding: gzip (Range: bytes=0-10 若是網絡請求有range,這裏就會有該內容,好比分段下載時候,參考:https://www.cnblogs.com/1995hxt/p/5692050.html) '
class GetRequest {
private static final Pattern RANGE_HEADER_PATTERN = Pattern.compile("[R,r]ange:[ ]?bytes=(\\d*)-");
private static final Pattern URL_PATTERN = Pattern.compile("GET /(.*) HTTP");
public final String uri;
public final long rangeOffset;
public final boolean partial;
public GetRequest(String request) {
checkNotNull(request);
long offset = findRangeOffset(request);
this.rangeOffset = Math.max(0, offset);
this.partial = offset >= 0;
this.uri = findUri(request);
}
public static GetRequest read(InputStream inputStream) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, "UTF-8"));
StringBuilder stringRequest = new StringBuilder();
String line;
while (!TextUtils.isEmpty(line = reader.readLine())) { // until new line (headers ending)
stringRequest.append(line).append('\n');
}
return new GetRequest(stringRequest.toString());
}
private long findRangeOffset(String request) {
//'獲取range值'
Matcher matcher = RANGE_HEADER_PATTERN.matcher(request);
if (matcher.find()) {
String rangeValue = matcher.group(1);
return Long.parseLong(rangeValue);
}
return -1;
}
private String findUri(String request) {
//'經過Matcher匹配,獲取GET /http%3A%2F%2Fxxxxxx.mp3 HTTP/1.1 裏面的中間的http%3A%2F%2Fxxxxxx.mp3'
Matcher matcher = URL_PATTERN.matcher(request);
if (matcher.find()) {
return matcher.group(1);
}
throw new IllegalArgumentException("Invalid request `" + request + "`: url not found!");
}
@Override
public String toString() {
return "GetRequest{" +
"rangeOffset=" + rangeOffset +
", partial=" + partial +
", uri='" + uri + '\'' + '}'; } } 複製代碼
因此該類的做用就是把本次發送到本地ServerSocket的請求中,拿到相關的請求裏面的參數,因此該類裏面包含了下面參數:
咱們再看看看這個類:
final class HttpProxyCacheServerClients {
private final AtomicInteger clientsCount = new AtomicInteger(0);
//'咱們看到了url: http://127.0.0.1:port/[實際url]'
private final String url;
//'看到了HttpProxyCache這個類,這個類是作什麼的????從字面意思是網絡代理緩存類,後續繼續細看'
private volatile HttpProxyCache proxyCache;
//'看到了一系列的監聽器,從字面意思就知道是緩存監聽器,當註冊了這塊的緩存監聽,後續緩存好了能夠通知'
private final List<CacheListener> listeners = new CopyOnWriteArrayList<>();
private final CacheListener uiCacheListener;
//'這個前面說過,咱們的Config配置,包括緩存路徑等'
private final Config config;
public HttpProxyCacheServerClients(String url, Config config) {
this.url = checkNotNull(url);
this.config = checkNotNull(config);
this.uiCacheListener = new UiListenerHandler(url, listeners);
}
//'進行請求'
public void processRequest(GetRequest request, Socket socket) throws ProxyCacheException, IOException {
//'實例化一個HttpProxyCache對象'
startProcessRequest();
try {
clientsCount.incrementAndGet();
//'使用HttpProxyCache對象拿着GetRequest和Socket對象,進行下一步操做'
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);
FileCache cache = new FileCache(config.generateCacheFile(url), config.diskUsage);
//'咱們能夠看到實例化HttpProxyCache對象,須要傳入HttpUrlSource對象和FileCache對象'
HttpProxyCache httpProxyCache = new HttpProxyCache(source, cache);
httpProxyCache.registerCacheListener(uiCacheListener);
return httpProxyCache;
}
......
......
......
}
複製代碼
因此簡單的顯示就是這樣:
因此咱們就知道了HttpProxyCacheServerSocket
的做用了,一個url
對應了一個HttpProxyCacheServerSocket
,也就對應了:
FileCache
管理了存儲空間地址,存儲狀態的判斷(好比是否存儲空間滿了)HttpUrlSource
管理了相關的網絡請求模塊並把數據下載到FileCache
處,因此咱們具體一個個來看。
public class FileCache implements Cache {
......
......
......
}
'咱們能夠看到它是實現了Cache類,因此咱們只要看接口定義了哪些方法,就知道了FileCache的具體功能'
public interface Cache {
//'返回當前文件的大小:file.getLength();'
long available() throws ProxyCacheException;
//'由於使用的是RandomAccessFile,因此能夠跳到File中的某一段在讀取,而不是必定要從頭開始'
//'offset偏移值,length讀取長度'
int read(byte[] buffer, long offset, int length) throws ProxyCacheException;
//'一邊雲端獲取數據,一邊往文件後續繼續添加內容(下載完就不日後面添加)'
//'data傳入的數據 , length 當前文件的內容長度'
void append(byte[] data, int length) throws ProxyCacheException;
//'下載完後須要作的操做,1.文件數據流關閉,2.使用DisUsage對緩存文件進行從新排序,清除不須要的緩存文件'
void close() throws ProxyCacheException;
//'下載完成後,把文件的後綴名.download改成真正的文件後綴名,好比mp3'
//'(這個後綴名是根據傳入的url裏面拿的,通常好比http://xxxxxxxx.mp3,就拿到了mp3後綴)'
void complete() throws ProxyCacheException;
//'判斷是不是臨時文件,根據文件後綴名是否是.download判斷'
//'(就像咱們日常下東西,下的時候是xxxx.yyyyy某個臨時文件,下載完後纔是正確的格式,好比是xxxx.mp3)'
boolean isCompleted();
}
複製代碼
因此整體來講是把雲端的數據緩存到本地等一系列操做。
咱們來看它的代碼:
public class HttpUrlSource implements Source {
......
......
......
}
public interface Source {
//'打開網絡鏈接,得到了HttpURLConnection對象'
void open(long offset) throws ProxyCacheException;
//'獲取網絡資源的內容長度'
long length() throws ProxyCacheException;
//'使用HttpURLConnection讀取相應的返回內容'
int read(byte[] buffer) throws ProxyCacheException;
//'關閉HttpURLConnection'
void close() throws ProxyCacheException;
}
複製代碼
是否是看着就很清晰了,該類用來進行網絡鏈接,而後讀取網絡數據暴露給外部。
這裏要注意一個小細節:
而HttpUrlSource中網絡請求回來的數據後面有二種方式提供:
也就是HttpProxyCache
類裏面的這段代碼:
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 {
//直接將雲端讀取的內容返回給Socket
responseWithoutCache(out, offset);
}
}
複製代碼
咱們確定是具體想看的是緩存讀取的流程。因此咱們上面大體的代碼已經寫過了,如今再回頭看看具體每一步是怎麼實現的。
咱們已經知道是在HttpUrlSource
裏面:
@Override
public void open(long offset) throws ProxyCacheException {
try {
//'openConnection打開網絡鏈接'
connection = openConnection(offset, -1);
//'獲取當前訪問的數據類型格式'
String mime = connection.getContentType();
//'獲取當前鏈接的輸出流'
inputStream = new BufferedInputStream(connection.getInputStream(), DEFAULT_BUFFER_SIZE);
//'獲取當前返回數據的總長度'
long length = readSourceAvailableBytes(connection, offset, connection.getResponseCode());
//'獲取到的內容都使用SourceInfo來進行存儲管理'
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);
}
}
//'網絡鏈接就是使用基礎的HttpURLConnection來進行鏈接'
private HttpURLConnection openConnection(long offset, int timeout) throws IOException, ProxyCacheException {
HttpURLConnection connection;
boolean redirected;
int redirectCount = 0;
String url = this.sourceInfo.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();
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;
}
//'暴露給外部的數據讀取方法實際上就是用上面獲取到的輸出流來讀取內容'
@Override
public int read(byte[] buffer) throws ProxyCacheException {
if (inputStream == null) {
throw new ProxyCacheException("Error reading data from " + sourceInfo.url + ": connection is absent!");
}
try {
return inputStream.read(buffer, 0, buffer.length);
} catch (InterruptedIOException e) {
throw new InterruptedProxyCacheException("Reading source " + sourceInfo.url + " is interrupted", e);
} catch (IOException e) {
throw new ProxyCacheException("Error reading data from " + sourceInfo.url, e);
}
}
複製代碼
咱們來看HttpProxyCache裏面的代碼:
//'咋一眼看和responseWithoutCache不是長的如出一轍麼關鍵就是在於讀取數據的地方作了中間處理'
private void responseWithCache(OutputStream out, long offset) throws ProxyCacheException, IOException {
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
int readBytes;
//'這邊read讀取的時候,其實已經作了緩存,而實際返回的已是本地數據'
while ((readBytes = read(buffer, offset, buffer.length)) != -1) {
out.write(buffer, 0, readBytes);
offset += readBytes;
}
out.flush();
}
複製代碼
咱們具體看看這個read方法:
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;
}
複製代碼
因此具體的其實就在readSourceAsync();
方法裏面:
private synchronized void readSourceAsync() throws ProxyCacheException {
boolean readingInProgress = sourceReaderThread != null && sourceReaderThread.getState() != Thread.State.TERMINATED;
if (!stopped && !cache.isCompleted() && !readingInProgress) {
sourceReaderThread = new Thread(new SourceReaderRunnable(), "Source reader for " + source);
sourceReaderThread.start();
}
}
private class SourceReaderRunnable implements Runnable {
@Override
public void run() {
readSource();
}
}
//'實際的方法:'
private void readSource() {
long sourceAvailable = -1;
long offset = 0;
try {
offset = cache.available();
//'前面介紹過HttpUrlSource的,open你們應該知道了。鏈接網絡請求'
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);
}
}
複製代碼
因此整個流程就清楚了.......
回到咱們剛開始的問題:
1.咱們能夠自定義FileNameGenerator
public class ATFileNameGenerator implements FileNameGenerator {
好比http://aaaaaaa.com/905_xxxxxxxxxxxx.mp3
通常來講網址裏面帶了這首歌的id值(好比這裏的905),可能後面拼接了其餘時間戳等。咱們只要取出來核心的地方就好了:
public String generate(String url) {
// 只有愛聽url會變化
if (url.contains("aaaaaa.com")) {
//而後經過大家對應的規則,取出來中間的id值,
//而後用id。來做爲文件名字,找的時候也經過這個規則找文件便可。
return musicId;
}
Md5FileNameGenerator md5FileNameGenerator = new Md5FileNameGenerator();
return md5FileNameGenerator.generate(url);
}
}
複製代碼
寫的爛輕點噴便可.......