Android多线程断点续传下载原理及实现,2021阿里Android笔试总结

  • @param url 下载url
  • @param callback 获取文件长度CallBack
    */
    void getTotalSize(String url, NetCallback callback);

/**

  • 获取InputStream
  • @param url 下载url
  • @param start 开始位置
  • @param end 结束位置
  • @param callback 获取字节流的CallBack
    */
    void getStreamByRange(String url, long start, long end, NetCallback callback);
    }

子任务实现

成员变量及解释

我们先从上到下,从子任务开始实现。在我的设计中,它具有如下的成员变量:

@Entity
public class SubDownloadTask implements Runnable {
public static final int BUFFER_SIZE = 1024 * 1024;
private static final String TAG = SubDownloadTask.class.getSimpleName();

@Id
private Long id;
private String url; // 文件下载的 url
private String taskTag; // 父任务的 Tag
private long taskSize; // 子任务大小
private long completedSize; // 子任务完成大小
private long startPos; // 开始位置
private long currentPos; // 当前位置
private long endPos; // 结束位置
private volatile int status; // 当前下载状态
@Transient
private SubDownloadListener listener; // 子任务下载监听,主要用于提示父任务
@Transient
private File saveFile; // 要保存到的文件


}

由于这里的数据库的操作是用 GreenDao 实现,因此这里有一些相关注解,各位可以忽略。

InputStream 获取

可以看到,子任务是一个 Runnable,我们可以通过其 run 方法开始下载,这样就可以通过如 ExecutorService 来开启多个线程执行子任务。

我们看到其 run 方法:

@Override
public void run() {
status = DownloadStatus.DOWNLOADING;
DownloadManager.getInstance()
.getHttpHelper()
.getStreamByRange(url, currentPos, endPos, new NetCallback() {
@Override
public void onResult(InputStream inputStream) {
listener.onSubStart();
writeFile(inputStream);
}
@Override
public void one rror(String message) {
listener.onSubError(“文件流获取失败”);
status = DownloadStatus.ERROR;
}
});
}

可以看到,我们获取了其从 currentPosendPos 端的字节流,通过其 Response Body 拿到了它的 InputStream,然后调用了 writeFile(InputStream) 方法进行文件的写入。

文件写入
接下来看到 writeFile 方法:

private void writeFile(InputStream in) {
try {
RandomAccessFile file = new RandomAccessFile(saveFile, “rwd”); // 通过 saveFile 建立RandomAccessFile
file.seek(currentPos); // 跳转到对应位置

byte[] buffer = new byte[BUFFER_SIZE];
while (true) {
// 循环读取 InputStream,直到暂停或读取结束
if (status != DownloadStatus.DOWNLOADING) {
// 状态不为 DOWNLOADING,停止下载
break;
}

int offset = in.read(buffer, 0, BUFFER_SIZE);
if (offset == -1) {
// 读取不到数据,说明读取结束
break;
}

// 将读取到的数据写入文件
file.write(buffer, 0, offset);
// 下载数据并在数据库中更新
currentPos += offset;
completedSize += offset;
DownloadManager.getInstance()
.getDbHelper()
.update(this);
// 通知父任务下载进度
listener.onSubDownloading(offset);
}
if(status == DownloadStatus.DOWNLOADING) {
// 下载完成
status = DownloadStatus.COMPLETED;
// 通知父任务下载完成
listener.onSubComplete(completedSize);
}
file.close();
in.close();
} catch (IOException e) {
e.printStackTrace();
listener.onSubError(“文件下载失败”);
status = DownloadStatus.ERROR;
resetTask();
}
}

具体流程可以看代码中的注释。可以看到,子任务实际上就是循环读取 InputStream,并写入文件,同时将下载进度同步到数据库。

父任务实现

父任务也就是我们具体的下载任务,我们同样先看到成员变量:

public class DownloadTask implements SubDownloadListener {
private static final String TAG = DownloadTask.class.getSimpleName();
private String tag; // 下载任务的 Tag,用于区分不同下载任务
private String url; // 下载 url
private String savePath; // 保存路径
private String fileName; // 保存文件名
private DownloadListener listener; // 下载监听
private long completeSize; // 下载完成大小
private long totalSize; // 下载任务总大小
private int status; // 当前下载进度
private int threadNum; // 线程数(由外部设置的每个任务的下载线程数)
private File file; // 保存文件
private List subTasks; // 子任务列表
private ExecutorService mExecutorService; // 线程池,用于执行子任务


}

下载功能

对于一个下载任务,可以通过 download 方法开始执行:

public void download() {
listener.onStart();
subTasks = querySubTasks();
status = DownloadStatus.DOWNLOADING;
if (subTasks.isEmpty()) {
// 是新任务
downloadNewTask();
} else if (subTasks.size() == threadNum) {
// 不是新任务
downloadExistTask();
} else {
// 不是新任务,但下载线程数有误
listener.onError(“断点数据有误”);
resetTask();
}
}

