Android使用OKHttp构建带进度回调的多文件下载器

最近重构掌上重邮的教务新闻时遇到了一个问题:

如何制作一个支持同时下载多个文件,并且进行进度回调的下载器。

查阅并学习了一些资料后实现了需要的功能,在这里整理汇总

前言 && 避雷

本文主要介绍:如何使用OKHttp来构建带进度回调的文件下载器

本文适合对象:想要从构建的过程中学习操作的意义的Android开发者

本文例子均为Kotlin编写

思路

个人习惯,做事情之前先理清思路,大多数博客都没有关于思路的讲解,个人感觉Ctrl C+V和搬砖过于相似。

回调接口

分析需求(多文件下载,进度回调),很明显是一个类似应用商店的下载,那么我们回调的时候应该把每个回调分开进行传递。最简单的方法是每个下载传一个独特的接口进去;还有一种是给回调的每个方法加上id参数,使用同一个回调接口进行下载

监听进度

按照原生的写法,是在每次从网络流读入后记录读入的量,进行回调,那么只要在okhttp对应的位置进行修改,添加上回调就好

下载完成

完成后应该写入文件,此时进度回调应该是满的,但是下载完成的回调并没有调用,而是在完成写入文件后调用。

总结

流程:用户点击UI,选中多个下载。下载器接收请求url和监听器,给请求设置监听,让okhttp进行下载。根据id回调,统计下载结束的数量,写入文件完成后回调文件。

我认为这里应该分成UI(Activity)、数据控制器(ViewModel)、下载器(DownloadManager)、下载/写文件/打开文件

正文

下载器

回调接口

为了让下载器和需求的多下载解耦,我结合使用了前面提到的两种接口,从实现单下载入手,构建单文件下载的接口

1
2
3
4
5
6
7
8
9
10
11
12
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的下载进度,我们需要从ResponseBodyfun source(): BufferedSource入手,以源的流作为真实的下载进度。

那我们重写ResponseBody,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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代码

下载器代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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)
}
}
})
}
}

注:其中关于文件的后缀,是由响应头中动态获取的

1
2
3
response.headers()["Content-Disposition"]?.let {
it.substring(it.indexOf("filename="), it.length).substringAfterLast(".")
}

更详细的内容请参考我的另一篇博客

其实到这里,本篇博客的标题内容已经结束了,之后的算作是后日谈,也算是使用实例,因为是为了解耦做了一定的操作。

控制器

回调接口

给UI的回调接口,根据UI改变的需要设计

1
2
3
4
5
interface NewsDownloadListener {
fun onDownloadStart()
fun onProgress(id: Int, currentBytes: Long, contentLength: Long)
fun onDownloadEnd(id: Int, file: File? = null, e: Throwable? = null)
}

控制下载

控制器接收确定的下载连接List,和监听器,进行下载。

当然,下载前需要进行权限检测,我这里使用了RxPermissions进行权限请求

最后下载代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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管理

1
2
3
private val files = mutableListOf<File>()
private var downloadNeedSize = 0
private var downloadEndSize = 0

当进行下载的时候,进行NeedSize的初始化

1
2
downloadNeedSize = list.size
viewModel.download(rxPermissions, list, this)

带ID的单文件下载完成回调

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@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接口,这其实不太好……

文章目录
  1. 1. 前言 && 避雷
  2. 2. 思路
    1. 2.1. 回调接口
    2. 2.2. 监听进度
    3. 2.3. 下载完成
    4. 2.4. 总结
  3. 3. 正文
    1. 3.1. 下载器
      1. 3.1.1. 回调接口
      2. 3.1.2. 监听OkHttp下载进度
      3. 3.1.3. Manager代码
  4. 4. 控制器
    1. 4.1. 回调接口
    2. 4.2. 控制下载
  5. 5. UI层
  6. 6. 写在最后