基於Retrofit2實現的LycheeHttp-多工下載的實現

VeCharm發表於2019-03-19

一個請求庫,除了請求,上傳功能,還需要一個下載的功能。而且下載功能很常用,最常用的就是App的更新了,作為一個下載器,斷點續傳也是必不可少的。

github地址:github.com/VipMinF/Lyc…

本庫其他相關文章

框架引入

dependencies {
    implementation 'com.vecharm:lycheehttp:1.0.2'
}
複製程式碼

如果你喜歡用RxJava 還需要加入

dependencies {
     //RxJava
     implementation 'com.vecharm.lycheehttp:lychee_rxjava:1.0.2'
    //或者 RxJava2
     implementation 'com.vecharm.lycheehttp:lychee_rxjava2:1.0.2'
}
複製程式碼

API的定義

    @Download
    @GET("https://xxx/xxx.apk")
    fun download(): Call<DownloadBean>

    @GET
    @Download
    fun download(@Url url: String, @Header(RANGE) range: String): Call<DownloadBean>
複製程式碼

API的使用

        //普通下載
        getService<API>().download().request(File(App.app.externalCacheDir, "xx.apk")) {
            onUpdateProgress ={fileName, currLen, size, speed, progress ->  /*進度更新*/}
            onSuccess = { Toast.makeText(App.app, "${it.downloadInfo?.fileName} 下載完成", Toast.LENGTH_SHORT).show() }
            onErrorMessage={}
            onCompleted={}
        }
        
        //斷點續傳
         getService<API>().download(url, range.bytesRange()).request(file) {
            onUpdateProgress ={fileName, currLen, size, speed, progress ->  /*進度更新*/}
            onSuccess = { Toast.makeText(App.app, "${id}下載完成", Toast.LENGTH_LONG).show() }
            onErrorMessage={}
            onCompleted={}
        }
複製程式碼

對與下載的API需要使用Download進行註解,斷點續傳的需要新增@Header(RANGE)引數。

實現流程

第一步,先實現最基本的下載功能,再去考慮多工,斷點續傳。下載功能,比較容易實現,retrofit2.Callback::onResponse 中返回的ResponseBody讀取就可以了。


open class ResponseCallBack<T>(private val handler: IResponseHandler<T>) : Callback<T> {
   var call: Call<T>? = null
    override fun onFailure(call: Call<T>, t: Throwable) {
        this.call = call
        handler.onError(t)
        handler.onCompleted()
    }
    override fun onResponse(call: Call<T>, response: Response<T>) {
        this.call = call
        try {
            val data = response.body()
            if (response.isSuccessful) {
                if (data == null) handler.onError(HttpException(response)) else onHandler(data)
            } else handler.onError(HttpException(response))

        } catch (t: Throwable) {
            handler.onError(t)
        }
        handler.onCompleted()
    }

    open fun onHandler(data: T) {
        if (call?.isCanceled == true) return
        if (handler.isSucceeded(data)) handler.onSuccess(data)
        else handler.onError(data)
    }
}

class DownloadResponseCallBack<T>(val tClass: Class<T>, val file: RandomAccessFile, val handler: IResponseHandler<T>) :
    ResponseCallBack<T>(handler) {
    override fun onHandler(data: T) {
      //這裡將data讀取出來 存進file檔案中
        super.onHandler(data)
    }
}
複製程式碼

看起來很簡單,實際上又是一把淚。發現等了好久才將開始讀取,而且下載速度飛快,經除錯發現,又是日誌那邊下載了,因為資料已經下載了,所以後面就沒下載的事,都是從快取中讀取,所以速度飛快。如果把日誌去掉了,感覺很不方便,而且也會導致普通的請求沒有日誌列印。想到第一個方法是,下載和普通請求從getService就開始區分開來,下載的去掉日誌,但這個方法不符合我封裝的框架,只好另外想辦法。

