我又来了,一个月写了三个小框架我也是屌屌的。
一般的小项目,遇到下载的问题时都是简单的开一个线程然后通过流的方式来实现。少量的下载,文件也比较小的的时候,这样的方式都是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();
就酱,谢谢观赏。