大型异步下载器:基于kotlin+Compose+协程+Flow+Channel实现多文件异步同时分片断点续传下载

1,155 阅读9分钟

ezgif-8eaac153aa4e9a.gif

掌握 多个文件断点续传同时下载,并且单个文件分片同时下载,对于学习多线程,多协程都是有很大帮助的。

一、前言

在之前的文章中Android提升开发测试效率,程序员应该多干了些什么?,我有提到过之前我手撸一个下载器,它是多文件同时下载,并且它也是单个文件分片同时断点续传下载。当时那个代码是 N多年前写的,全部基于: java + 线程池 + HttpURLConnection 来实现的。有部分网友也想要研究,我承诺后面用现代方式在实现一遍:基于kotlin+Compose+协程+Flow+Channel来实现
这里涉及到我之前写的一篇Compose 进度条按钮UI文章Android下载进度百分比按钮,Compose轻松秒杀实现

本文重点介绍是如何实现的:

  1. 用最原始的方式来实现。
  2. 没有用过多的设计模式和高级的写法,可能代码不美观,本文不为了代码炫技
  3. 主要是为了让人更加清晰的明白实现思路。
  4. 当然后面文章还会介绍和优化,可能写法上会有变化。
  5. 后面会拓展利用OKhttp来实现

主要功能包含:

  1. 单个文件可分几个部分同时下载
  2. 多个文件可同时下载
  3. 可配置同时下载多个文件最大数:超过了进入等待队列,结束一个就从队列里面取一个再下载
  4. 可暂停:恢复下载时候,已经下载的部分还在,从上次暂停最后一个位置起再继续下载:即:断点续传
  5. 生命周期自动管理:比如,如果从Activity触发,当Activity在finish之后自动停止下载。如果从后台service触发下载,可多个Activity同时监听进度,只有当服务停止后,会自动停止。

二、下载器实现思路:

整体下载思路图 img_v3_02n5_d47c6dcc-0de2-4da4-ab7e-ccdb64e4d01g.jpg

  • 从上面图可以看出来:
  1. 比如我们依次点了4个按钮下载4个文件,这4个文件就开始同时下载了。这里需要4个线程,
  2. 在支持断点续传条件下:我们把每个文件配置成3个部分下载,这里每个文件又再次需要3个线程
  • 如果我们配置的最大并行下载文件数为5:那么前3个进入正在下载集合,第4个进入等待队列,等到前3个中任何一个下载 成功,失败,或者被手动暂停,那么就从等待队列里面取出第4个就开始自动下载。 OKhttp里面也有类似相同的逻辑。

  • 有没有感觉像 迅雷,和百度网盘

部分代码实现:

/**
 * 同时下载的任务数量
 */
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)
        }
    }
}

三、是否支持断点续传下载,文件长度的获取

  • 我们要想实现每个文件分片下载,就需要提前知道文件总长度,如果,文件总长度拿不到,那么就无法进行分片,也就无法进行断点续传。
  1. 在Android 7.0以上获取长度是 httpConnection.contentLengthLong
  2. 在Android 7.0以下获取长度是 httpConnection.contentLength.toLong()
  3. 如果还是获取不到,就直接根据文件输入流来读取文件长度。具体代码如下
  4. 可设置开启单个文件分片分块下载的文件大小阈值,如果文件大小总体本身就很小,没有必要开启几个协程线程分片下载,只需要一个协程线程就可以了(代码这里可以设置成配置模式,后续会进行优化整理)
  • 我们也可以从返回请求头里面读取到是否有Accept-Ranges字段来判断是否支持断点续传
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,连接失败")
}

四、断点文件位置存取,分片起始位置设置

  • 当我们知道要下载的文件总大小时候,我们需要根据我们对每个下载文件设置的分片数进行计算,每一片部分块的文件的起始位置。计算起始位置代码如下:
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)记录分片的起始位置文件,有几个分片,就有几个文件

  • 现在我们知道怎么分片了,知道怎么计算每一块的其实位置了,知道怎么保存下载的位置了。接下来开始下载!!!

五、管理:开启单个文件多任务分片下载,下载成功,失败判断

  1. 在当前协程作用域里面,根据文件分片数量,开启对应的 协程async(Dispatchers.IO) 去下载,让该协程在IO线程里面调度。
  2. 所有分块协程开启完之后,让每个协程等待完成,Deferred.await(),这是协程最基础的用法
  3. 当所有的 Deferred.await() 都等到结果后。会执行后面的代码,然后判断已经下载的文件大小是否等于,我们前面最初拿到的文件大小,如果相等,则说明下载成功。否则下载失败
  4. 这里成功和失败的状态通过 Channel 把状态发送出去
  5. 当不支持断点续传时候,退回只有一个线程下载。
val fileAsynNum: Int = downLoadFileBean.fileAsyncNumb
WLog.i(this, this@WXDownloadCoroutineManager.mis + "开始")
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}秒")

六、真正开启分片下载逻辑实现

  1. 文件分片下载,每一片要知道自己的开始位置和结束位置,
  2. 告诉服务端分片起始位置设置:httpConnection.setRequestProperty("Range", "bytes=$startPos-$endPos"),把起始位置放在请求头部
  3. 支持分片下载返回状态判断:if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_PARTIAL) { 开始分片读取下载 }
  4. 对于临时文件要先将其实位置 移动到当前下载的开始位置开始往里面写入:file.seek(startPos)转到文件指针位置
  5. 下载是一个循环读取过程,要判断当前协程是否在还在运行 isActive
  6. 记录当前分片的起始位置,每写入一点,要重新修改记录起始位置,即已经下载到哪个位置了,要记录下来
  7. 怎么知道当前分块已经下载完成,只需要把每次读取的累计到起点位置值上,当起始位置累计到 大于等于 分片结束位置,即是当前分片下载结束。
  8. 下载文件暂停: 每个要下载的文件封装了下载文件的所有属性参数,同时加入了isAbortDownload,当它被暂停是设置为true时候,循环读取自动跳出,下载结束,下次继续下载,其实是重新执行下载,从上次已经下载的位置开始下载而已。
  9. 下载进度值:通过channel把下载进度值发送出去,那边供UI展示下载进度百分比。
  • 部分代码如下:
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实现多文件异步同时分片断点续传下载:可以分为以下几个步骤

  1. 下载器实现思路,类似百度网盘下载,迅雷下载,配置最大下载数,超过即等待,任意结束,取出等待的开始下载,直到全部下载完成。
  2. 是否支持断点续传下载,文件长度的获取,当 contentLengthLong 拿不到文件长度时候怎么处理
  3. 断点文件位置存取,分片起始位置设置:怎么存取下载位置,分片起始位置计算
  4. 管理:开启单个文件多任务分片下载,下载成功,失败判断以及当不支持断点续传时候退回单线程下载
  5. 真正开启分片下载逻辑实现

项目地址

感谢阅读:

欢迎用你发财的小手 关注,点赞、收藏

这里你会学到不一样的东西