Android Retrofit下载进度

18

我是retrofit的新手。我已经搜索过但没有找到简单的答案。我想知道如何在通知栏中显示下载进度,或者至少显示一个进度对话框,指定进程百分比和下载文件的大小。 这是我的代码:

public interface ServerAPI {
    @GET
    Call<ResponseBody> downlload(@Url String fileUrl);

    Retrofit retrofit =
            new Retrofit.Builder()
                    .baseUrl("http://192.168.43.135/retro/") 
                    .addConverterFactory(GsonConverterFactory.create())
                    .build();

}

public void download(){
    ServerAPI api = ServerAPI.retrofit.create(ServerAPI.class);
    api.downlload("https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_120x44dp.png").enqueue(new Callback<ResponseBody>() {
        @Override
        public void onResponse(Call<ResponseBody> call, Response<ResponseBody> response) {
            try {
                File path = Environment.getExternalStorageDirectory();
                File file = new File(path, "file_name.jpg");
                FileOutputStream fileOutputStream = new FileOutputStream(file);
                IOUtils.write(response.body().bytes(), fileOutputStream);
            }
            catch (Exception ex){
            }
        }


        @Override
        public void onFailure(Call<ResponseBody> call, Throwable t) {
        }
    });
}

please guide me if you can. thanks


这是一个很好的解决方案来解决问题:https://dev59.com/wFgR5IYBdhLWcg3wAJFK#49398941,我已经测试过了,希望能对你有所帮助。 - MichaelZ
5个回答

22

你需要创建一个特定的OkHttp客户端,它将拦截网络请求并发送更新。这个客户端应该只用于下载。

首先,你需要一个接口,就像这个:

public interface OnAttachmentDownloadListener {
    void onAttachmentDownloadedSuccess();
    void onAttachmentDownloadedError();
    void onAttachmentDownloadedFinished();
    void onAttachmentDownloadUpdate(int percent);
}

你的下载调用应该返回一个 ResponseBody ,我们将从中扩展以获得下载进度。

private static class ProgressResponseBody extends ResponseBody {

    private final ResponseBody responseBody;
    private final OnAttachmentDownloadListener progressListener;
    private BufferedSource bufferedSource;

    public ProgressResponseBody(ResponseBody responseBody, OnAttachmentDownloadListener progressListener) {
        this.responseBody = responseBody;
        this.progressListener = progressListener;
    }

    @Override public MediaType contentType() {
        return responseBody.contentType();
    }

    @Override public long contentLength() {
        return responseBody.contentLength();
    }

    @Override public BufferedSource source() {
        if (bufferedSource == null) {
            bufferedSource = Okio.buffer(source(responseBody.source()));
        }
        return bufferedSource;
    }

    private Source source(Source source) {
        return new ForwardingSource(source) {
            long totalBytesRead = 0L;

            @Override public long read(Buffer sink, long byteCount) throws IOException {
                long bytesRead = super.read(sink, byteCount);

                totalBytesRead += bytesRead != -1 ? bytesRead : 0;

                float percent = bytesRead == -1 ? 100f : (((float)totalBytesRead / (float) responseBody.contentLength()) * 100);

                if(progressListener != null)
                    progressListener.onAttachmentDownloadUpdate((int)percent);

                return bytesRead;
            }
        };
    }
}

然后你需要像这样创建你的OkHttpClient

public OkHttpClient.Builder getOkHttpDownloadClientBuilder(OnAttachmentDownloadListener progressListener) {
    OkHttpClient.Builder httpClientBuilder = new OkHttpClient.Builder();

    // You might want to increase the timeout
    httpClientBuilder.connectTimeout(20, TimeUnit.SECONDS);
    httpClientBuilder.writeTimeout(0, TimeUnit.SECONDS);
    httpClientBuilder.readTimeout(5, TimeUnit.MINUTES);

    httpClientBuilder.addInterceptor(new Interceptor() {
        @Override
        public Response intercept(Chain chain) throws IOException {
            if(progressListener == null) return chain.proceed(chain.request());

        Response originalResponse = chain.proceed(chain.request());
        return originalResponse.newBuilder()
                .body(new ProgressResponseBody(originalResponse.body(), progressListener))
                .build();
        }
    });

    return httpClientBuilder;
}

最后,你只需要通过传递你的新OkHttp客户端来以不同的方式创建Retrofit客户端。根据你的代码,你可以使用类似这样的代码:

 public Retrofit getDownloadRetrofit(OnAttachmentDownloadListener listener) {

    return new Retrofit.Builder()
                .baseUrl("http://192.168.43.135/retro/") 
                .addConverterFactory(GsonConverterFactory.create())
                .client(getOkHttpDownloadClientBuilder(listener).build())
                .build();

}

你的侦听器将处理您要创建的通知或其他任何内容。


