基於Retrofit2實現的LycheeHttp-使用動態代理實現上傳

VeCharm發表於2019-03-19

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

本庫其他相關文章

Retrofit2的上傳方式相信大家都有了解,下面是百度的一個栗子

@Multipart
@POST("upload")
Call<ResponseBody> uploadFiles(@PartMap Map<String, RequestBody> map);

RequestBody fb = RequestBody.create(MediaType.parse("text/plain"), "hello,retrofit");
RequestBody fileTwo = RequestBody.create(MediaType.parse("image/*"), new File(Environment.getExternalStorageDirectory() + file.separator + "original.png"));
Map<String, RequestBody> map = new HashMap<>();
//這裡的key必須這麼寫,否則服務端無法識別
map.put("file\"; filename=\""+ file.getName(), fileRQ);
map.put("file\"; filename=\""+ "2.png", fileTwo);
Call<ResponseBody> uploadCall = downloadService.uploadFiles(map);
複製程式碼

這個是使用Retrofit2上傳的一種方式,由上面的程式碼可總結以下5個步驟

  • 首先,new一個Map
  • 然後,new一個File
  • 接著根據檔案型別 建立 MediaType
  • 再然後在根據MediaType 和 File 建立一個RequestBody
  • push 到 Map裡面

其他的方式也差不多,看起來很不好看,不簡約。所以,我設計了一個庫,只為了用更優雅的方式寫API——使用註解完成上面的步驟。

框架引入

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的定義

  1. 根據檔名稱的字尾名獲取,使用Upload 進行註解
    @Upload
    @Multipart
    @POST("http://xxx/xxx")
    fun upload(@Part("file") file: File): Call<ResultBean<UploadResult>>
複製程式碼
  1. 對某個file進行註解,使用FileType("png") 或者FileType("image/png")
    @Multipart
    @POST("http:/xxx/xxx")
    fun upload(@Part("file") @FileType("png") file: File): Call<ResultBean<UploadResult>>
複製程式碼
  1. 對整個方法的所有file引數進行註解,使用MultiFileType("png")或者MultiFileType("image/png")
    @Multipart
    @MultiFileType("png")
    @POST("http://xxx/xxx")
    fun upload(@PartMap map: MutableMap<String, Any>): Call<ResultBean<UploadResult>>
複製程式碼

三個註解可以同時使用,優先順序FileType > MultiFileType > Upload,喜歡哪一種就看你自己了

API的使用

  getService<API>().upload(file).upload {
        onUpdateProgress = { fileName, currLen, size, speed, progress -> /*上傳進度更新*/ }
        onSuccess = { }
        onErrorMessage = { }
        onCompleted = { }
    }
複製程式碼

開始動工

從上面的上傳步驟可知,其實就是要建立一個帶MediaTypeRequestBody,要實現通過註解建立其實不難,只需要寫一個Converter,在請求的時候,獲取註解方法的值進行建立就可以了。

    override fun requestBodyConverter(type: Type, parameterAnnotations: Array<Annotation>, methodAnnotations: Array<Annotation>, retrofit: Retrofit): Converter<*, RequestBody>? {
        return when {
            //引數是File的方式
            type == File::class.java -> FileConverter(parameterAnnotations.findFileType(), methodAnnotations.findMultiType(), methodAnnotations.isIncludeUpload())
           //引數是Map的方式
            parameterAnnotations.find { it is PartMap } != null -> {
                //為map中不是File型別的引數找到合適的Coverter
                var realCover: Converter<*, *>? = null
                retrofit.converterFactories().filter { it != this }.find { it.requestBodyConverter(type, parameterAnnotations, methodAnnotations, retrofit).also { realCover = it } != null }
                if (realCover == null) return null
                return MapParamsConverter(parameterAnnotations, methodAnnotations, realCover as Converter<Any, RequestBody>)
            }
            else -> null
        }
    }
複製程式碼

重點在FileConverter

class FileConverter(private val fileType: FileType?, private val multiFileType: MultiFileType?, private val isAutoWried: Boolean) :
    Converter<File, RequestBody> {
    override fun convert(value: File): RequestBody {
        //獲取字尾名 先判斷FileType 然後上MultiFileType
        var subffix = fileType?.value ?: multiFileType?.value
        //然後上面兩個都沒有設定 判斷是否設定了Upload註解
        if (isAutoWried) if (subffix == null) subffix = value.name?.substringAfterLast(".", "")
        //都沒有設定為了null
        if (subffix == null || subffix.isEmpty()) return create(null, value)
        //判斷設定的是 image/png 還是 png
        val type = (if (subffix.contains("/")) subffix else LycheeHttp.getMediaTypeManager()?.getTypeBySuffix(subffix))
            ?: return create(null, value)
        return create(MediaType.parse(type), value)
    }
}
複製程式碼

對於字尾名對應的MediaType,從http發展至今有多個,所有我整理了一些常用的放到了DefaultMediaTypeManager,大約有300個。

        "png" to "image/png",
        "png" to "application/x-png",
        "pot" to "application/vnd.ms-powerpoint",
        "ppa" to "application/vnd.ms-powerpoint",
        "ppm" to "application/x-ppm",
        "pps" to "application/vnd.ms-powerpoint",
        "ppt" to "application/vnd.ms-powerpoint",
        "ppt" to "application/x-ppt",
        "pr" to  "application/x-pr",
        "prf" to "application/pics-rules",
        "prn" to "application/x-prn",
        .......
複製程式碼

