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

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

一、前言

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

这里涉及到我之前写的一篇Compose 进度条按钮UI文章Android下载进度百分比按钮,Compose轻松秒杀实现

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

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

主要功能包含:

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

二、下载器实现思路:

整体下载思路图

  • 从上面图可以看出来:
  1. 比如我们依次点了4个按钮下载4个文件,这4个文件就开始同时下载了。这里需要4个线程,
  2. 在支持断点续传条件下:我们把每个文件配置成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)
        }
    }
}

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

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

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

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

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

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

  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展示下载进度百分比。
  • 部分代码如下:
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实现多文件异步同时分片断点续传下载:可以分为以下几个步骤

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

项目地址

感谢阅读:

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

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

相关推荐
颜颜颜yan_38 分钟前
【HarmonyOS5】掌握UIAbility启动模式:Singleton、Specified、Multiton
后端·架构·harmonyos
键盘歌唱家1 小时前
mysql索引失效
android·数据库·mysql
一块plus1 小时前
Polkadot 的 Web3 哲学:从乔布斯到 Gavin Wood 的数字自由传承
人工智能·程序员·架构
webbin1 小时前
Compose @Immutable注解
android·android jetpack
it_xiao_xiong2 小时前
微服务集成seata分布式事务 at模式快速验证
分布式·微服务·架构
无知的前端2 小时前
Flutter开发,GetX框架路由相关详细示例
android·flutter·ios
互联网搬砖老肖2 小时前
Web 架构之 Kubernetes 弹性伸缩策略设计
前端·架构·kubernetes
玲小珑2 小时前
Auto.js 入门指南(十二)网络请求与数据交互
android·前端
webbin2 小时前
Compose 副作用
android·android jetpack
小傅哥3 小时前
Docker 环境配置(一键安装)
后端·架构