onResponse() 之后收到 onAttachmentDownloadUpdate(),这意味着文件已经下载完成。 - Divy Soni
这种方法可能会导致处理大文件时出现OutOfMemoryError(对我来说是400+mb)。最终我使用了这个答案:https://dev59.com/wlgQ5IYBdhLWcg3w3Hl-#65588935 - anro

17

这里是使用Flow的另一个Kotlin解决方案

interface MyService {
    @Streaming // allows streaming data directly to fs without holding all contents in ram
    @GET
    suspend fun getUrl(@Url url: String): ResponseBody
}

sealed class Download {
    data class Progress(val percent: Int) : Download()
    data class Finished(val file: File) : Download()
}

fun ResponseBody.downloadToFileWithProgress(directory: File, filename: String): Flow<Download> =
    flow {
        emit(Download.Progress(0))

        // flag to delete file if download errors or is cancelled
        var deleteFile = true
        val file = File(directory, "${filename}.${contentType()?.subtype}")

        try {
            byteStream().use { inputStream ->
                file.outputStream().use { outputStream ->
                    val totalBytes = contentLength()
                    val data = ByteArray(8_192)
                    var progressBytes = 0L

                    while (true) {
                        val bytes = inputStream.read(data)

                        if (bytes == -1) {
                            break
                        }

                        outputStream.write(data, 0, bytes)
                        progressBytes += bytes

                        emit(Download.Progress(percent = ((progressBytes * 100) / totalBytes).toInt()))
                    }

                    when {
                        progressBytes < totalBytes ->
                            throw Exception("missing bytes")
                        progressBytes > totalBytes ->
                            throw Exception("too many bytes")
                        else ->
                            deleteFile = false
                    }
                }
            }

            emit(Download.Finished(file))
        } finally {
            // check if download was successful

            if (deleteFile) {
                file.delete()
            }
        }
    }
        .flowOn(Dispatchers.IO)
        .distinctUntilChanged()

suspend fun Context.usage() {
    coroutineScope {
        myService.getUrl("https://www.google.com")
            .downloadToFileWithProgress(
                externalCacheDir!!,
                "my_file",
            )
            .collect { download ->
                when (download) {
                    is Download.Progress -> {
                        // update ui with progress
                    }
                    is Download.Finished -> {
                        // update ui with file
                    }
                }
            }
    }
}

你为什么要做 ${contentType()?.subtype} - nimi0112
4
contentType()类似于"video/mp4",其中subtype部分是"mp4"。 - Robert C.
这是一个非常棒的答案!代码优美,View层使用起来非常简单,采用了最新的最佳实践,封闭类的应用也很好。如果可以的话,我会给它20个赞。 - Vin Norman
很棒的回答!这行代码outputStream.channel是用来做什么的?看起来它似乎没有起到任何作用。 - Granjero
outputStream.channel 没有任何作用。一定是因为疏忽而留下的。干得好,@Granjero。 - Robert C.
1
问题是ResponseBody只有在文件完全下载后才返回。然后这个流程返回写入磁盘的文件进度,而不是所需的下载进度。 - Amr

4

这是使用 Kotlin 协程的变体

  1. 指定 API 接口。我们需要 @Streaming 注释来告诉 Retrofit 我们想要手动处理响应体。否则,Retrofit 会尝试直接将您的文件写入 RAM。
interface Api {

    @Streaming
    @GET("get-zip-ulr/{id}")
    fun getZip(@Path("id") id: Int): Call<ResponseBody>
}
  1. 创建数据源,用于控制下载过程
class FilesDataSource(private val parentFolder: File, private val api: Api) {

    suspend fun downloadZip(id: Int, processCallback: (Long, Long) -> Unit): File {
        val response = api.getZip(id).awaitResponse()// returns the response, but it's content will be later
        val body = response.body()
        if (response.isSuccessful && body != null) {
            val file = File(parentFolder, "$id")
            body.byteStream().use { inputStream ->
                FileOutputStream(file).use { outputStream ->
                    val data = ByteArray(8192)
                    var read: Int
                    var progress = 0L
                    val fileSize = body.contentLength()
                    while (inputStream.read(data).also { read = it } != -1) {
                        outputStream.write(data, 0, read)
                        progress += read
                        publishProgress(processCallback, progress, fileSize)
                    }
                    publishProgress(processCallback, fileSize, fileSize)
                }
            }
            return file
        } else {
            throw HttpException(response)
        }
    }

    private suspend fun publishProgress(
        callback: (Long, Long) -> Unit,
        progress: Long, //bytes
        fileSize: Long  //bytes
    ) {
        withContext(Dispatchers.Main) { // invoke callback in UI thtread
            callback(progress, fileSize)
        }
    }
}

现在您可以在您的 ViewModelPresenter 中执行 downloadZip() 方法,并为其提供回调函数,该函数将链接到某个 ProgerssBar。下载完成后,您将收到已下载的文件。

