Kotlin 协程 (十五) ——— Flow + Retrofit 下载文件

1,831 阅读2分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

这篇文章我们使用 Flow + Retrofit 实现一个文件下载的功能。

一、声明 ApiService 接口

使用 Retrofit 需要定义一个接口:

interface DownloadService {

    @Streaming
    @GET
    fun download(@Url url: String): Call<ResponseBody>
}

Retrofit 下载文件时,默认会存储到缓存中,如果要下载大文件,需要用 @Streaming 将返回数据转换成流。

二、定义 DownloadState

定义一些记录下载状态的类:

sealed class DownloadState {
    data class InProgress(val progress: Int) : DownloadState()
    data class Success(val file: File) : DownloadState()
    data class Error(val throwable: Throwable) : DownloadState()
}

InProgress 用于回调下载进度,Success 表示下载成功,Error 表示下载失败。

这里让他们都继承自 DownloadState,待会构建 Flow 时,就可以使用 DownloadState 作为流中传输的对象类型。

三、定义 DownloadManager

在 DownloadManager 中实现下载功能:

object DownloadManager {
    suspend fun download(url: String, file: File): Flow<DownloadState> {
        return flow {
            val retrofit = Retrofit.Builder()
                .baseUrl(UrlUtils.getBaseUrl(url))
                .build()
            val response = retrofit.create(DownloadService::class.java).download(url).execute()
            if (response.isSuccessful) {
                saveToFile(response.body()!!, file) {
                    emit(DownloadState.InProgress(it))
                }
                emit(DownloadState.Success(file))
            } else {
                emit(DownloadState.Error(IOException(response.toString())))
            }
        }.catch {
            emit(DownloadState.Error(it))
        }.flowOn(Dispatchers.IO)
    }

    private inline fun saveToFile(responseBody: ResponseBody, file: File, progressListener: (Int) -> Unit) {
        val total = responseBody.contentLength()
        var bytesCopied = 0
        var emittedProgress = 0
        file.outputStream().use { output ->
            val input = responseBody.byteStream()
            val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
            var bytes = input.read(buffer)
            while (bytes >= 0) {
                output.write(buffer, 0, bytes)
                bytesCopied += bytes
                bytes = input.read(buffer)
                val progress = (bytesCopied * 100 / total).toInt()
                if (progress - emittedProgress > 0) {
                    progressListener(progress)
                    emittedProgress = progress
                }
            }
        }
    }
}

其中,用到的 UrlUtils 类如下:

object UrlUtils {

    /**
     * 从url分割出BaseUrl
     */
    fun getBaseUrl(url: String): String {
        var mutableUrl = url
        var head = ""
        var index = mutableUrl.indexOf("://")
        if (index != -1) {
            head = mutableUrl.substring(0, index + 3)
            mutableUrl = mutableUrl.substring(index + 3)
        }
        index = mutableUrl.indexOf("/")
        if (index != -1) {
            mutableUrl = mutableUrl.substring(0, index + 1)
        }
        return head + mutableUrl
    }
}

这个工具用于从 url 中分割出 baseUrl。

在 download 方法中,我们构建了一个 Flow<DownloadState> 对象,使用 retrofit 的 execute 方法拿到 Response 对象,然后通过 saveToFile 方法将返回的 ResponseBody 写入文件中。

在写入过程中,当进度发生改变后,发射 DownloadState.InProgress 通知客户端。

下载完成后,发射 DownloadState.Success。

下载出现错误时,发射 DownloadState.Error。下载出错的地方包括:

  • response.isSuccessful 为 false。
  • response.body() 为 null,此时 response.body()!! 会抛出空指针异常,被 flow 的 catch 捕获到。
  • 网络请求中发生的其他异常,被 flow 的 catch 捕获到。

最后,通过 flowOn 使其在 IO 线程中运行。

四、使用 DownloadManager

在 Activity 或者 Fragment 中使用 DownloadManager:

lifecycleScope.launchWhenCreated {
    DownloadManager.download(
        "https://upload-images.jianshu.io/upload_images/5809200-a99419bb94924e6d.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240",
        File(getExternalFilesDir(""), "image.png")
    ).collect {
        when (it) {
            is DownloadState.InProgress -> {
                Log.d("~~~", "download in progress: ${it.progress}.")
            }
            is DownloadState.Success -> {
                Log.d("~~~", "download finished.")
            }
            is DownloadState.Error -> {
                Log.d("~~~", "download error: ${it.throwable}.")
            }
        }
    }
}

这里我们采用 lifecycleScope 作为协程作用域,然后使用 DownloadManager 下载一张图片,保存到应用的缓存文件夹下。

这里调用 collect() 收集数据时,是在应用的主线程中,所以可以做一些 UI 相关的操作。本例中只是打印了一些日志。

运行程序,Log 控制台输出如下:

~~~: download in progress: 11.
~~~: download in progress: 23.
~~~: download in progress: 35.
~~~: download in progress: 47.
~~~: download in progress: 59.
~~~: download in progress: 71.
~~~: download in progress: 83.
~~~: download in progress: 95.
~~~: download in progress: 100.
~~~: download finished.

五、小结

下载文件很适合使用 Flow,通过 flowOn 切换到 IO 线程下载,然后在主线程中收集,非常方便。