使用Retrofit暂停和恢复下载

6
我使用这个教程在我的应用程序中实现下载文件:https://www.learn2crack.com/2016/05/downloading-file-using-retrofit.html 问题是,如果网络速度慢或者网络波动甚至只有一秒钟,下载就会被永久停止。是否有一种方式,应用程序可以检测到互联网不活动(已连接但实际上没有工作),然后暂停下载并在互联网良好时恢复。
或者有一些替代方案,让用户不会感到沮丧吗?

你解决了吗?如果是,请把它发布为答案。 - Parag Kadam
不,正如你所看到的,似乎没有人在SO上知道。 - Paras Sidhu
这有点难以置信,但如果我得到解决方案,我一定会在这里发布。 - Parag Kadam
我在SO上许多线程中都一无所获,感到失望。 - Paras Sidhu
我在答案部分发布了解决方案,伙计们。非常好用 ^_^ - Клаус Шварц
2个回答

2
我今天也遇到了这个问题,但没有找到任何好的解决方案,它们可以一次性下载简历、提供进度通知和使用BufferedSink进行快速nio操作。
以下是使用Retrofit2和RxJava2实现的方法。代码是用Kotlin编写的Android代码,但很容易移植到纯JVM:只需摆脱AndroidSchedulers。
由于代码是在短时间内从头开始编写的,并且几乎没有经过测试,因此可能包含错误。
import com.google.gson.GsonBuilder
import io.reactivex.Observable
import io.reactivex.ObservableEmitter
import io.reactivex.ObservableOnSubscribe
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.functions.Consumer
import io.reactivex.functions.Function
import io.reactivex.schedulers.Schedulers
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import okio.Buffer
import okio.BufferedSink
import okio.ForwardingSource
import okio.Okio
import org.slf4j.LoggerFactory
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Streaming
import retrofit2.http.Url
import java.io.File
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import java.util.regex.Pattern

class FileDownloader(val baseUrl: String) {

    private val log = LoggerFactory.getLogger(FileDownloader::class.java)

    private val expectedFileLength = ConcurrentHashMap<String, Long>()
    private val eTag = ConcurrentHashMap<String, String>()

    private val apiChecker: FileDownloaderAPI

    init {
        apiChecker = Retrofit.Builder()
                .baseUrl(baseUrl)
                .client(OkHttpClient())
                .addConverterFactory(GsonConverterFactory.create(GsonBuilder().setLenient().create()))
                .build()
                .create(FileDownloaderAPI::class.java)

    }


    /**
     *
     * @return File Observable
     */
    fun download(
            urlPath: String,
            file: File,
            dlProgressConsumer: Consumer<Int>): Observable<File> {
        return Observable.create(ObservableOnSubscribe<File> {
            val downloadObservable: Observable<Int>

            if (file.exists() &&
                    file.length() > 0L &&
                    file.length() != expectedFileLength[file.name]
                    ) {
                /**
                 * Try to get rest of the file according to:
                 * http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
                 */
                downloadObservable = apiChecker.downloadFile(
                        urlPath,
                        "bytes=${file.length()}-",
                        eTag[file.name] ?: "0"
                ).flatMap(
                        DownloadFunction(file, it)
                )
            } else {
                /**
                 * Last time file was fully downloaded or not present at all
                 */
                if (!file.exists())
                    eTag[file.name] = ""

                downloadObservable = apiChecker.downloadFile(
                        urlPath,
                        eTag[file.name] ?: "0"
                ).flatMap(
                        DownloadFunction(file, it)
                )

            }

            downloadObservable
                    .observeOn(AndroidSchedulers.mainThread())
                    .subscribe(dlProgressConsumer)

        }).subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
    }