如果以上不夠用,可以繼承DefaultMediaTypeManager進行擴充套件,如果覺得不需要這麼精確的需求,也可以實現IMediaTypeManager 直接返回*/*

以上是上傳時實現自動建立帶MediaType的RequestBody的程式碼。

進度回撥的實現

上傳功能最基本的,肯定要有上傳進度的互動。

  1. 首先,第一步就是將進度獲取出來,再去考慮如何回撥,先有1再有2嘛。根據OKHttp的設計,上傳的資料讀取是再RequestBody的Source中,我們可以再這裡做文章。
class FileRequestBody(private val contentType: MediaType?, val file: File) : RequestBody() {
   .....
    private fun warpSource(source: Source) = object : ForwardingSource(source) {
        private var currLen = 0L
        override fun read(sink: Buffer, byteCount: Long): Long {
            val len = super.read(sink, byteCount)
            currLen += if (len != -1L) len else 0
            return len
        }
    }
}
複製程式碼

寫完了,歡快的測試一下。結果,事情一般沒有這麼簡單,踩坑了。發現第一次的進度是日誌讀取而產生的。去掉日誌吧,又感覺除錯不方便,除錯除錯著,最後發現,讀取時兩個BufferedSink是不同實現的,日誌用的是Buffer,上傳用的是RealBufferedSink,這就好辦了。

    @Throws(IOException::class)
    override fun writeTo(sink: BufferedSink) {
        var source: Source? = null
        try {
            source = Okio.source(file)
            if (sink is Buffer) sink.writeAll(source)//處理被日誌讀取的情況
            else sink.writeAll(warpSource(source))
        } finally {
            Util.closeQuietly(source)
        }
    }
複製程式碼

完美。

  1. 第二步,關聯回撥。這個就有點頭疼了,RequestBody和回撥天高皇帝遠,關係不到啊。但還是攻克了這一個難題。
  • 首先,確定Callback和誰有關係,和Call有關係,Call從哪裡來,從CallAdapter中生成。
  • CallAdapter,我們是可以自定義的。而CallAdapter中有的T adapt(Call<R> call); Call有RequestBody。
  • 最後,三者的關係已經通過CallAdapter關聯的起來,只需要獲得adapt引數返回值,在通過PostStation關聯他們。

使用動態代理獲取引數和返回值

class CoreCallAdapter : CallAdapter.Factory() {
    override fun get(returnType: Type, annotations: Array<Annotation>, retrofit: Retrofit): CallAdapter<*,*>? {
        var adapter: CallAdapter<*, *>? = null
        //獲取真實的 adapterFactory
        retrofit.callAdapterFactories().filter { it != this }
            .find { adapter = it.get(returnType, annotations, retrofit);adapter != null }
            
        return adapter?.let {
            //使用動態代理 
            Proxy.newProxyInstance(CallAdapter::class.java.classLoader, arrayOf(CallAdapter::class.java)) { _, method, arrayOfAnys ->

                val returnObj = when (arrayOfAnys?.size) { // 這裡 Retrofit呼叫 CallAdapter.adapt 獲得返回值物件 在此截獲
                    null, 0 -> method.invoke(adapter)
                    1 -> method.invoke(adapter, arrayOfAnys[0])
                    else -> method.invoke(adapter, arrayOfAnys)
                }
                //從引數中把OkHttpCall拿出 OkHttpCall是Retrofit 封裝的一個請求call 裡面放了本次請求的所有引數
                val okHttpCall = arrayOfAnys?.getOrNull(0) as? Call<*>
                //因為上面我們自定了一個FileRequestBody 通過這個識別是否是和上傳有關的請求
                okHttpCall?.also {  list = getFileRequestBody(okHttpCall.request()?.body())}
                    //將返回值物件的toString 和 UploadFile 關聯起來,因為一次可能上傳多個檔案就用陣列
                    list.forEach { UploadFilePostStation.setCallBack(returnObj.toString(), it) }
                    
                return@newProxyInstance returnObj
            } as CallAdapter<*, *>
        }
    }
複製程式碼

關聯兩者關係的驛站實現

object UploadFilePostStation {
    val map = WeakHashMap<String, ArrayList<UploadFile>>()
    
    // first be executed 在CallAdapter中呼叫這個
    fun setCallBack(callBackToken: String, callbackFile: UploadFile) {
        val list = map[callBackToken] ?: ArrayList()
        if (!list.contains(callbackFile)) list.add(callbackFile)
        map[callBackToken] = list
    }
    
    // second 在CallBack呼叫這個,關聯完移除在驛站的引用
    fun registerProgressCallback(callBackToken: String, listener: ProgressHelper.ProgressListener) {
        map[callBackToken]?.forEach { it.progressListener = listener }
        map[callBackToken]?.clear()
        map.remove(callBackToken)
    }
}
複製程式碼
  1. 最終構成了一條路,接下來都是一些計算速度,計算進度的實現了。都是比較簡單的,如果不喜歡預設提高的方式,可以實現ISpeedComputer介面來實現自己的計算思路。
object ProgressHelper {

    /**
     * 下載速度計算器,可初始化時改變這個值
     * */
    var downloadSpeedComputer: Class<out ISpeedComputer>? = DefaultSpeedComputer::class.java

    /**
     * 上傳速度計算器,可初始化時改變這個值
     * */
    var uploadSpeedComputer: Class<out ISpeedComputer>? = downloadSpeedComputer
    
    /**
     * 速度計算器介面
     * @see FileRequestBody.warpSource 上傳
     * @see CoreResponseBody.read 下載
     * */
    interface ISpeedComputer {
        /**
         * 獲取速度
         * */
        fun computer(currLen: Long, contentLen: Long): Long

        /**
         * 獲取進度
         * */
        fun progress(currLen: Long, contentLen: Long): Int

        /**
         * 是否允許回撥
         * */
        fun isUpdate(currLen: Long, contentLen: Long): Boolean
    }
}
複製程式碼

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

相關文章