この間ツールライブラリのスタジオのダウンロードコンポーネントを見て、いくつかの問題が見つかりました:
1.コアロジックにバグがあるダウンロード、確率一時停止のダウンロードまたはダウンロードに失敗したので、正常にダウンロード完了することはできませんがあります。
2.オリジナルのデザインは、マルチスレッドHTTPデザインの使用ですが、少しロギングダウンロードタスクをプレイすると、実際には同じスレッドシリアル実行で発見されますが、ダウンロード速度を加速する上で役割を果たしていませんでした。
ダウンロードコンポーネントのこの部分が書き換えられているので、元のコードが複雑ではありません考慮して。ここではマルチスレッドHTTP機能内部の実現について記録します。
フルPDF版を参照してください。
(つづく....より完全なプロジェクトのソースコードのダウンロードグラフィック知識、その後のアップロードgithubの)
をクリックすることができます私の上で完全なPDFのための接触私
(VX:mm14525201314)
マルチスレッドダウンロード意義
まず第一に、我々は、マルチスレッドダウンロードを意味し、話します。
日常の場面では、そのようなネットワークは、唯一の回避の混雑にウィンドウのサイズを調整することで、このようなシナリオで回避ネットワークの輻輳するために、TCPプロトコルを党とサーバー間の接続をダウンロードすることはできませんが、このウィンドウのサイズは、かもしれません私たちの所望の結果を達成する方法はありません。私たちの帯域幅をフルに活用。したがって、私たちは、帯域幅の使用率を高めるために複数のTCP接続の形を取ると高速なダウンロードすることができますすることができます。
アナロジーは、我々はポンプで水タンクが原因パイプ径、などの制限があるため、パイプを通って圧送したいということで、我々は完全に私たちのポンプの単管ポンピングパワーを利用することはできません。したがって、我々はそれがより完全にそれによって排気速度を増加させる、我々のポンプの動力を利用できるように、複数のチューブに割り当てられた部品の数、にこれらのタスクをポンピングします。
したがって、我々は主要な意義をダウンロードするには、複数のスレッドを使用している - の増加のダウンロード速度。
マルチスレッドのダウンロードの原則
タスク割り当て
我々は、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<SubDownloadTask> queryByTaskTag(String taskTag);
}
Http 辅助类
public interface DownloadHttpHelper {
/**
* 获取文件总长度
* @param url 下载url
* @param callback 获取文件长度CallBack
*/
void getTotalSize(String url, NetCallback<Long> callback);
/**
* 获取InputStream
* @param url 下载url
* @param start 开始位置
* @param end 结束位置
* @param callback 获取字节流的CallBack
*/
void getStreamByRange(String url, long start, long end, NetCallback<InputStream> 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<InputStream>() {
@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<SubDownloadTask> 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<Long>() { @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);
}
}
}
可以看到,这里其实只是通过 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
方法:
public void cancel() {
stopAsyncDownload();
resetTask();
listener.onCancel();
}
可以看到和暂停的逻辑差不多,只是在暂停后还需要对子任务重置从而使得下次下载从头开始。
底层到上层的通知机制
前面提到,外部可以通过 DownloadListener
监听下载的进度,下面是 DownloadListener
接口的定义:
public interface DownloadListener {
default void onStart() {}
default void onDownloading(long progress, long total) {}
default void onPause() {}
default void onCancel() {}
default void onComplete() {}
default void onError(String message) {}
}
我们实时的下载进度其实是在子任务的保存文件过程中才能体现出来的,同样,子任务的下载失败也需要通知到 DownloadListener
,这是怎么做到的呢?
前面提到了,我们还定义了一个 SubDownloadListener
,其监听者就是子任务的父任务。通过监听我们可以将子任务状态反馈到父任务,父任务再根据具体情况反馈数据给 DownloadListener
。
public interface SubDownloadListener {
void onSubStart();
void onSubDownloading(int offset);
void onSubCancel();
void onSubComplete(long completeSize);
void onSubError(String message);
}
比如之前看到,每次下载失败我们都会调用 onSubError
,每次读取 offset 的数据都会调用 onSubDownload(offset)
,每个任务下载失败都会调用 onSubComplete(completeSize)
。这样,我们子任务的下载状态就成功返回给了上层。
我们接着看看上层是如何处理的:
@Override
public void onSubStart() {}
@Override
public void onSubDownloading(int offset) {
synchronized (this) {
completeSize = completeSize + offset;
listener.onDownloading(completeSize, totalSize);
}
}
@Override
public void onSubCancel() {}
@Override
public void onSubComplete(long completeSize) {
checkComplete();
}
@Override
public void onSubError(String message) {
error(message);
}
あなたは、それが返されるデータの量アップとなり、ダウンロードするには、この時間データの各部分を見ることができcompleteSize
、それは対応するオフセット、その後、新しいと結合されているcompleteSize
ので、モニタのダウンロードの進捗状況を達成、リスナーへの通知を。ここではその理由は、複数のスレッド(サブタスクスレッド)としてロックされているcompleteSize
スレッドの安全性を確保するためにロックを操作します。
そして、それぞれの時間は、サブタスクが完了すると、それが呼び出されますcheckComplete
、各サブタスクはダウンロードが完了している場合は、ダウンロードがタスクを完了した後、リスナーに通知され、ダウンロードが完了したかどうかを確認する方法を。
同様に、各サブタスクエラーは、リスナーがエラーに気付き、およびエラー条件の下で、いくつかの処理を行います。
ここでは、この記事が終わって、我々は、マルチスレッドHTTPダウンロードを達成するために管理しました。この原理に基づいて、我々は、ファイルのダウンロードフレームワークを実装するためにいくつかのより高いレベルのパッケージを行うことができます。
フルPDF版を参照してください。
(つづく....より完全なプロジェクトのソースコードのダウンロードグラフィック知識、その後のアップロードgithubの)
をクリックすることができます私の上で完全なPDFのための接触私
(VX:mm14525201314)