    private inner class DownloadFunction(
            val file: File,
            val fileEmitter: ObservableEmitter<File>
    ) : Function<Response<ResponseBody>, Observable<Int>> {

        var contentLength = 0L

        var startingByte = 0L
        var endingByte = 0L
        var totalBytes = 0L


        var contentRangePattern = "bytes ([0-9]*)-([0-9]*)/([0-9]*)"
        fun parseContentRange(contentRange: String) {
            val matcher = Pattern.compile(contentRangePattern).matcher(contentRange)
            if (matcher.find()) {
                startingByte = matcher.group(1).toLong()
                endingByte = matcher.group(2).toLong()
                totalBytes = matcher.group(3).toLong()
            }
        }

        var totalRead = 0L

        var lastPercentage = 0

        override fun apply(response: Response<ResponseBody>): Observable<Int> {
            return Observable.create { subscriber ->
                try {
                    if (!response.isSuccessful) {
                        /**
                         * Including response 304 Not Modified
                         */
                        fileEmitter.onError(IllegalStateException("Code: ${response.code()}, ${response.message()}; Response $response"))
                        return@create
                    }

                    contentLength = response.body().contentLength()


                    log.info("{}", response)
                    /**
                     * Receiving partial content, which in general means that download is resumed
                     */
                    if (response.code() == 206) {
                        parseContentRange(response.headers().get("Content-Range"))
                        log.debug("Getting range from {} to {} of {} bytes", startingByte, endingByte, totalBytes)
                    } else {
                        endingByte = contentLength
                        totalBytes = contentLength
                        if (file.exists())
                            file.delete()
                    }

                    log.info("Starting byte: {}, ending byte {}", startingByte, endingByte)

                    totalRead = startingByte

                    eTag.put(file.name, response.headers().get("ETag"))
                    expectedFileLength.put(file.name, totalBytes)


                    val sink: BufferedSink
                    if (startingByte > 0) {
                        sink = Okio.buffer(Okio.appendingSink(file))
                    } else {
                        sink = Okio.buffer(Okio.sink(file))
                    }

                    sink.use {
                        it.writeAll(object : ForwardingSource(response.body().source()) {

                            override fun read(sink: Buffer, byteCount: Long): Long {
                                val bytesRead = super.read(sink, byteCount)

                                totalRead += bytesRead

                                /**
                                 * May not wok good if we get some shit from the middle of the file,
                                 * though that's not the case of this function, as we plan only to
                                 * resume downloads
                                 */
                                val currentPercentage = (totalRead * 100 / totalBytes).toInt()
                                if (currentPercentage > lastPercentage) {
                                    val progress = "$currentPercentage%"
                                    lastPercentage = currentPercentage
                                    subscriber.onNext(currentPercentage)
                                    log.debug("Downloading {} progress: {}", file.name, progress)
                                }
                                return bytesRead
                            }
                        })
                    }

                    subscriber.onComplete()
                    fileEmitter.onNext(file)
                    fileEmitter.onComplete()
                } catch (e: IOException) {
                    log.error("Last percentage: {}, Bytes read: {}", lastPercentage, totalRead)
                    fileEmitter.onError(e)
                }
            }
        }

    }

    interface FileDownloaderAPI {


        @Streaming @GET
        fun downloadFile(
                @Url fileUrl: String,
                @Header("If-None-Match") eTag: String
        ): Observable<Response<ResponseBody>>

        @Streaming @GET
        fun downloadFile(
                @Url fileUrl: String,

                // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35
                @Header("Range") bytesRange: String,

                // https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.27
                @Header("If-Range") eTag: String
        ): Observable<Response<ResponseBody>>
    }
}

然后将其用于您想要的位置

    val fileDownloader = FileDownloader("http://wwww.example.com")
    fileDownloader.download(
            "/huge-video.mkv",
            File("file-where-I-will-save-this-video.mkv"),
            Consumer { progress ->
                updateProgressNotificatuin()
            }
    ).subscribe({
        log.info("File saved at path {}", it.absolutePath)
    },{
        log.error("Download error {}", it.message, it)
    },{
        log.info("Download completed")
    })

此示例中使用的依赖项:

dependencies {
    compile "org.jetbrains.kotlin:kotlin-stdlib:1.1.1"
    compile 'io.reactivex.rxjava2:rxandroid:2.0.1'

    compile 'com.squareup.retrofit2:retrofit:2.2.0'
    compile 'com.squareup.retrofit2:converter-gson:2.2.0'
    compile 'com.squareup.retrofit2:adapter-rxjava2:2.2.0'
    compile 'com.google.code.gson:gson:2.7'


    compile 'org.slf4j:slf4j-api:1.7.25'
}

