
掌握
多个文件断点续传同时下载,并且单个文件分片同时下载
,对于学习多线程,多协程都是有很大帮助的。
一、前言
在之前的文章中Android提升开发测试效率,程序员应该多干了些什么?,我有提到过之前我手撸一个下载器,它是多文件同时下载,并且它也是单个文件分片同时断点续传下载。当时那个代码是 N多年前写的,全部基于: java + 线程池 + HttpURLConnection
来实现的。有部分网友也想要研究,我承诺后面young现代方式在实现一遍:基于kotlin+Compose+协程+Flow+Channel来实现
,
这里涉及到我之前写的一篇Compose 进度条按钮UI文章Android下载进度百分比按钮,Compose轻松秒杀实现
本文重点介绍是如何实现的:
- 用最原始的方式来实现。
- 没有用过多的设计模式和高级的写法,可能代码不美观,本文不为了代码炫技
主要是为了让人更加清晰的明白实现思路。
- 当然后面文章还会介绍和优化,可能写法上会有变化。
- 后面会拓展利用OKhttp来实现
主要功能包含:
- 单个文件可分几个部分同时下载,
- 多个文件可同时下载
- 可配置同时下载多个文件最大数:超过了进入等待队列,结束一个就从队列里面取一个再下载
- 可暂停 :恢复下载时候,已经下载的部分还在,从上次暂停最后一个位置起再继续下载:即:
断点续传
- 生命周期自动管理 :比如,如果从
Activity
触发,当Activity在finish之后自动停止下载。如果从后台service
触发下载,可多个Activity同时监听进度,只有当服务停止后,会自动停止。
二、下载器实现思路:
整体下载思路图
- 从上面图可以看出来:
- 比如我们依次点了4个按钮下载4个文件,
这4个文件就开始同时下载了
。这里需要4个线程, - 在支持断点续传条件下:我们把每个文件配置成
3个部分下载
,这里每个文件又再次需要3个线程
-
如果我们配置的最大并行下载文件数为5:那么前3个进入正在下载集合,第4个进入等待队列,等到前3个中任何一个下载 成功,失败,或者被手动暂停,那么就从等待队列里面取出第4个就开始自动下载。 OKhttp里面也有类似相同的逻辑。
-
有没有感觉像 迅雷,和百度网盘
部分代码实现:
kotlin
/**
* 同时下载的任务数量
*/
var maxTaskNumber = 3
/**
* 任务map正在下载的
*/
private val runningMapTask by lazy { ConcurrentHashMap<String, WXDownloadFileTask>() }
/** 下载的所有存储 task的key **/
private val runningMapKey by lazy { ConcurrentHashMap<Int, String>() }
/** 等待队列**/
private val waitingDeque by lazy { ConcurrentLinkedQueue<WXDownloadFileTask>() }
//触发调用下载方法
fun download(coroutineScope: CoroutineScope, which: Int, fileSiteURL: String, strDownloadDir: String, fileSaveName: String, fileAsyncNumb: Int = 1) {
coroutineScope.launch(Dispatchers.IO) {
WLog.i(this@WXDownloadManager, "download ${Thread.currentThread().name}")
val downloadTask = WXDownloadFileTask(which, fileSiteURL, strDownloadDir, fileSaveName, channel, fileAsyncNumb)
val key = StringBuilder().append(which).append(fileSiteURL).append(strDownloadDir).append(fileSaveName).append(fileAsyncNumb).toString()
if (runningMapTask.size < maxTaskNumber) {
runningMapKey.takeUnless { it.containsKey(which) }?.put(which, key)
if (!runningMapTask.containsKey(key)) {
runningMapTask[key] = downloadTask
downloadTask.download()
}
} else {
runningMapKey.takeUnless { it.containsKey(which) }?.let {
it[which] = key
waitingDeque.takeUnless { it.contains(downloadTask) }?.add(downloadTask)
}
downloadTask.waiting()
WLog.e(this@WXDownloadManager, "正在等待:${waitingDeque.size}")
}
}
}
//初始化下载最大文件数,在此监听到正在下载的有成功,失败,暂停,就从等待队列里面取出去下载
fun downloadInit(coroutineScope: CoroutineScope, maxTaskNumber: Int) {
this.maxTaskNumber = maxTaskNumber
coroutineScope.launch {
WLog.i(this@WXDownloadManager, "downloadInit ${Thread.currentThread().name}")
channel.consumeEach { s ->
when (s) {
is WXState.Succeed, is WXState.Failed, is WXState.Pause -> {
runningMapKey.takeIf { it.containsKey(s.which) }?.let {
runningMapTask.remove(it[s.which])
it.remove(s.which)
}
WLog.e(this@WXDownloadManager, "等待:${waitingDeque.size}")
waitingDeque.takeIf { it.size > 0 }?.poll()?.run {
val key = StringBuilder().append(this.which).append(this.fileSiteURL).append(this.strDownloadDir).append(this.fileSaveName).append(this.fileAsyncNumb).toString()
if (!runningMapTask.containsKey(key)) {
runningMapTask[key] = this@run
coroutineScope.launch(Dispatchers.IO) {
download()
}
}
}
}
else -> {
}
}
_downloadStateFlow.emit(s)
}
}
}
三、是否支持断点续传下载,文件长度的获取
- 我们要想实现每个文件分片下载,就需要提前知道文件总长度,如果,文件总长度拿不到,那么就无法进行分片,也就无法进行断点续传。
- 在Android 7.0以上获取长度是
httpConnection.contentLengthLong
- 在Android 7.0以下获取长度是
httpConnection.contentLength.toLong()
- 如果还是获取不到,就直接根据文件输入流来读取文件长度。具体代码如下
- 可设置开启单个文件分片分块下载的文件大小阈值,如果文件大小总体本身就很小,没有必要开启几个协程线程分片下载,只需要一个协程线程就可以了(代码这里可以设置成配置模式,后续会进行优化整理)
- 我们也可以从返回请求头里面读取到是否有Accept-Ranges字段来判断是否支持断点续传
kotlin
httpConnection?.let {
val responseCode = httpConnection.responseCode
if (responseCode <= 400) {
val acceptRanges = it.getHeaderField("Accept-Ranges")
downLoadFileBean.isRange = ("bytes" == acceptRanges)
WLog.i(this, "$mis-支持断点续传:${downLoadFileBean.isRange}")
fileLength = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
httpConnection.contentLengthLong // 设置下载长度
} else {
httpConnection.contentLength.toLong() // 设置下载长度
}
WLog.i(this, "$mis-请求返回fileLength:$fileLength")
if (fileLength == -1L) {
val inputstream = httpConnection.inputStream
val swapStream = ByteArrayOutputStream()
val buff = ByteArray(512)
var rc = 0
while ((inputstream.read(buff, 0, 512).also { rc = it }) > 0) {
swapStream.write(buff, 0, rc)
}
val b = swapStream.toByteArray()
fileLength = b.size.toLong()
WLog.i(this, "$mis-请求返回fileLength2:$fileLength")
inputstream.close()
swapStream.close()
}
downLoadFileBean.fileLength = fileLength
if (fileLength < 1L * 1024 * 1024) {
//如果文件大小小于1M 默认就只分一块下载
downLoadFileBean.fileAsyncNumb = 1
}
return true // 失败成功
}
WLog.i(this, "$mis-请求返回responseCode=$responseCode,连接失败")
}
四、断点文件位置存取,分片起始位置设置
- 当我们知道要下载的文件总大小时候,我们需要根据我们对每个下载文件设置的分片数进行计算,每一片部分块的文件的起始位置。计算起始位置代码如下:
scss
val fileThreadSize = fileLength / fileAsyncNumb // 每个线程需要下载的大小
for (i in 0..<fileAsyncNumb) {
tempFile[i].createNewFile()
tempFileFos[i] = RandomAccessFile(downLoadFileBean.tempFile[i], "rw")
startPos[i] = fileThreadSize * i
if (i == fileAsyncNumb - 1) {
endPos[i] = fileLength
} else {
endPos[i] = fileThreadSize * (i + 1) - 1
}
tempFileFos[i].writeLong(startPos[i])
}
-
这里涉及到
RandomAccessFile
的断点续传的用法:当一个文件被设置成比如3块下载,那么需要3个RandomAccessFile来保存下载到当前位置,RandomAccessFile.writeLong()
:方法可以保存下当前已经下载到的位置。当下载到中途被停止时,下次继续从当前位置开始下载:即断点续传,我们可以通过startPos[i] = tempFileFos[i].readLong()
,这样就可以读取到上次已经下载到的位置。 -
我们保存的总共有哪些文件?
1)要下载的临时文件只有一个,通过RandomAccessFile可以移到相应的起始位置开始写入
2)记录分片的起始位置文件,有几个分片,就有几个文件
-
现在我们知道怎么分片了,知道怎么计算每一块的其实位置了,知道怎么保存下载的位置了。接下来开始下载!!!
五、管理:开启单个文件多任务分片下载,下载成功,失败判断
- 在当前协程作用域里面,根据文件分片数量,开启对应的
协程async(Dispatchers.IO)
去下载,让该协程在IO线程里面调度。 - 所有分块协程开启完之后,让每个协程等待完成,
Deferred.await()
,这是协程最基础的用法 - 当所有的
Deferred.await()
都等到结果后。会执行后面的代码,然后判断已经下载的文件大小是否等于,我们前面最初拿到的文件大小,如果相等,则说明下载成功。否则下载失败 - 这里成功和失败的状态通过
Channel
把状态发送出去 - 当不支持断点续传时候,退回只有一个线程下载。
ini
val fileAsynNum: Int = downLoadFileBean.fileAsyncNumb
WLog.i(this, [email protected] + "开始")
val isRange = downLoadFileBean.isRange
if (isRange) {
val sets = mutableSetOf<Deferred<Any>>()
for (i in 0 until fileAsynNum) {
val downloadDeferred = async(Dispatchers.IO) {
WXRangeDownload(downLoadFileBean, channel, startPos[i], endPos[i], i, stateHolder).runDownload()
}
sets.add(downloadDeferred)
}
sets.forEach { it.await() }
} else {
val downloadDeferred = async(Dispatchers.IO) {
WXRangeDownload(downLoadFileBean, channel, stateHolder = stateHolder).runDownload()
}
downloadDeferred.await()
}
val file = downLoadFileBean.saveFile
// 删除临时文件
val downloadFileSize = file.length()
var msg = "失败"
if (downloadFileSize == fileLength) {
msg = "成功"
downLoadFileBean.isDownSuccess = true // 下载成功
channel.send(stateHolder.succeed)
tempFile.forEach {
it.delete()
}// 临时文件删除
// 下载成功,处理解析文件
} else {
if (downLoadFileBean.isAbortDownload) channel.send(stateHolder.pause)
else channel.send(stateHolder.failed)
}
val end = System.currentTimeMillis()
WLog.i(this, msg + "下载'${downLoadFileBean.fileSaveName}'花时:${(end - start).toDouble() / 1000}秒")
六、真正开启分片下载逻辑实现
- 文件分片下载,每一片要知道自己的开始位置和结束位置,
- 告诉服务端分片起始位置设置:
httpConnection.setRequestProperty("Range", "bytes=$startPos-$endPos")
,把起始位置放在请求头部 - 支持分片下载返回状态判断:
if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_PARTIAL) { 开始分片读取下载 }
- 对于临时文件要先将其实位置 移动到当前下载的开始位置开始往里面写入:
file.seek(startPos)转到文件指针位置
- 下载是一个循环读取过程,要判断当前协程是否在还在运行
isActive
- 记录当前分片的起始位置,每写入一点,要重新修改记录起始位置,即已经下载到哪个位置了,要记录下来
- 怎么知道当前分块已经下载完成,只需要把每次读取的累计到起点位置值上,当起始位置累计到 大于等于 分片结束位置,即是当前分片下载结束。
- 下载文件暂停 : 每个要下载的文件封装了下载文件的所有属性参数,同时加入了isAbortDownload,当它被暂停是设置为true时候,循环读取自动跳出,下载结束,下次继续下载,其实是重新执行下载,从上次已经下载的位置开始下载而已。
- 下载进度值:通过channel把下载进度值发送出去,那边供UI展示下载进度百分比。
- 部分代码如下:
ini
if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_PARTIAL) {
inputStream = con.inputStream // 打开输入流
var len = 0
val b = ByteArray(1024)
tempFile.seek(0L)
file.seek(startPos)
while (isActive && !downLoadFileBean.isAbortDownload && !isOK && (inputStream.read(b).also { len = it }) != -1) {
file.write(b, 0, len) // 写入临时数据文件,外性能需要提高
count += len.toLong()
startPos += len.toLong()
tempFile.writeLong(startPos) // 写入断点数据文件
if ((count - myFileLength) > 1024 * 50) {
myFileLength = count
var tempSize = 0L
val file = downLoadFileBean.saveFile
if (file.exists()) {
tempSize = file.length()
}
val nPercent = (tempSize * 100 / downLoadFileBean.fileLength).toInt()
channel.send(stateHolder.downloading.apply { progress = nPercent })
}
// if (endPos - startPos < 1024 * 2) WLog.e(this@WXRealDownload, "${mis} len:$len startPos:$startPos endPos:$endPos")
if (startPos >= endPos) {
isOK = true
} // 下载完成
}
if (isOK) {
WLog.e(this, "$mis 下载完成")
} else
WLog.e(this, "$mis 下载暂停")
七、总结
本文主要从最基础,最简单的易懂的方式介绍了基于kotlin+Compose+协程+Flow+Channel实现多文件异步同时分片断点续传下载:可以分为以下几个步骤
- 下载器实现思路,类似百度网盘下载,迅雷下载,配置最大下载数,超过即等待,任意结束,取出等待的开始下载,直到全部下载完成。
- 是否支持断点续传下载,文件长度的获取,当**
contentLengthLong
**拿不到文件长度时候怎么处理 - 断点文件位置存取,分片起始位置设置:怎么存取下载位置,分片起始位置计算
- 管理:开启单个文件多任务分片下载,下载成功,失败判断以及当不支持断点续传时候退回单线程下载
- 真正开启分片下载逻辑实现