輕量級多執行緒斷點續傳下載框架
我又來了,一個月寫了三個小框架我也是屌屌的。
一般的小專案,遇到下載的問題時都是簡單的開一個執行緒然後通過流的方式來實現。少量的下載,檔案也比較小的的時候,這樣的方式都是OK的。但是如果真要做一款下載為主要功能的app的時候,或者專案中涉及大量下載任務的時候,首先,單執行緒下載速度慢的感人,其次,使用者想要自由的暫停一些任務開始一些任務,而不是開始了就停不下來,另外,即使使用者退出了,下次再點選下載的時候,如果又重頭下載了,使用者一定會解除安裝你的。基於這幾個小需求,我們專案中必須引入多執行緒斷點續傳下載機制。
博文很多,github上開源專案也很多,還是那句話,SDK需要的是精簡和高效,能用自行車解決的沒必要也不允許用SUV來解決,所以與其費勁去讀別人的原始碼,擔心未知的bug,不如理解原理之後自己動手寫一個。
先看多執行緒下載的原理。
簡單來說,就是把一個大檔案切割成幾個小檔案,然後分別用獨立的執行緒去下載,當然客戶端效能有限,一般2-3個執行緒即可。這裡需要用到Http頭中的Range屬性,具體後面程式碼會說。
斷點續傳原理。
普通下載是先本地建立了一個空檔案,然後通過流不斷向裡面寫入資料,當網路連線被切斷之後,若再次開始下載,只能刪除這個檔案,然後重新建立再從頭下載。
和普通下載不同的是,斷點續傳下載會首先通過http協議獲得檔案的大小,然後在本地建立一個同樣大小檔案,接下來同樣會通過流向當中寫入資料,當網路切斷的時候,只要我們儲存好當前已經寫入的資料長度,從這個長度重新開始寫入資料,就能保證續傳後的檔案一樣是完整可用的檔案。這裡同樣需要用到Http頭中的Content-Length和Range屬性,同時還有一個重要的java類RandomAccessFile。
講完原理上程式碼:
/**
* Created by Amuro on 2016/10/31.
*/
public class DownloadManager
{
public interface DownloadListener
{
void onStart(String fileName, String realUrl, long fileLength);
void onProgress(int progress);
void onFinish(File file);
void onError(int status, String error);
}
private DownloadManager()
{}
private static DownloadManager instance;
public static DownloadManager getInstance()
{
if(instance == null)
{
synchronized (DownloadManager.class)
{
if(instance == null)
{
instance = new DownloadManager();
}
}
}
return instance;
}
public static final int ERROR_CODE_NO_NETWORK = 1000;
public static final int ERROR_CODE_FILE_LENGTH_ERROR = 1001;
public static final int ERROR_CODE_BEYOND_MAX_TASK = 1002;
public static final int ERROR_CODE_INIT_FAILED = 1003;
public static final int ERROR_CODE_DOWNLOAD_FAILED = 1004;
private DownloadConfig config;
private Handler handler = new Handler(Looper.getMainLooper());
private DLCore dlCore;
public void init(DownloadConfig config)
{
if(config == null)
{
throw new RuntimeException("Download config can't be null");
}
this.config = config;
dlCore = new DLCore();
}
public DownloadConfig getConfig()
{
return config;
}
public void invoke(String url, DownloadListener listener)
{
invoke(url, null, listener);
}
public void invoke(String url, String fileName, DownloadListener listener)
{
if(!DLUtil.isNetworkAvailable(config.getContext()))
{
if(listener != null)
{
listener.onError(ERROR_CODE_NO_NETWORK, "no network");
}
return;
}
DLFileInfo fileInfo = new DLFileInfo();
fileInfo.url = url;
fileInfo.name = fileName;
dlCore.start(fileInfo, listener);
}
public void stop(String url)
{
dlCore.stop(url);
}
public void postToUIThread(Runnable r)
{
handler.post(r);
}
public void destroy()
{
dlCore.stopAll();
}
}
跟ImageLoader一樣,我們先要讓使用者初始化的時候傳一個配置進去,這裡的配置用了同樣的建造者模式:
/**
* Created by Amuro on 2016/10/31.
*/
public class DownloadConfig
{
private static final String DEFAULT_SAVE_PATH =
Environment.getExternalStorageDirectory().getAbsolutePath() +
"/downloadCompact/";
private DownloadConfig()
{}
private Context context;
private String savePath;
private boolean isDebug;
private int maxTask;
public String getSavePath()
{
return savePath;
}
public boolean isDebug()
{
return isDebug;
}
public Context getContext()
{
return context;
}
public int getMaxTask()
{
return maxTask;
}
public static class Builder
{
Context context;
String savePath;
boolean isDebug = false;
int maxTask = 1;
public Builder setContext(Context context)
{
this.context = context;
return this;
}
public Builder setSavePath(String savePath)
{
this.savePath = savePath;
return this;
}
public Builder setIsDebug(boolean isDebug)
{
this.isDebug = isDebug;
return this;
}
public Builder setMaxTask(int maxTask)
{
this.maxTask = maxTask;
return this;
}
public DownloadConfig create()
{
DownloadConfig config = new DownloadConfig();
config.context = context;
if(TextUtils.isEmpty(savePath))
{
config.savePath = DEFAULT_SAVE_PATH;
}
else
{
config.savePath = savePath;
}
config.isDebug = isDebug;
config.maxTask = maxTask;
return config;
}
}
}
其中maxTask是app啟動是允許同時下載的最大任務數。
初始化除了儲存外部傳入的配置外,最重要的是初始化下載核心類DLCore,看程式碼:
/**
* Created by Amuro on 2016/10/31.
*/
public class DLCore
{
protected static ExecutorService threadPool =
Executors.newCachedThreadPool();
protected static AtomicInteger runningTasks = new AtomicInteger(0);;
private String savePath;
private Map<String, DLTask> taskMap = new LinkedHashMap<String, DLTask>();
public DLCore()
{
this.savePath =
DownloadManager.getInstance().getConfig().getSavePath();
}
public void start(DLFileInfo fileInfo, DownloadListener listener)
{
if(runningTasks.get() < DownloadManager.getInstance().getConfig().getMaxTask())
{
runningTasks.incrementAndGet();
threadPool.execute(new InitThread(fileInfo, listener));
}
else
{
listener.onError(
DownloadManager.ERROR_CODE_BEYOND_MAX_TASK,
"waiting for other task completed");
}
}
public void stop(String url)
{
DLTask dlTask = taskMap.get(url);
if(dlTask != null)
{
dlTask.pause();
runningTasks.decrementAndGet();
}
}
public void stopAll()
{
for(DLTask dlTask : taskMap.values())
{
if(dlTask != null)
{
dlTask.pause();
}
}
runningTasks.set(0);
}
//run in main thread
private void onInitCompleted(DLFileInfo fileInfo, DownloadListener listener)
{
DLTask dlTask = new DLTask(
fileInfo, listener, 3);
dlTask.download();
taskMap.put(fileInfo.url, dlTask);
}
class InitThread extends Thread
{
private DLFileInfo fileInfo;
private DownloadListener listener;
public InitThread(DLFileInfo fileInfo, DownloadListener listener)
{
this.fileInfo = fileInfo;
this.listener = listener;
}
@Override
public void run()
{
doInit();
}
private void doInit()
{
HttpURLConnection conn = null;
RandomAccessFile raf = null;
try
{
URL url = new URL(fileInfo.url);
conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(3000);
conn.setRequestMethod("GET");
int fileTotalLength = -1;
int rspCode = conn.getResponseCode();
if(rspCode == HttpURLConnection.HTTP_OK ||
rspCode == HttpURLConnection.HTTP_PARTIAL)
{
fileTotalLength = conn.getContentLength();
}
DLogUtils.e("content length: " + fileTotalLength);
if(fileTotalLength <= 0)
{
DownloadManager.getInstance().postToUIThread(new Runnable()
{
@Override
public void run()
{
if(listener != null)
{
listener.onError(
DownloadManager.ERROR_CODE_FILE_LENGTH_ERROR,
"obtain file length error");
}
}
});
return;
}
//下載dir確認,不存在則建立
File dir = new File(savePath);
if(!dir.exists())
{
dir.mkdir();
}
DLogUtils.e("before: " + fileInfo.name);
//建立下載檔案,檔名可以從外面傳入,如果沒有傳入則通過網路獲取
File file = null;
if(TextUtils.isEmpty(fileInfo.name))
{
String disposition = conn.getHeaderField("Content-Disposition");
String location = conn.getHeaderField("Content-Location");
String generatedFileName =
DLUtil.obtainFileName(
fileInfo.url, disposition, location);
file = new File(savePath, generatedFileName);
fileInfo.name = generatedFileName;
}
else
{
file = new File(savePath, fileInfo.name);
}
DLogUtils.e("after: " + fileInfo.name);
//設定下載檔案,並回調onStart
raf = new RandomAccessFile(file, "rwd");
raf.setLength(fileTotalLength);
fileInfo.totalLength = fileTotalLength;
DownloadManager.getInstance().postToUIThread(new Runnable()
{
@Override
public void run()
{
if (listener != null)
{
listener.onStart(
fileInfo.name, fileInfo.url, fileInfo.totalLength);
}
onInitCompleted(fileInfo, listener);
}
});
}
catch (final Exception e)
{
runningTasks.decrementAndGet();
DownloadManager.getInstance().postToUIThread(new Runnable()
{
@Override
public void run()
{
if(listener != null)
{
listener.onError(DownloadManager.ERROR_CODE_INIT_FAILED,
"init failed: " + e.getMessage());
}
}
});
}
finally
{
try
{
conn.disconnect();
raf.close();
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
}
}
DLCore中初始化了執行緒池,並利用AtomInteger來給當前執行的任務進行計數,控制下載的最大任務數。這裡我們選用了快取執行緒池,因為下載會開啟大量的執行緒,用固定數量的執行緒池會經常導致執行緒浪費或不夠用,所以把這件事動態化是一個明智的選擇。start方法是啟動下載的核心方法,會先開啟一個執行緒傳送http請求做一些初始化的工作,主要是獲取檔名和檔案長度,並在本地通過RandomAccessFile類建立一個對應的檔案,原理在前面已經講過了。一切沒有異常的話,初始化完成,然後就會啟動多執行緒下載。我們來看DLTask這個類:
/**
* Created by Amuro on 2016/10/20.
*/
public class DLTask
{
private DLFileInfo fileInfo;
private ThreadInfoDAO dao;
private int threadCount = 1;
private long totalFinishedLength = 0;
private boolean isPause = false;
private List<DownloadThread> threadList;
private DownloadListener listener;
public DLTask(DLFileInfo fileInfo, DownloadListener listener, int threadCount)
{
this.fileInfo = fileInfo;
this.listener = listener;
this.threadCount = threadCount;
this.dao = new ThreadInfoDAOImpl(
DownloadManager.getInstance().getConfig().getContext());
}
public void download()
{
List<ThreadInfo> threadInfoList = dao.queryThreadInfo(fileInfo.url);
if(threadInfoList.size() == 0)
{
long length = fileInfo.totalLength / threadCount;
for (int i = 0; i < threadCount; i++)
{
ThreadInfo threadInfo = new ThreadInfo(
i, fileInfo.url, length * i, length * (i + 1) - 1, 0);
if(i == threadCount - 1)
{
threadInfo.setEnd(fileInfo.totalLength);
}
threadInfoList.add(threadInfo);
dao.insertThreadInfo(threadInfo);
}
}
threadList = new ArrayList<DownloadThread>();
for(ThreadInfo info : threadInfoList)
{
DownloadThread downloadThread = new DownloadThread(info);
DLCore.threadPool.execute(downloadThread);
threadList.add(downloadThread);
}
}
public void pause()
{
this.isPause = true;
}
class DownloadThread extends Thread
{
private ThreadInfo threadInfo;
private boolean isFinished = false;
public DownloadThread(ThreadInfo threadInfo)
{
this.threadInfo = threadInfo;
}
@Override
public void run()
{
HttpURLConnection conn = null;
RandomAccessFile raf = null;
InputStream inputStream = null;
try
{
URL url = new URL(threadInfo.getUrl());
conn = (HttpURLConnection)url.openConnection();
conn.setConnectTimeout(3000);
conn.setRequestMethod("GET");
long start = threadInfo.getStart() + threadInfo.getFinished();
conn.setRequestProperty(
"Range", "bytes=" + start + "-" + threadInfo.getEnd());
final File file = new File(
DownloadManager.getInstance().getConfig().getSavePath(),
fileInfo.name);
raf = new RandomAccessFile(file, "rwd");
raf.seek(start);
totalFinishedLength += threadInfo.getFinished();
if(conn.getResponseCode() == HttpURLConnection.HTTP_OK
|| conn.getResponseCode() == HttpURLConnection.HTTP_PARTIAL)
{
inputStream = conn.getInputStream();
byte[] buffer = new byte[1024 * 4];
int len = -1;
long time = System.currentTimeMillis();
while ((len = inputStream.read(buffer)) != -1)
{
raf.write(buffer, 0, len);
totalFinishedLength += len;
//每個執行緒自己的進度
threadInfo.setFinished(threadInfo.getFinished() + len);
if(System.currentTimeMillis() - time > 1000)
{
time = System.currentTimeMillis();
DownloadManager.getInstance().postToUIThread(new Runnable()
{
@Override
public void run()
{
if(listener != null)
{
Long l = totalFinishedLength * 100 / fileInfo.totalLength;
int finishedPercent = l.intValue();
DLogUtils.e(
"當前下載完成的檔案長度:" + totalFinishedLength + "\n" +
"fileTotalLength: " + fileInfo.totalLength + "\n" +
"finishedPercent: " + finishedPercent);
listener.onProgress(finishedPercent);
// fileInfo.finishedPercent
}
}
});
}
if(isPause)
{
dao.updateThreadInfo(
threadInfo.getUrl(), threadInfo.getId(),
threadInfo.getFinished());
return;
}
}
isFinished = true;
checkAllThreadsFinished();
}
}
catch (final Exception e)
{
e.printStackTrace();
DLCore.runningTasks.decrementAndGet();
DownloadManager.getInstance().postToUIThread(new Runnable()
{
@Override
public void run()
{
if(listener != null)
{
listener.onError(DownloadManager.ERROR_CODE_DOWNLOAD_FAILED,
"download failed: " + e.getMessage());
}
}
});
}
finally
{
try
{
conn.disconnect();
inputStream.close();
raf.close();
}
catch (Exception e)
{
e.printStackTrace();
}
}
}
}
private synchronized void checkAllThreadsFinished()
{
boolean allFinished = true;
for(DownloadThread downloadThread : threadList)
{
if(!downloadThread.isFinished)
{
allFinished = false;
break;
}
}
if(allFinished)
{
dao.deleteThreadInfo(fileInfo.url);
DLCore.runningTasks.decrementAndGet();
DownloadManager.getInstance().postToUIThread(new Runnable()
{
@Override
public void run()
{
if(listener != null)
{
listener.onFinish(
new File(DownloadManager.getInstance().getConfig().getSavePath(),
fileInfo.name));
}
}
});
}
}
}
程式碼比較多,也是最複雜的一個類了,但是原理就是上面的分段下載的思想,按這個分析就很簡單了。初始化的時候主要是接收外面配置的執行緒數量,同時把資料庫操作的DAO類初始化。資料庫操作是基本功了,這裡就不貼程式碼了,主要就是儲存停止下載時所有下載執行緒的快取資訊,再下一次啟動下載的時候讀取。再來看核心的download方法,這個方法首先會通過url去查詢對應的ThreadInfo資訊,如果查詢不到,就建立對應數量的新ThreadInfo,併入庫;如果查詢到,則取出對應的ThreadInfo繼續操作。ThreadInfo準備好之後,這時候就建立對應數量的下載執行緒去做下載的工作。
下載的程式碼裡有幾個重點的地方,首先是之前說的要用到Http請求頭的Range屬性,也就是每個執行緒要設定自己的開始和結束位置。然後同樣的事情也要對本地的檔案進行操作,這裡用到了RandomAccessFile的seek方法,而總進度則是三個執行緒進度的總和。下載開始後,就是最普通的流讀取,快取寫入等操作,同時通過handler更新進度給介面回撥。看一下pause裡的程式碼,其實就是儲存了當前所有的ThreadInfo資訊,然後退出執行緒的run,是不是很簡單。最後,當某個執行緒的下載完成後,要去確認是不是所有的執行緒都下載完成。所有執行緒下載完成,才是真正下載完成,這時候才能回撥給介面層。別忘了把我們前面的下載任務計數器減一。
一共只有九個類就基本完成了簡單的多執行緒斷點續傳,後面有新需求再拓展就好了。
最後貼一下用法,基本也是標準化的東西了。
初始化:
DownloadConfig config = new DownloadConfig.Builder(). setContext(this).setIsDebug(true).setMaxTask(2).create();
DownloadManager.getInstance().init(config);
下載與回撥:
private class DownloadStartOnClickListener implements View.OnClickListener
{
private ViewHolder viewHolder;
private DLFileInfo fileInfo;
protected DownloadStartOnClickListener(ViewHolder viewHolder, DLFileInfo fileInfo)
{
this.viewHolder = viewHolder;
this.fileInfo = fileInfo;
}
@Override
public void onClick(View v)
{
DownloadManager.getInstance().invoke(
fileInfo.url,
fileInfo.name,
new DownloadManager.DownloadListener()
{
@Override
public void onStart(String fileName, String realUrl, long fileLength)
{
viewHolder.textViewFileName.setText(fileName);
showToast(fileName + "開始下載");
}
@Override
public void onProgress(int progress)
{
viewHolder.progressBar.setProgress(progress);
}
@Override
public void onFinish(File file)
{
viewHolder.progressBar.setProgress(100);
showToast(fileInfo.name + "下載完成");
}
@Override
public void onError(int status, String error)
{
showToast("error -> " + status + " : " + error);
}
});
}
}
銷燬:
DownloadManager.getInstance().destroy();
就醬,謝謝觀賞。