最終想到的辦法是,自己來實現日誌的功能。當然不是自己寫,先是把LoggingInterceptor的程式碼複製過來,然後在chain.proceed之後進行處理,為啥要在這之後處理,先看看下OKHttp的Interceptor的使用。

class CoreInterceptor(private val requestConfig: IRequestConfig) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        ·····
        val response = chain.proceed(request)
        ···
       return  response
    }
}
複製程式碼

像這樣的Interceptor 我們可以新增很多個,引數是Interceptor.Chain一個鏈。所以應該可以想像這是一個鏈式處理,一層層深入,詳細可看RealInterceptorChain

處理過程

基於Retrofit2實現的LycheeHttp-多工下載的實現
由上圖可以得出,我們只要在LoggingInterceptor之前處理Response就可以了。所以,思路是自定義一個ResponseBody返回給LoggingInterceptor,這個ResponseBody裡面定義一個CallBack,然後在LoggingInterceptor中實現這個CallBack,等到下載完成就可以通知LoggingInterceptor讀取列印日誌,對於下載來說,當然只能列印頭部資料,因為ResponseBody中的資料已經被讀走了,但是下載只是列印頭部資料的日誌已經足夠了。只有一個自定義一個ResponseBody如何區分這是下載還是普通請求,總不能普通請求返回的資料也給我攔截了吧。對於這一點,只需要自定義CoverFactoryresponseBodyConverter中處理。

 /**
     *
     * 獲取真實的ResponseCover,處理非下載情況返回值的轉換
     * 如果是Download註解的方法,則認為這是一個下載方法
     * */
    override fun responseBodyConverter(type: Type, annotations: Array<Annotation>, retrofit: Retrofit): Converter<ResponseBody, *>? {
        var delegateResponseCover: Converter<*, *>? = null
        retrofit.converterFactories().filter { it != this }.find {
            delegateResponseCover = it.responseBodyConverter(type, annotations, retrofit); delegateResponseCover != null
        }
        return CoreResponseCover(annotations.find { it is Download } != null, delegateResponseCover as Converter<ResponseBody, Any>)
    }
/**
 * 所有Response的body都經過這裡
 * */
class CoreResponseCover(private val isDownloadMethodCover: Boolean, private val delegateResponseCover: Converter<ResponseBody, Any>) :
    Converter<ResponseBody, Any> {

    override fun convert(value: ResponseBody): Any {
        .......
        //非下載的情況
        if (!isDownloadMethodCover) {
            (responseBody as? CoreResponseBody).also {
              // 通知日誌讀取
                it?.startRead()
                it?.notifyRead()
            }
            return delegateResponseCover.convert(value)
        } else {
            //下載的情況
            return responseBody ?: value
        }
    }
}
複製程式碼

之後就是下載時的資料讀取和回撥速度計算了。

    /**
     *
     *  使用這個方法讀取ResponseBody的資料
     * */
    fun read(callback: ProgressHelper.ProgressListener?, dataOutput: IBytesReader? = null) {
        var currLen = rangeStart

        try {
            val fileName = downloadInfo?.fileName ?: ""
            progressCallBack = object : CoreResponseBody.ProgressCallBack {
                val speedComputer = ProgressHelper.downloadSpeedComputer?.newInstance()
                override fun onUpdate(isExhausted: Boolean, currLen: Long, size: Long) {
                    speedComputer ?: return
                    if (speedComputer.isUpdate(currLen, size)) {
                        callback?.onUpdate(fileName, currLen, size, speedComputer.computer(currLen, size), speedComputer.progress(currLen, size))
                    }
                }
            }
            startRead()
            val source = source()
            val sink = ByteArray(1024 * 4)
            var len = 0
            while (source.read(sink).also { len = it } != -1) {
                currLen += dataOutput?.onUpdate(sink, len) ?: 0
                //返回當前range用於斷點續傳
                progressCallBack?.onUpdate(false, currLen, rangeEnd)
            }
            progressCallBack?.onUpdate(true, currLen, rangeEnd)
            //通知日誌讀取,由於日誌已經在上面消費完了,所以在只能獲取頭部資訊
            notifyRead()
        } catch (e: java.lang.Exception) {
            e.printStackTrace()
        } finally {
            Util.closeQuietly(source())
            dataOutput?.onClose()
        }
    }
