title: Android使用OKHttp构建带进度回调的多文件下载器
date: 2018-09-29
categories: Android
tags: [Android,下载器,教程]
最近重构掌上重邮的教务新闻时遇到了一个问题:
如何制作一个支持同时下载多个文件,并且进行进度回调的下载器。
查阅并学习了一些资料后实现了需要的功能,在这里整理汇总
前言 && 避雷
本文主要介绍:如何使用OKHttp来构建带进度回调的文件下载器
本文适合对象:想要从构建的过程中学习操作的意义的Android开发者
本文例子均为Kotlin编写
思路
个人习惯,做事情之前先理清思路,大多数博客都没有关于思路的讲解,个人感觉Ctrl C+V和搬砖过于相似。
回调接口
分析需求(多文件下载,进度回调),很明显是一个类似应用商店的下载,那么我们回调的时候应该把每个回调分开进行传递。最简单的方法是每个下载传一个独特的接口进去;还有一种是给回调的每个方法加上id参数,使用同一个回调接口进行下载
监听进度
按照原生的写法,是在每次从网络流读入后记录读入的量,进行回调,那么只要在okhttp对应的位置进行修改,添加上回调就好
下载完成
完成后应该写入文件,此时进度回调应该是满的,但是下载完成的回调并没有调用,而是在完成写入文件后调用。
总结
流程:用户点击UI,选中多个下载。下载器接收请求url和监听器,给请求设置监听,让okhttp进行下载。根据id回调,统计下载结束的数量,写入文件完成后回调文件。
我认为这里应该分成UI(Activity)、数据控制器(ViewModel)、下载器(DownloadManager)、下载/写文件/打开文件
正文
下载器
回调接口
为了让下载器和需求的多下载解耦,我结合使用了前面提到的两种接口,从实现单下载入手,构建单文件下载的接口
import java.io.File
/**
* Author: Hosigus
* Date: 2018/9/23 18:06
* Description: 下载进度回调
*/
interface RedDownloadListener {
fun onDownloadStart()
fun onProgress(currentBytes: Long, contentLength: Long)
fun onSuccess(file: File)
fun onFail(e: Throwable)
}
监听OkHttp下载进度
要实现监听OkHttp的下载进度,我们需要从ResponseBody
的fun source(): BufferedSource
入手,以源的流作为真实的下载进度。
那我们重写ResponseBody
,代码如下:
import okhttp3.ResponseBody
import okio.Buffer
import okio.BufferedSource
import okio.ForwardingSource
import okio.Okio
/**
* Author: Hosigus
* Date: 2018/9/23 18:08
* Description: 重写ForwardingSource的read方法,在read方法中计算百分比,回调进度
*/
class RedResponseBody(private val responseBody: ResponseBody,
private val listener: RedDownloadListener
) : ResponseBody() {
private val source by lazy {
Okio.buffer(
object : ForwardingSource(responseBody.source()) {
private var bytesRead = 0L
override fun read(sink: Buffer, byteCount: Long): Long {
val read = super.read(sink, byteCount)
if (read != -1L) {
bytesRead += read
listener.onProgress(bytesRead, responseBody.contentLength())
}
return read
}
}
)
}
override fun contentLength() = responseBody.contentLength()
override fun contentType() = responseBody.contentType()
override fun source(): BufferedSource = source
}
要将ResponseBody
应用到OkHttp中,需要添加Interceptor
重写Interceptor
,代码如下:
import okhttp3.Interceptor
import okhttp3.Response
/**
* Author: Hosigus
* Date: 2018/9/23 19:23
* Description: 将原ResponseBody拦截转换成RedResponseBody
*/
class RedDownloadInterceptor(private val listener: RedDownloadListener) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
val body = response.body() ?: return response
return response.newBuilder().body(RedResponseBody(body, listener)).build()
}
}
最后调用addNetworkInterceptor
方法,将Interceptor
添加到OkHttp的Client
中,就实现了带进度回调的下载器
Manager代码
下载器代码如下:
import android.os.Environment
import okhttp3.*
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
/**
* Author: Hosigus
* Date: 2018/9/24 16:18
* Description: 下载的入口
*/
object DownloadManager {
fun download(listener: RedDownloadListener, url: String, fileName: String) {
val client = OkHttpClient.Builder()
.addNetworkInterceptor(RedDownloadInterceptor(listener))
.build()
listener.onDownloadStart()
client.newCall(Request.Builder().url(url).build())
.enqueue(object : retrofit2.Callback<ResponseBody> {
override fun onFailure(call: Call<ResponseBody>, t: Throwable) {
listener.onFail(t)
}
override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
val body = response.body() ?: return
val state = Environment.getExternalStorageState()
if (Environment.MEDIA_MOUNTED != state && Environment.MEDIA_MOUNTED_READ_ONLY != state) {
listener.onFail(Exception("permission deny"))
return
}
val ins: InputStream
val fos: FileOutputStream
try {
ins = body.byteStream()
val file = File(Environment.getExternalStoragePublicDirectory(DIRECTORY_DOWNLOADS),
"$fileName.${splitFileType(response.headers()["Content-Disposition"])}")
fos = FileOutputStream(file)
val bytes = ByteArray(1024)
var length = ins.read(bytes)
while (length != -1) {
fos.write(bytes, 0, length)
length = ins.read(bytes)
}
fos.flush()
listener.onSuccess(file)
} catch (e: Exception) {
listener.onFail(e)
}
}
})
}
}
注:其中关于文件的后缀,是由响应头中动态获取的
response.headers()["Content-Disposition"]?.let {
it.substring(it.indexOf("filename="), it.length).substringAfterLast(".")
}
更详细的内容请参考我的另一篇博客
其实到这里,本篇博客的标题内容已经结束了,之后的算作是后日谈,也算是使用实例,因为是为了解耦做了一定的操作。
控制器
回调接口
给UI的回调接口,根据UI改变的需要设计
interface NewsDownloadListener {
fun onDownloadStart()
fun onProgress(id: Int, currentBytes: Long, contentLength: Long)
fun onDownloadEnd(id: Int, file: File? = null, e: Throwable? = null)
}
控制下载
控制器接收确定的下载连接List,和监听器,进行下载。
当然,下载前需要进行权限检测,我这里使用了RxPermissions进行权限请求
最后下载代码如下:
fun download(rxPermissions: RxPermissions, list: List<NewsAttachment>, listener: NewsDownloadListener) {
checkPermission(rxPermissions) { isGranted ->
if (isGranted) {
listener.onDownloadStart()
list.forEachIndexed { pos, it ->
DownloadManager.download(object : RedDownloadListener {
override fun onDownloadStart() {}
override fun onProgress(currentBytes: Long, contentLength: Long) {
listener.onProgress(pos, currentBytes, contentLength)
}
override fun onSuccess(file: File) {
listener.onDownloadEnd(pos, file)
}
override fun onFail(e: Throwable) {
listener.onDownloadEnd(pos, e = e)
}
}, it.url, it.name)
}
} else {
listener.onDownloadEnd(-1, e = Exception("permission deny"))
}
}
}
private fun checkPermission(rxPermissions: RxPermissions, result: (Boolean) -> Unit) {
rxPermissions.request(WRITE_EXTERNAL_STORAGE).subscribe(result).lifeCycle()
}
可以看到,控制器放弃了每次下载的onDownloadStart回调,而是在第一次下载开始前就回调UI下载开始;回调进度的时候添加上了id;合并了回调结果。
这都是为了UI做的中转变换,因为下载已经解耦了,所以可以按需求来进行控制层的接口变更,而不需要更改下载器的代码。
UI层
根据应用商店的排布,他需要独立管理下载完成的文件,因此我将下载的文件和数量均交给Listener管理
private val files = mutableListOf<File>()
private var downloadNeedSize = 0
private var downloadEndSize = 0
当进行下载的时候,进行NeedSize
的初始化
downloadNeedSize = list.size
viewModel.download(rxPermissions, list, this)
带ID的单文件下载完成回调
@Synchronized
override fun onDownloadEnd(id: Int, file: File?, e: Throwable?) {
if (file != null) {
files.add(file)
} else {
e?.printStackTrace()
AndroidSchedulers.mainThread().scheduleDirect {
...//UI提示相关错误
}
}
downloadEndSize++
if (downloadEndSize == downloadNeedSize) {
AndroidSchedulers.mainThread().scheduleDirect {
...//全部下载完成
}
}
}
另外俩回调就根据UI需求写了
写在最后
感觉功能并不复杂,使用Android原生也能实现,甚至改改DownloadManager就可以用了
但是就是不想那样做,可能是因为那样的做法写过了,想尝试一些别的操作
最开始尝试的是Retrofit+RxJava,之后发现过于麻烦,失去了使用他们的意义,最后还是决定从okhttp入手
然后是为了解耦合,将下载器和管理器分开了,虽然这样就多写了一层接口,但是我没有想到啥更好的解法
最后的问题就是懒得把进度管理和View再加一层隔开,是直接让Activity实现的NewsDownloadListener接口,这其实不太好……