阅读 34

基于Retrofit2实现的LycheeHttp-使用动态代理实现上传

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
    }
}
复制代码

后话:第一次写文章,写的头晕脑涨,写的不太好。如果这篇文章对各位大大有用的话,可以给我点个赞鼓励一下我哦,感谢!

关注下面的标签,发现更多相似文章
评论