因此,我们使用多线程下载的主要意义就是——提高下载速度。
多线程下载原理
任务分配
前面提到了我们主要的目的是将一个总的下载任务分摊到多个子任务中,比如假设我们用 5 个线程下载这个文件,那么我们就可以对一个长度为 N 的任务进行如下图的均分:
但真实场景下往往 N 都不是刚好为 5 的倍数的,因此对于最后一个任务还需要加上剩余的任务量,也就是 N/5+N%5。
Http Range 请求头
上面的任务分配我们已经了解了,看起来很理想,但有一个问题,我们如何实现向服务器只请求这个文件的某一段而不是全部呢?
我们可以通过在请求头中加入 Range 字段来指定请求的范围,从而实现指定某一段的数据。
如:RANGE bytes=10000-19999
就指定了 10000-19999 这段字节的数据
所以我们的核心思想就是通过它拿到文件对应字节段的 InputStream,然后对它读取并写入文件。
RandomAccessFile 文件写入
下面再讲讲文件写入问题,由于我们是多线程下载,因此文件并不是每次都是从前往后一个个字节写入的,随时可能在文件的任何一个地方写入数据。因此我们需要能够在文件的指定位置写入数据。这里我们用到了RandomAccessFile
来实现这个功能。
RandomAccessFile
是一个随机访问文件类,同时整合了 FileOutputStream
和 FileInputStream
,支持从文件的任何字节处读写数据。通过它我们就可以在文件的任何字节处写入数据。
接下来简单讲讲我们这里是如何使用 RandomAccessFile
的。我们对于每个子任务来说都有一个开始和结束的位置。每个任务都可以通过 RandomAccessFile::seek
跳转到文件的对应字节位置,然后从该位置开始读取 InputStream
并写入。
这样,就实现了不同线程对文件的随机写入。
文件大小的获取
由于我们在真正开始下载之前,我们需要先将任务分配到各个线程,因此我们需要先了解到文件的大小。
为了获取到文件的大小,我们用到 Response Headers
中的 Content-Length
字段。
如下图所示,可以看到,打开该下载请求的链接后,Response Headers
中包含了我们需要的 Content-Length
,也就是该文件的大小,单位是字节。
断点续传原理
对于多个子任务,我们如何实现它们的断点续传呢?
其实原理很简单,只需要保证每个子任务的下载进度能够被即时地记录即可。这样继续下载时只需要读取这些下载记录,从上次下载结束的位置开始下载即可。
它的实现有很多方式,只要能做到数据持久化即可。这里我使用的是数据库来实现。
这样,我们的子任务需要拥有一些必要的信息
completedSize
:当前下载完成大小taskSize
:子任务总大小startPos
:子任务开始位置currentPos
:子任务进行到的位置endPos
:子任务结束位置
通过这些信息,我们就能够记录子任务的下载进度从而恢复我们之前的下载,实现断点续传。
代码实现
下面我们用代码来实现这样一个多线程下载功能。
下载状态
首先,我们定义一下下载中的各个状态:
public class DownloadStatus {
public static final int IDLE = 233; // 空闲,默认状态
public static final int COMPLETED = 234; // 完成
public static final int DOWNLOADING = 235; // 下载中
public static final int PAUSE = 236; // 暂停
public static final int ERROR = 237; // 出错
}
可以看到,这里定义了如上的五种状态。
基本辅助类的抽象
这里需要用到如数据库及 HTTP 请求的功能,我们这里定义其接口如下,具体实现各位可以根据需要自己实现:
数据库辅助类
public interface DownloadDbHelper {
/**
- 从数据库中删除子任务记录
- @param task 子任务记录
*/
void delete(SubDownloadTask task);
/**
- 向数据库中插入子任务记录
- @param task 子任务记录
*/
void insert(SubDownloadTask task);
/**
- 在数据库中更新子任务记录
- @param task 子任务记录
*/
void update(SubDownloadTask task);
/**
- 获取所有指定Task下的子任务记录
- @param taskTag Task的Tag
- @return 子任务记录
*/
List queryByTaskTag(String taskTag);
}
Http 辅助类
public interface DownloadHttpHelper {
/**
- 获取文件总长度
- @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 onError(String message) {
listener.onSubError(“文件流获取失败”);
status = DownloadStatus.ERROR;
}
});
}
可以看到,我们获取了其从 currentPos
到 endPos
端的字节流,通过其 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 onError(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);
}
}
}
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Android工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Android移动开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Android开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V获取:vip204888 (备注Android)
Android高级架构师
由于篇幅问题,我呢也将自己当前所在技术领域的各项知识点、工具、框架等汇总成一份技术路线图,还有一些架构进阶视频、全套学习PDF文件、面试文档、源码笔记。
- 330页PDF Android学习核心笔记(内含上面8大板块)
-
Android学习的系统对应视频
-
Android进阶的系统对应学习资料
- Android BAT部分大厂面试题(有解析)
好了,以上便是今天的分享,希望为各位朋友后续的学习提供方便。觉得内容不错,也欢迎多多分享给身边的朋友哈。
一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
9416298)]
-
Android学习的系统对应视频
-
Android进阶的系统对应学习资料
[外链图片转存中…(img-XhITuwpK-1712829416298)]
- Android BAT部分大厂面试题(有解析)
[外链图片转存中…(img-utTPNLJr-1712829416298)]
好了,以上便是今天的分享,希望为各位朋友后续的学习提供方便。觉得内容不错,也欢迎多多分享给身边的朋友哈。
一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
[外链图片转存中…(img-sae5i2em-1712829416299)]