嗨,我有同样的问题(我正在使用Kotlin),我尝试了你的代码,但它没有起作用,甚至由于非空类型转换而无法编译。 你能否写一个示例代码,可能不需要使用rxjava? 我正在尝试使用协程来实现它,谢谢! - Plokko
你的目标平台是哪个?你遇到了什么关于“null-casting”的问题? - Клаус Шварц
大多数情况下在响应体上使用空断言; 顺便说一句,我从你的代码中获得了灵感,阅读了一些RFC代码,现在我应该有一个基于协程的Kotlin工作代码! - Plokko
好的!请在这里分享作为答案 :) - Клаус Шварц
当然!我只是在提交之前进行了精细化和测试 ;) - Plokko

0

这是我基于 Клаус Шварц 的 Kotlin 实现:

我使用了协程,因为它们使代码非常易于阅读和使用; 我还使用了 ru.gildor.coroutines:kotlin-coroutines-retrofit 来为 Retrofit 添加协程支持。

import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import okhttp3.logging.HttpLoggingInterceptor
import okio.Buffer
import okio.BufferedSink
import okio.ForwardingSource
import okio.Okio
import retrofit2.Call
import retrofit2.HttpException
import retrofit2.Response
import retrofit2.Retrofit
import retrofit2.http.GET
import retrofit2.http.HeaderMap
import retrofit2.http.Streaming
import retrofit2.http.Url
import ru.gildor.coroutines.retrofit.awaitResponse
import java.io.File
import java.util.concurrent.TimeUnit
import java.util.regex.Pattern


object FileDownloader{

    private val Service by lazy { serviceBuilder().create<FileDownloaderInterface>(FileDownloaderInterface::class.java) }

    val baseUrl = "http://www.your-website-base-url.com"

    private fun serviceBuilder(): Retrofit {
        //--- OkHttp client ---//
        val okHttpClient = OkHttpClient.Builder()
                .readTimeout(60, TimeUnit.SECONDS)
                .connectTimeout(60, TimeUnit.SECONDS)

        //--- Add authentication headers ---//
        okHttpClient.addInterceptor { chain ->
            val original = chain.request()

            // Just some example headers
            val requestBuilder = original.newBuilder()
                    .addHeader("Connection","keep-alive")
                    .header("User-Agent", "downloader")

            val request = requestBuilder.build()
            chain.proceed(request)
        }

        //--- Add logging ---//

        if (BuildConfig.DEBUG) {
            // development build
            val logging = HttpLoggingInterceptor()
            logging.setLevel(HttpLoggingInterceptor.Level.BASIC)
            // NOTE: do NOT use request BODY logging or it will not work!

            okHttpClient.addInterceptor(logging)
        }


        //--- Return Retrofit class ---//
        return Retrofit.Builder()
                .client(okHttpClient.build())
                .baseUrl(baseUrl)
                .build()
    }

    suspend fun downloadOrResume(
            url:String, destination: File,
            headers:HashMap<String,String> = HashMap<String,String>(),
            onProgress: ((percent: Int, downloaded: Long, total: Long) -> Unit)? = null
        ){

        var startingFrom = 0L
        if(destination.exists() && destination.length()>0L){
            startingFrom = destination.length()
            headers.put("Range","bytes=${startingFrom}-")
        }
        println("Download starting from $startingFrom - headers: $headers")

        download(url,destination,headers,onProgress)
    }

    suspend fun download(
            url:String,
            destination: File,
            headers:HashMap<String,String> = HashMap<String,String>(),
            onProgress: ((percent: Int, downloaded: Long, total: Long) -> Unit)? = null
        ) {
        println("---------- downloadFileByUrl: getting response -------------")
        val response = Service.downloadFile(url,headers).awaitResponse()
        handleDownloadResponse(response,destination,onProgress)
    }