可以看到,我们先将子任务列表从数据库中读取出来。

  • 如果子任务列表为空,则说明还没有下载记录,也就是说是一个新任务,调用 downloadNewTask 方法。
  • 如果子任务列表大小等于线程数,则说明其不是新任务,调用 downloadExistTask 方法。
  • 如果子任务列表大小不等于线程数,说明当前的下载记录已不可用,于是重置下载任务,从新下载。

下载新任务

我们先看到 downloadNewTask 方法:

DownloadManager.getInstance()
.getHttpHelper()
.getTotalSize(url, new NetCallback() {
@Override
public void onResult(Long total) {
completeSize = 0L;
totalSize = total;
initSubTasks();
startAsyncDownload();
}

@Override
public void one rror(String message) {
error(“获取文件长度失败”);
}
});

可以看到,获取到总长度后,通过调用 initSubTasks 方法,对子任务列表进行了初始化(计算子任务长度等),然后调用了 startAsyncDownload 方法后通过 ExecutorService 运行子任务进入子任务进行下载。

我们看到 initSubTasks 方法:

private void initSubTasks() {
long averageSize = totalSize / threadNum;
for (int taskIndex = 0; taskIndex < threadNum; taskIndex++) {
long taskSize = averageSize;
if (taskIndex == threadNum - 1) {
// 最后一个任务,则 size 还需要加入剩余量
taskSize += totalSize % threadNum;
}
long start = 0L;
int index = taskIndex;
while (index > 0) {
start += subTasks.get(index - 1).getTaskSize();
index–;
}
long end = start + taskSize - 1; // 注意这里
SubDownloadTask subTask = new SubDownloadTask();
subTask.setUrl(url);
subTask.setStatus(DownloadStatus.IDLE);
subTask.setTaskTag(tag);
subTask.setCompletedSize(0);
subTask.setTaskSize(taskSize);
subTask.setStartPos(start);
subTask.setCurrentPos(start);
subTask.setEndPos(end);
subTask.setSaveFile(file);
subTask.setListener(this);
DownloadManager.getInstance()
.getDbHelper()
.insert(subTask);
subTasks.add(subTask);
}
}

可以看到就是计算每个任务的大小及开始及结束点的位置,这里要注意的是 endPos 需要 -1,否则各个任务的下载位置会重叠,并且最后一个任务会多下载一个字节导致如文件损坏等影响。具体原因就是比如一个大小为 500 的文件,则应当是 0-499 而不是 0-500。

恢复旧任务

接下来我们看看 downloadExistTask 方法:

private void downloadExistTask() {
// 不是新任务,且下载线程数无误,计算已下载大小
completeSize = countCompleteSize();
totalSize = countTotalSize();
startAsyncDownload();
}

这里其实很简单,遍历子任务列表计算已下载量及总任务量,并调用 startAsyncDownload 开始多线程下载。

执行子任务

具体执行子任务我们可以看到 startAsyncDownload 方法:

private void startAsyncDownload() {
for (SubDownloadTask subTask : subTasks) {
if (subTask.getCompletedSize() < subTask.getTaskSize()) {
// 只下载没有下载结束的子任务
mExecutorService.execute(subTask);
}
}
}

可以看到,这里其实只是通过 ExecutorService 执行对应子任务(Runnable)而已。

####暂停功能
我们接下来看到 pause 方法:

public void pause() {
stopAsyncDownload();
status = DownloadStatus.PAUSE;
listener.onPause();
}

可以看到,这里只是调用了 stopAsyncDownload 方法停止子任务。

看到 stopAsyncDownload 方法:

private void stopAsyncDownload() {
for (SubDownloadTask subTask : subTasks) {
if (subTask.getStatus() != DownloadStatus.COMPLETED) {
// 下载完成的不再取消
subTask.cancel();
}
}
}

可以看到,调用了子任务的 cancel 方法。

继续看到子任务的 cancel方法:

void cancel() {
status = DownloadStatus.PAUSE;
listener.onSubCancel();
}

这里很简单,仅仅是将下载状态设置为了 PAUSE,这样在写入文件的下一次 while 循环时便会中止循环从而结束 Runnable 的执行。

取消功能

看到 cancel方法:
Android多线程断点续传下载原理及实现,2021阿里Android笔试总结

public void cancel() {
stopAsyncDownload();
resetTask();
listener.onCancel();
}

可以看到和暂停的逻辑差不多,只是在暂停后还需要对子任务重置从而使得下次下载从头开始。

底层到上层的通知机制

前面提到,外部可以通过 DownloadListener 监听下载的进度,下面是 DownloadListener接口的定义:

public interface DownloadListener {
[外链图片转存中…(img-GsBDHGVe-1643606831114)]

public void cancel() {
stopAsyncDownload();
resetTask();
listener.onCancel();
}

可以看到和暂停的逻辑差不多,只是在暂停后还需要对子任务重置从而使得下次下载从头开始。

底层到上层的通知机制

前面提到,外部可以通过 DownloadListener 监听下载的进度,下面是 DownloadListener接口的定义:

public interface DownloadListener {

上一篇:洛谷P7843


下一篇:第三届“图灵杯”趣味网络邀请赛(中级) 题解