所谓的断点续传,就是下载/上传过程中被中断,然后下次下载/上传的时候接着上次中断的地方继续下载/上传,直到下载/上传完成。
Range
HTTP头部用于在HTTP请求或响应中传递额外信息。range是HTTP请求头,可用于从服务器获取文档的一部分。如果服务器返回文档的一部分,则响应状态码为206(部分内容)。 如果给定的范围无效,则响应状态码为416(范围无法满足),并且服务器在忽略范围请求的情况下响应状态码为200(OK)。
-
从给定range开始获取整个文档
Range: <unit>=<range-start>-
-
请求多部分
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>
-
请求文档的特定结尾部分
Range: <unit>=-<suffix-length>
RandomAccessFile
RandomAccessFile支持读取和写入随机访问文件。随机访问文件的行为类似于存储在文件系统中的字节数组。隐式数组中有游标或索引,称为文件指针;输入操作从文件指针开始读取字节,然后使文件指针前进经过读取的字节。如果随机访问文件是在读/写模式下创建的,则输出操作也可用;输出操作从文件指针处开始写入字节,然后使文件指针前进经过写入的字节。如果写入操作超过隐含数组尾部就会导致数组扩展。文件指针可以通过getFilePointer方法获取,并通过seek方法设置。
如果在读取所需字节数之前已到达文件末尾,则将引发EOFException。
单线程下载
单线程下载只需要每次读取已下载文件长度,并作为range的起始位置传到服务端请求数据。
多线程下载
将一个文件分成多个片段,同时下载。需要先分片段,确定每个片段的起始位置和结束位置,然后存放到数据库或者文件中。然后每次下载都需要读取对应片段的起始位置,将起始位置和结束位置传到服务端请求数据。
数据库设计
单线程下载不需要数据库,每次下载只需要读取已下载文件即可。多线程下载需要文件或者数据库来存储片段信息。
- 断点信息表
列名 | 类型 | 约束 | 描述 |
---|---|---|---|
id | INTEGER | PRIMARY KEY | 序号 |
url | VARCHAR | NOT NULL | 下载链接 |
etag | VARCHAR | 请求条件,请求头If-Match的值,传到服务器作验证,匹配则允许下载 ,没有值则不需要匹配,直接下载 | |
parent_path | VARCHAR | NOT NULL | 文件存储目录 |
file_name | VARCHAR | NOT NULL | 文件名 |
signature | VARCHAR | 签名,用于下载完成后验证文件是否合法,如md5校验 |
- 片段信息表
列名 | 类型 | 约束 | 描述 |
---|---|---|---|
id | INTEGER | PRIMARY KEY AUTOINCREMENT | 序号 |
breakpoint_id | INTEGER | NOT NULL | 断点信息表中的序号 |
block_index | INTEGER | 片段索引,文件分片以后记录第几片 | |
start_position | INTEGER | 片段在文件中的开始位置 | |
block_length | INTEGER | 片段长度 | |
current_position | INTEGER | 该片段已下载长度 |
IO优化
可以采用NIO的方式来替代RandomAccessFile读写。首先根据文件File生成一个FileOutputStream对象,然后调用FileOutputStream#getChannel方法获取到FileChannel,FileChannel#position方法用于定位数据追加位置,类似于RandomAccessFile#seek方法,最后用FileOutputStream对象生成BufferedOutputStream来进行写入。
实践
DownloadManager
public class DownloadManager {
private static volatile DownloadManager sInstance;
private Context context;
final Dispatcher dispatcher;
final OkHttpClient client;
private DownloadStore<DownloadInfo> store;
public static DownloadManager getInstance() {
if (sInstance == null) {
synchronized (DownloadManager.class) {
if (sInstance == null) {
sInstance = new DownloadManager();
}
}
}
return sInstance;
}
public void init(Context context) {
this.context = context.getApplicationContext();
}
public void init(Context context, DownloadStore store) {
this.context = context.getApplicationContext();
this.store = store;
}
private DownloadManager() {
dispatcher = new Dispatcher();
client = new OkHttpClient.Builder()
.connectTimeout(2, TimeUnit.MINUTES)
.writeTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build();
client.dispatcher().setMaxRequests(20);
client.dispatcher().setMaxRequestsPerHost(20);
}
public void start(DownloadTask task) {
dispatcher.enqueue(task);
}
public void cancel(@NonNull DownloadTask task) {
dispatcher.cancel(task);
}
public void cancelAll() {
dispatcher.cancelAll();
}
Context getContext() {
return context;
}
String getDownloadFileDir() {
return Environment.getExternalStorageDirectory() + File.separator + "download";
}
}
DownloadTask
public class DownloadTask implements Runnable {
public static final String RANGE = "Range";
public static final int DEFAULT_READ_BUFFER_SIZE = 4096;
public static final int DEFAULT_FLUSH_BUFFER_SIZE = 16384;
private volatile String taskId;
private String url;
private String fileName;
private String signature;
private DownloadListener listener;
private String range;
private long existFileLength;
private boolean executed = false;
private boolean canceled = false;
private DownloadOutputStream output;
private DownloadTask(String taskId, String url, String fileName, String range, String signature, DownloadListener listener) {
this.taskId = taskId;
this.url = url;
this.fileName = fileName;
this.range = range;
this.signature = signature;
this.listener = listener;
}
@Override
public void run() {
synchronized (this) {
if (executed) throw new IllegalStateException("The task already executed");
executed = true;
}
if (listener != null) {
listener.onDownloadStart(this);
}
if (TextUtils.isEmpty(url)) {
downloadError(new IllegalArgumentException("The url is empty"));
DownloadManager.getInstance().dispatcher.finished(this, true);
return;
}
if (canceled) {
if (listener != null) {
listener.onDownloadCanceled(this);
}
DownloadManager.getInstance().dispatcher.finished(this, true);
return;
}
DownloadConnection connection = null;
try {
connection = callAndSave();
} catch (Exception e) {
if (listener != null) {
listener.onDownloadError(this, e);
}
} finally {
if (output != null) {
try {
output.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (connection != null) {
connection.release();
}
DownloadManager.getInstance().dispatcher.finished(this, true);
}
}
private DownloadConnection callAndSave() throws Exception {
File dir = new File(DownloadManager.getInstance().getDownloadFileDir());
if (!dir.exists()) {
dir.mkdirs();
}
File file = new File(dir, fileName);
if (!file.exists()) {
//不存在则创建
file.createNewFile();
} else if (!TextUtils.isEmpty(signature) && checkFileValid(file)) {
//已经下载过
downloadComplete(file);
return null;
} else {
existFileLength = file.length();
}
Log.d("DownloadTask", "The exist file length is " + existFileLength);
DownloadConnection connection = new DownloadConnection(DownloadManager.getInstance().client, url);
if (!TextUtils.isEmpty(range)) {
connection.addHeader(RANGE, "bytes=" + this.range);
} else {
connection.addHeader(RANGE, "bytes=" + this.existFileLength + "-");
}
connection.execute();
final long contentLength = connection.contentLength();
final long totalSize = contentLength + existFileLength;
if (contentLength == 0) {
downloadComplete(file);
return connection;
}
output = new DownloadOutputStream(file, DEFAULT_FLUSH_BUFFER_SIZE);
output.seek(existFileLength);
int readLength = 0;
int progress = 0, lastProgress = 0;
byte[] buf = new byte[DEFAULT_READ_BUFFER_SIZE];
InputStream is = connection.inputStream();
while (!canceled && (readLength = is.read(buf)) != -1) {
output.write(buf, 0, readLength);
if (listener != null) {
existFileLength += readLength;
lastProgress = progress;
progress = (int) (existFileLength * 100 / totalSize);
if (progress > 0 && progress >= lastProgress) {
listener.onDownloading(this, progress);
}
}
}
if (!canceled) {
output.flushAndSync();
output.setLength(existFileLength);
if (!TextUtils.isEmpty(signature)) {
if (!checkFileValid(file)) {
file.delete();
downloadError(new Exception("File md5 check failed"));
} else {
downloadComplete(file);
}
} else {
downloadComplete(file);
}
} else if (listener != null) {
listener.onDownloadCanceled(this);
}
return connection;
}
public void cancel() {
canceled = true;
}
public void delete() {
try {
File dir = new File(DownloadManager.getInstance().getDownloadFileDir());
if (!dir.exists()) return;
File file = new File(dir, fileName);
if (file.exists()) {
file.delete();
}
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* MD5校验
*
* @param file
* @return
*/
private boolean checkFileValid(File file) {
final String downloadFileMd5 = Utils.getMD5(file, 32);
return !TextUtils.isEmpty(downloadFileMd5) && downloadFileMd5.equalsIgnoreCase(signature.toLowerCase());
}
public String getId() {
return taskId;
}
private void downloadComplete(File file) {
if (listener != null) {
listener.onDownloadComplete(this, file.getPath());
}
}
private void downloadError(Throwable e) {
if (listener != null) {
listener.onDownloadError(this, e);
}
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
DownloadTask task = (DownloadTask) o;
return ObjectsCompat.equals(taskId, task.taskId) &&
ObjectsCompat.equals(url, task.url);
}
@Override
public int hashCode() {
return ObjectsCompat.hash(taskId, url);
}
public static class Builder {
private String url;
private String fileName;
private String signature;
private DownloadListener listener;
private String range;
public Builder url(@NonNull String url) {
this.url = url;
return this;
}
public Builder fileName(String fileName) {
this.fileName = fileName;
return this;
}
public Builder listen(DownloadListener listener) {
this.listener = listener;
return this;
}
public Builder setRange(String range) {
this.range = range;
return this;
}
public Builder setSignature(String signature) {
this.signature = signature;
return this;
}
public DownloadTask build() {
final String taskId = url != null && url.length() > 0 ? String.valueOf(url.hashCode()) : null;
return new DownloadTask(taskId, url, fileName, range, signature, listener);
}
}
}
感谢大家的支持,如有错误请指正,如需转载请标明原文出处!