    fun handleDownloadResponse(
            response:Response<ResponseBody>,
            destination:File,
            onProgress: ((percent: Int, downloaded: Long, total: Long) -> Unit)?
    ) {
        println("-- downloadFileByUrl: parsing response! $response")


        var startingByte = 0L
        var endingByte = 0L
        var totalBytes = 0L


        if(!response.isSuccessful) {
            throw HttpException(response)
            //java.lang.IllegalStateException: Error downloading file: 416, Requested Range Not Satisfiable; Response Response{protocol=http/1.1, code=416, message=Requested Range Not Satisfiable, u
        }
        val contentLength = response.body()!!.contentLength()

        if (response.code() == 206) {
            println("- http 206: Continue download")
            val matcher = Pattern.compile("bytes ([0-9]*)-([0-9]*)/([0-9]*)").matcher(response.headers().get("Content-Range"))
            if (matcher.find()) {
                startingByte = matcher.group(1).toLong()
                endingByte = matcher.group(2).toLong()
                totalBytes = matcher.group(3).toLong()
            }
            println("Getting range from $startingByte to ${endingByte} of ${totalBytes} bytes" )
        } else {
            println("- new download")
            endingByte = contentLength
            totalBytes = contentLength
            if (destination.exists()) {
                println("Delete previous download!")
                destination.delete()
            }
        }


        println("Getting range from $startingByte to ${endingByte} of ${totalBytes} bytes" )
        val sink: BufferedSink
        if (startingByte > 0) {
            sink = Okio.buffer(Okio.appendingSink(destination))
        } else {
            sink = Okio.buffer(Okio.sink(destination))
        }


        var lastPercentage=-1
        var totalRead=startingByte
        sink.use {
            it.writeAll(object : ForwardingSource(response.body()!!.source()) {

                override fun read(sink: Buffer, byteCount: Long): Long {
                    //println("- Reading... $byteCount")
                    val bytesRead = super.read(sink, byteCount)

                    totalRead += bytesRead

                    val currentPercentage = (totalRead * 100 / totalBytes).toInt()
                    //println("Progress: $currentPercentage - $totalRead")
                    if (currentPercentage > lastPercentage) {
                        lastPercentage = currentPercentage
                        if(onProgress!=null){
                            onProgress(currentPercentage,totalRead,totalBytes)
                        }
                    }
                    return bytesRead
                }
            })
        }

        println("--- Download complete!")
    }

    internal interface FileDownloaderInterface{
        @Streaming
        @GET
        fun downloadFile(
                @Url fileUrl: String,
                @HeaderMap headers:Map<String,String>
        ): Call<ResponseBody>
    }
}

示例用法:

    val url = "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-9.4.0-amd64-xfce-CD-1.iso"
    val destination = File(context.filesDir, "debian-9.4.0-amd64-xfce-CD-1.iso")

    //Optional: you can also add custom headers
    val headers = HashMap<String,String>()

    try {
        // Start or continue a download, catch download exceptions
        FileDownloader.downloadOrResume(
                url,
                destination,
                headers,
                onProgress = { progress, read, total ->
                    println(">>> Download $progress% ($read/$total b)")
                });
    }catch(e: SocketTimeoutException){
        println("Download socket TIMEOUT exception: $e")
    }catch(e: SocketException){
        println("Download socket exception: $e")
    }catch(e: HttpException){
        println("Download HTTP exception: $e")
    }

Gradle 依赖

dependencies {
    /** Retrofit 2 **/
    compile 'com.squareup.retrofit2:retrofit:2.4.0'

    // OkHttp for Retrofit request customization
    compile 'com.squareup.okhttp3:okhttp:3.10.0'

    // For http request logging
    compile 'com.squareup.okhttp3:logging-interceptor:3.10.0'

    // Retrofit Kotlin coroutines support
    compile 'ru.gildor.coroutines:kotlin-coroutines-retrofit:0.9.0'
}

注意: 必须启用 Kotlin 协程,目前它们需要作为实验功能启用


你好。我已经尝试了你的Java代码,并添加了拦截器,当网络断开时可以暂停下载60秒。但是当网络重新连接后,下载不会恢复并在超时后停止。你能否在这里检查我的代码:https://github.com/sidhuparas/CDLUMathematicsHub/compare/master...DownloadResume - Paras Sidhu
在提供的链接中,我没有看到任何与您所询问的相关内容。首先,您必须了解连续性是如何工作的,以及您的服务器是否能够提供连续性:如果由于超时而导致下载失败,则已将X字节写入文件,您需要从头开始进行新的请求,并添加“Range: byes = X-”标头,服务器必须返回206连续或什么都不会起作用。然后,您应该将所有新字节附加到已下载的文件中。使用CURL尝试一下,一切都会变得清晰(例如:curl -H "Range: bytes=1000-" http://your.url.com/file.html ....)。 - Plokko

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