一個請求庫,除了請求,上傳功能,還需要一個下載的功能。而且下載功能很常用,最常用的就是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
。
處理過程
由上圖可以得出,我們只要在LoggingInterceptor之前處理Response就可以了。所以,思路是自定義一個ResponseBody
返回給LoggingInterceptor
,這個ResponseBody裡面定義一個CallBack
,然後在LoggingInterceptor
中實現這個CallBack,等到下載完成就可以通知LoggingInterceptor
讀取列印日誌,對於下載來說,當然只能列印頭部資料,因為ResponseBody中的資料已經被讀走了,但是下載只是列印頭部資料的日誌已經足夠了。只有一個自定義一個ResponseBody
如何區分這是下載還是普通請求,總不能普通請求返回的資料也給我攔截了吧。對於這一點,只需要自定義CoverFactory
在responseBodyConverter
中處理。
/**
*
* 獲取真實的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
用於斷點續傳。接下來,開始完成最後的斷點續傳。
斷點續傳的實現
斷點續傳,顧名思義就是記錄上次斷開的點,在下次新的請求的時候告訴伺服器從哪裡開始下載。續傳的步驟
- 儲存下載的進度,也就是上面的
currLen
- 建立新的請求,在請求頭上設定
Range:bytes=123-
,123表示已經下載完成,需要跳過的位元組。 - 伺服器收到後會返回
Content-Range:bytes 123-299/300
的頭部 - 使用
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)
}
複製程式碼
後話:第一次寫文章,寫的頭暈腦漲,寫的不太好。如果這篇文章對各位大大有用的話,可以給我點個贊鼓勵一下我哦,感謝!