複製程式碼

在上面的回撥中,返回了currLen也就是range用於斷點續傳。接下來,開始完成最後的斷點續傳。

斷點續傳的實現

斷點續傳,顧名思義就是記錄上次斷開的點,在下次新的請求的時候告訴伺服器從哪裡開始下載。續傳的步驟

  1. 儲存下載的進度,也就是上面的currLen
  2. 建立新的請求,在請求頭上設定Range:bytes=123-,123表示已經下載完成,需要跳過的位元組。
  3. 伺服器收到後會返回Content-Range:bytes 123-299/300的頭部
  4. 使用RandomAccessFile.seek(123)的方式追加後面的資料

前面已經寫完了基礎的下載方式,斷點續傳只需要在進行一層封裝。對於請求頭加入range這個比較簡單,在API定義的時候就可以做了。

    @GET
    @Download
    fun download(@Url url: String, @Header(RANGE) range: String): Call<DownloadBean>
複製程式碼

封裝的思路是定義一個Task類用來儲存下載的資訊,比如下載路徑,檔名稱,檔案大小,已經下載的大小,下載時間,本次請求的ID

open class Task : Serializable {

    val id = UUID.randomUUID()
  
    var createTime = System.currentTimeMillis()

    var range = 0L
  
    var progress = 0

    var fileName: String? = null

    var fileSize = 0L

    var url: String? = null

    var filePath: String? = null
    
    
     var onUpdate = { fileName: String, currLen: Long, size: Long, speed: Long, progress: Int ->
        //儲存進度資訊
        //儲存檔案資訊
        //通知UI更新
        this.updateUI?.invoke() ?: Unit
    }
    
    open var updateUI: (() -> Unit)? = null
    set(value) {
            field = value
            value?.invoke()
    }
        
    var service: Call<*>? = null

    var isCancel = false
        private set

    fun cancel() {
        isCancel = true
        service?.cancel()
    }

    fun resume() {
        if (!isCancel) return
        url ?: return
        filePath ?: return
        isCancel = false
        download(url!!, File(filePath))
       
    }
    
    fun cache() {
        //todo 將任務資訊儲存到本地
    }

    fun download(url: String, saveFile: File) {
        this.url = url
        this.filePath = saveFile.absolutePath
        if (range == 0L) saveFile.delete()
        val file = RandomAccessFile(saveFile, "rwd").also { it.seek(range) }
        service = getService<API>().download(url, range.bytesRange()).request(file) {
            onUpdateProgress = onUpdate
            onSuccess = { Toast.makeText(App.app, "${id}下載完成", Toast.LENGTH_LONG).show() }
        }
    }
}
複製程式碼

UI更新的回撥,item是上面定義的Task

    item.updateUI = {
        helper.getView<TextView>(R.id.taskName).text = "任務:${item.id}"
        helper.getView<TextView>(R.id.speed).text = "${item.getSpeed()}"
        helper.getView<TextView>(R.id.progress).text = "${item.progress}%"
        helper.getView<ProgressBar>(R.id.progressBar).also {
                it.max = 100
                it.progress = item.progress
        }
    }
複製程式碼

任務的請求

 addDownloadTaskButton.setOnClickListener {
            val downloadTask = DownloadTask()
            val file = File(App.app.externalCacheDir, "xx${adapter.data.size + 1}.apk")
            downloadTask.download("https://xxx.xxx.apk", file)
            adapter.addData(downloadTask)
        }
複製程式碼

後話:第一次寫文章,寫的頭暈腦漲,寫的不太好。如果這篇文章對各位大大有用的話,可以給我點個贊鼓勵一下我哦,感謝!

相關文章