3
它不是先加载数据,然后再提供输入流吗?因为当我将HttpLoggingInterceptor添加到Retrofit时,我发现所有字节都通过流先下载,而进度条并不是真正的下载进度,而是从输入流复制到另一个流中。如果我说得不对,请告诉我。 - NAUSHAD
我和@NAUSHAD的行为相同。直接调用Response.body()会下载文件。所以,我不太确定这是如何工作的。 - Ov3r1oad

3
提供的答案都不正确,这里有一个正确的解决方案,它正确地结合了两种方法。
首先为下载创建一个新的Retrofit对象,此Retrofit对象不应包含任何日志拦截器,因为这会引发java.lang.IllegalStateException: closed异常。
fun interface ResponseBodyListener {
    fun update(responseBody: ResponseBody)
}

fun getDownloaderRetrofit(listener: ResponseBodyListener): Retrofit = Retrofit.Builder()
    .baseUrl("https://example.com")// <-- this is just a placeholder, we will not use it either way in the request.
    .client(initHttpDownloadListenerClient(listener))
    .build()

private fun initHttpDownloadListenerClient(listener: ResponseBodyListener): OkHttpClient {
    return OkHttpClient.Builder()
        .connectTimeout(20, TimeUnit.SECONDS)
        .readTimeout(5, TimeUnit.MINUTES)
        .writeTimeout(0, TimeUnit.SECONDS)
        .addNetworkInterceptor { chain ->
            chain.proceed(chain.request()).also { originalResponse ->
                originalResponse.body?.let { listener.update(it) }
            }
        }
        .build()
}

在addNetworkInterceptor中,我们会尽快获取响应体,以便跟踪实际的下载进度。它包含从服务器下载的数据流。

以下是API接口

interface DownloadFilesApi {
    @GET
    @Streaming
    suspend fun downloadFile(@Url url: String): ResponseBody
}

以下是请求本身

@OptIn(ExperimentalCoroutinesApi::class)
override suspend fun downloadFile(url: String, directory: File, fileName: String): Flow<DownloadFileState> =
    callbackFlow {
        val listener = ResponseBodyListener { responseBody: ResponseBody ->
            this.launch {
                responseBody.downloadToFileWithProgress(directory, fileName).collect {
                    trySend(it)
                }
            }
        }
        getDownloaderRetrofit(listener).create(DownloadFilesApi::class.java).downloadFile(url)
        awaitClose()
    }

请注意,downloadFile是挂起(coroutine)函数,这会暂停协程直到下载完成。这里的callbackFlow用作普通回调(callback)和flow结果之间的桥梁。
最后,downloadToFileWithProgress与@Robert编写的相同,区别在于此处显示文件下载进度而不是实际下载完成后写入桌面的进度。参考文件:RetrofitExtentions.kt。
    fun ResponseBody.downloadToFileWithProgress(directory: File, filename: String): Flow<DownloadFileState> = flow {
        emit(DownloadFileState.Progress(0))

        // flag to delete file if download errors or is cancelled
        var deleteFile = true
        val file = File(directory, filename)

        try {
            byteStream().use { inputStream ->
                file.outputStream().use { outputStream ->
                    val totalBytes = contentLength()
                    val data = ByteArray(8_192)
                    var progressBytes = 0L

                    while (true) {
                        val bytes = inputStream.read(data)
                        if (bytes == -1) {
                            break
                        }

                        outputStream.write(data, 0, bytes)
                        progressBytes += bytes

                        val progress = ((progressBytes * 100) / totalBytes).toInt()
                        emit(DownloadFileState.Progress(percent = progress))
                    }

                    when {
                        progressBytes < totalBytes ->
                            throw Exception("missing bytes")
                        progressBytes > totalBytes ->
                            throw Exception("too many bytes")
                        else ->
                            deleteFile = false
                    }
                }
            }

            emit(DownloadFileState.Finished(file))
        } finally {
            if (deleteFile) {
                file.delete()
            }
        }
    }

为了完整起见,这里介绍如何获取您要保存视频的根文件夹。

        fun videosRootFolder(context: Context): File {
            return File(
                Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
                APP_DIRECTORY_NAME
            ).run {
                if (exists()) {
                    this
                } else {
                    this.mkdir()
                    this
                }
            }
        }

请问您能否包含ResponseBodyListener的定义,因为目前似乎缺失了。 - JamieH
2
@JamieH 是的,我确实在开头添加了它。你也可以只使用lambda而不是整个接口。 - Amr
应该接受这个答案,因为其他答案只是展示已经下载文件的复制。实际上,有趣的是,对于这种简单明显的情况,没有“开箱即用”的解决方案。 - Ruslan

-4

你可以在这里看一下,你不必自己实现,其背后的想法是获取请求的内容长度,在缓冲区中写入时计算进度。


您提供的链接是关于上传文件而不是下载,因此实现方式是不同的。 - w3bshark

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接