Retrofit+协程使用填坑和优化

Posted open-Xu

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Retrofit+协程使用填坑和优化相关的知识,希望对你有一定的参考价值。

版权声明:本文为openXu原创文章【openXu的博客】,未经博主允许不得以任何形式转载

本文章主要记录在项目中使用Retrofit+协程时遇到的问题,当然有关问题不局限于使用协程,可能使用RxJava或者原始Call也会遇到,所以算是对Retrofit相关问题的解决和优化。文章第一个问题讲的比较啰嗦,主要介绍了当我们遇到问题时应该怎样去分析,并学会使用相关工具定位问题产生的根本原因,这样才能更好的解决问题,而后面的就直接简单的描述问题、阐述原因和解决办法。

1. (优化)Retrofit+协程第一次请求时卡顿现象

1.1 背景

/**1. 接口定义*/
@POST("jeecg-boot/.../phoneLogin")
suspend fun login(@Body body: LoginBody): ApiResult<LoginInfo>

/**2. Retrofit配置*/
private val retrofit = Retrofit.Builder()
    .client(okHttpClient)
    .baseUrl(PublicApiService.TEST_URL)
    .addConverterFactory(MoshiConverterFactory.create()) 
    .build()

/**Retrofit+协程发起请求*/
viewModelScope.launch {
    showDialog.value = true   //显示dialog
    var startTime = System.currentTimeMillis()
    FLog.w("开始获取动态代理对象${startTime}")
    val service = RetrofitClient.getService(ApiService::class.java)
    FLog.w("1. 获取动态代理对象耗时${System.currentTimeMillis() - startTime}")  //25ms
    startTime = System.currentTimeMillis()
    val loginBody = LoginBody(account,
            Base64.encodeToString(
                    FEncryptUtils.encryptAES2Base64(
                            password.toByteArray(),
                            LoginBody.key.toByteArray(),
                            "AES/CBC/PKCS5Padding",
                            LoginBody.iv.toByteArray()
                    ), Base64.NO_WRAP),
            AppConfig.productAppId,
            getDeviceToken(),
            "1")
    FLog.w("2. 组织参数耗时${System.currentTimeMillis() - startTime}") //10ms
    val loginInfo = service.login(loginBody).data()
    FLog.w("3. 登录完成")
    showDialog.value = false
}

上述代码中通过Retrofit定义了一个接口login(),它是一个挂起函数,在ViewModel中直接获取接口代理对象并调用login()登录。出现的问题程序运行后第一次登录会有明显的卡顿现象(dialog延迟了差不多1.5s才能显示出来),后面再调用登录接口就不会卡顿了。

1.2 初步解决方案

原因可能是Retrofit在第一次请求调用动态代理方法时会反射创建代理对象、解析接口方法注解、参数等操作都是在主线程进行的,只有真正的OkHttp请求call.enqueue()发起异步请求的时候才会切到子线程,Call的扩展挂起函数如下:

//ServiceMethod的adapt()中调用call的扩展函数await(),并传入continuation作为参数
//这种调用方式看起来有些奇怪,其实就是java调用kotlin代码
KotlinExtensions.await(call, continuation);

/**Call的扩展方法,被定义在retrofit2.KotlinExtensions.kt文件中*/
suspend fun <T : Any> Call<T>.await(): T {
    return suspendCancellableCoroutine { continuation ->
        ...
        //发起请求:相当于this.enqueue,而扩展方法中的this就是被扩展的类也就是call对象
        enqueue(object : Callback<T> {
            override fun onResponse(call: Call<T>, response: Response<T>) {
                if (response.isSuccessful) {
                    val body = response.body()
                    //恢复协程执行,返回响应结果
                    continuation.resume(body)
                } else {
                	//恢复协程执行,抛出一个异常
                    continuation.resumeWithException(HttpException(response))
                }
            }
            ...
        })
    }
}

为了使Retrofit的操作全部切到子线程,应该在调用接口的时候就切线程,这样就不会卡顿了:

viewModelScope.launch {
	val category : ApiResult<MutableList<Category>> = withContext(Dispatchers.IO){
                    RetrofitClient.apiService.login()
                }
}

1.3 问题探索(使用工具对应用进行监测剖析)

问题虽然解决了,还是希望搞清楚究竟是哪个步骤导致的卡顿。launch{}中的代码可分为3个部分:创建接口代理对象、组织接口参数、调用接口发起请求,前两个步骤通过日志打印发现耗时总共也就20几毫秒,那就是调用登录接口时初次解析接口方法耗时的?以前我们直接调用Retrofit接口得到一个Call对象,然后发起请求,调用接口的代码也是在主线程中完成的为什么没有发现明显卡顿?可以怀疑卡顿并不是Retrofit解析接口方法造成的。为了准确的找出原因,决定对launch{}中的代码使用工具进行监测追踪

1.3.1 android Studio CPU性能剖析器

①. 通过代码插桩生成跟踪日志

在代码中我们通过FLog.w("${System.currentTimeMillis() - startTime}")的方式打印了主要步骤的耗时时间,但是调用login()接口的时间却没办法打印(一部分在主线程执行、另一部分在子线程)。其实Android系统为我们提供了Method Tracing用于检查CPU活动,跟踪App某段时间内调用过的所有方法以及它们花费的时间,在需要跟踪的代码开头和结尾插入android.os.Debug.startMethodTracing()stopMethodTracing()后运行程序,系统会把追踪结果保存到手机的Android\\data\\包名\\files\\dmtrace.trace文件中(不同系统版本保存位置可能不一样,查看startMethodTracing()方法源码说明),将该文件导出并使用Android Studio的Profiler打开:
在这里插入图片描述

分析过程截图中已经标记出来了,根据方法调用栈,Retrofit在解析接口方法创建ServiceMethod对象时,会调用RequestFactory.parseParameterAnnotation()解析接口方法的参数和注解,解析参数和注解时,需要为每个参数创建都创建一个请求数据转换器Converter对象。通过Converter.FactoryrequestBodyConverter()创建请求数据转换器(作用是将接口方法中的参数转换为RequestBody对象),而我在配置Retrofit时添加了Moshi适配器工厂MoshiConverterFactory,该工厂的requestBodyConverter()实现中调用了moshi.adapter()来创建JsonAdapter对象,其实跟踪到这里我们就已经知道了主要就是moshi.adapter()耗时的。所以Retrofit创建代理对象、解析接口方法等操作并不是耗时的根本原因,虽然这些操作会消耗一些时间(不到50ms),但不会造成肉眼可见的卡顿,真正耗时的操作是moshi.adapter()

为什么moshi.adapter()会那么耗时? 上面截图中方法调用栈不完整,我根据方法栈发现moshi在创建JsonAdapter对象时会通过InputStream读取清单文件Manifest,所以才会这么耗时,至于它为什么要读取清单文件就不再去深究了。

那为什么只有第一次请求会明显卡顿,而后面再次调用该接口就不会卡顿了? 查看源码发现Moshi中维护了一个Map<Object, JsonAdapter<?>> adapterCache,用于缓存已经创建的JsonAdapter对象,其key是接口方法的参数类型和注解类型组成的数组,同一种参数就只需要创建一次JsonAdapter,下次是直接取的缓存。所以参数类型相同的接口方法(包括同一个接口 和 需要json转换的参数类型相同的接口)只有第一次请求时会卡顿,如果接口方法没有参数,或者所有参数类型都是String和基本类型,这种接口第一次请求不会卡顿,因为不需要参数序列化

public final class Moshi {
  ...
  //缓存
  private final Map<Object, JsonAdapter<?>> adapterCache = new LinkedHashMap<>();
  ...
  //根据Retrofit定义的接口方法的参数和注解,获取JsonAdapter对象
  public <T> JsonAdapter<T> adapter(
      Type type, Set<? extends Annotation> annotations, @Nullable String fieldName) {
    ...

    // 获取key = Arrays.asList(type, annotations);
    Object cacheKey = cacheKey(type, annotations);
    synchronized (adapterCache) {
      //从缓存中获取
      JsonAdapter<?> result = adapterCache.get(cacheKey);
      if (result != null) return (JsonAdapter<T>) result;
    }
    ...
      for (int i = 0, size = factories.size(); i < size; i++) {
        //缓存中没有则创建
        JsonAdapter<T> result = (JsonAdapter<T>) factories.get(i).create(type, annotations, this);
        if (result == null) continue;
        lookupChain.adapterFound(result);
        success = true;
        return result;
      }
    ...

  }
  ...
}

要怎样优化它?现在看来有2种解决办法:

  • 将耗时操作切换到子线程(上面已经通过withContext(Dispatchers.IO)解决了)。如果使用Retrofit+RxJava的话,可以使用二次动态代理将Retrofit的放到子线程中完成,参考知乎安卓客户端启动优化:Retrofit 代理
public final class Net {
    public static <T> createService(Class<T> service) {
        // ...
        return createWrapperService(mRetrofit, service);
    }
    private static <T> T createWrapperService(Retrofit retrofit, Class<T> service) {
        //创建Retrofit接口的二次动态代理对象,目的是当调用二次动态代理对象的接口方法时,让接口方法的解析切到子线程
        return (T) Proxy.newProxyInstance(service.getClassLoader(),
                new Class<?>[]{service}, new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args)throws Throwable {
                        //通过Retrofit对象创建接口原代理对象,这里需要添加缓存机制,要不然每次都会创建新的代理对象
                        final T originalService = retrofit.create(service);
                        if (method.getReturnType() == Observable.class) {
                            // 如果方法返回值是Observable的话,则包一层再返回
                            return Observable.defer(() -> {
                                // 调用原始代理对象的接口方法,获取原始Observable,然后在包裹一层subscribeOn(Schedulers.io()
                                return ((Observable) getRetrofitMethod(originalService, method)
                                        .invoke(originalService, args))
                                        .subscribeOn(Schedulers.io());   //将loadServiceMethod()操作切换到子线程
                            }).subscribeOn(Schedulers.single());
                        }
                        // 返回值不是Observable的话不处理,直接使用原代理对象执行方法
                        return getRetrofitMethod(originalService, method).invoke(originalService, args);
                    }
                    // ...
                });
    }
    private static <T> Method getRetrofitMethod(T serviceOne, Method method) throws NoSuchMethodException {
        return serviceOne.getClass().getDeclaredMethod(method.getName(), method.getParameterTypes());
    }
}
  • 不要用Moshi,而用其他的数据转换器比如GsonConverterFactory,这种方式我也试过了,确实不会明显卡顿。但是GsonConverterFactory对kotlin的支持不是太好,最好还是继续使用Moshi,所以采用第一中解决办法最完美

②. 直接使用Android Studio Profiler进行实时监测

上面是通过插入代码生成追踪结果文件来进行代码追踪分析,这种方式需要导出dmtrace.trace文件后在Profiler视图中打开,比较麻烦,其实可以直接通过Profiler窗口进行代码追踪。打开应用,进入到需要追踪的代码页面,在Profiler窗口中点击+号,选择你的测试设备->应用包名,这时候就开始对应用进程的CPU、内存、网络等进行实时监测了。由于我们需要追踪方法的耗时,也就是CPU执行时间,直接点击CPU那一栏,然后切换到只监测CPU,下方有一个Record按钮,点击一下该按钮就相当于在代码中插入了Debug.startMethodTracing(),这时候就开始对CPU进行监测记录了,对应用进行相关操作后,点击Stop按钮,则会停止监测记录,同时可以直接查看分析追踪结果,省去了对dmtrace.trace文件的导出和加载。

③. 通过adb命令监测应用冷启动

上面我们都是对应用启动之后的某段时间内进行追踪,但有时候我们发现程序启动速度变慢了,要怎样对App冷启动进行监测呢?需要使用Android系统的am命令来启动App,然后导出.trace文件进行分析:

# 启动指定Activity,并同时进行采样跟踪
adb shell am start -n com.fpc.zs119/com.fpc.zs119.ui.activity.SplashActivity --start-profiler /data/local/tmp/zs119-startup.trace --sampling 1000

# 当App冷启动完毕,Activity已经绘制到屏幕后,停止追踪
adb shell am profile 

# 拉取 .trace 文件到本机当前目录
adb pull /data/local/tmp/zs119-startup.trace .

2. (优化)组件化中的Retrofit使用享元模式思想重用接口动态代理对象

享元模式尝试重用现有的同类对象,如果未找到匹配的对象,则创建新对象,用于减少创建对象的数量,以减少内存占用和提高性能,这种类型的设计模式属于结构型模式。说的简单一点就是,一段程序中经常需要创建某种类型的对象,而对象的数量是有限的,可以将已经创建的对象缓存起来,下次要创建这个对象时直接从缓存中拿,缓存中没有就去创建。Retrofit中创建接口方法ServiceMethod对象时就用到了享元模式:

public final class Retrofit {
  private final Map<Method, ServiceMethod<?>> serviceMethodCache = new ConcurrentHashMap<>();
  //调用接口方法时,使用享元模式尝试从缓存中获取,这样避免一个接口被调用多次时每次都要解析接口方法的注解和参数
  ServiceMethod<?> loadServiceMethod(Method method) {
    //取缓存
    ServiceMethod<?> result = serviceMethodCache.get(method);
    if (result != null) return result;
    synchronized (serviceMethodCache) {
      result = serviceMethodCache.get(method);
      if (result == null) {
        //创建并缓存
        result = ServiceMethod.parseAnnotations(this, method);
        serviceMethodCache.put(method, result);
      }
    }
    return result;
  }
}

一个大项目根据业务划分为多个模块,不同模块中使用到的服务器接口不同,通常会将Retrofit接口定义到对应的module中,然后通过RetrofitClient.retrofit.create(ModuleApiService::class.java)获取接口动态代理对象,如果每次请求接口都创建一个接口代理对象肯定不合适,所以通过享元模式的思想对现有代理对象进行缓存重用:

object RetrofitClient {
    private val okHttpClient = OkHttpClient.Builder()
            .retryOnConnectionFailure(true)
            .connectTimeout(4000, TimeUnit.MILLISECONDS)       //IP连接超时
            .dns(TimeOutDns(4000, TimeUnit.MILLISECONDS))
            .readTimeout(20000, TimeUnit.MILLISECONDS)
            .writeTimeout(30000, TimeUnit.MILLISECONDS)
            .addInterceptor(TokenInterceptor()) //must call proceed() exactly once
            .addInterceptor(HttpUrlInterceptor())
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = if(AppConfig.DEBUG)
                    HttpLoggingInterceptor.Level.BODY
                else HttpLoggingInterceptor.Level.BASIC
            })
            .build()


    /**Retrofit*/
    private val retrofit = Retrofit.Builder()
        .client(okHttpClient)
        .baseUrl(PublicApiService.TEST_URL)
        .addConverterFactory(MoshiConverterFactory.create())
        .addCallAdapterFactory(CoroutinesResponseCallAdapterFactory())
        .build()

    /**ApiService*/
    private val serviceMap = mutableMapOf<Class<out Any>, Any>()
    //获取动态代理对象
    fun <S> getService(javaClass : Class<out S>) : S{
        //享元模式重用现有对象
        if(!serviceMap.containsKey(javaClass)){
            serviceMap.put(javaClass, retrofit.create(javaClass)!!)
        }
        return serviceMap[javaClass] as S
    }
}

3. (优化)Retrofit+OkHttp下载、上传文件时进度条卡顿

3.1 问题

//1 文件上传
@Multipart
@POST("/jeecg-boot/cmds/attachment/uploadMultipleFile")
suspend fun uploadFile(@PartMap map:MutableMap<String, RequestBody>, @Part parts:MutableList<MultipartBody.Part>): ApiResult<String>

//2 文件下载
@Streaming
@GET
suspend fun downloadFile(@Url url: String): Response<ResponseBody>

fun downloadFile(path: String, build: IDownloadBuild) = flow {
        val response = getService(PublicApiService::class.java).downloadFile(path)
        response.body()?.let { body ->
            val allLength = body.contentLength()
            val contentType = body.contentType().toString()
            val inputStream = body.byteStream()
            val info = try {
                dowloadBuildToOutputStream(build, contentType)
            } catch(e:Exception){
                emit(DownloadStatus.DownloadErron(e))
                DownloadInfo(null)
                return@flow
            }
            val outputStream = info.ops
            if (outputStream == null) {
                emit(DownloadStatus.DownloadErron(RuntimeException("下载出错")))
                return@flow
            }
            //当前下载长度
            var currentLength = 0
            //写入文件,每次读取8kb
            val bufferSize = 1024 * 8
            val buffer = ByteArray(bufferSize)
            val bufferedInputStream = BufferedInputStream(inputStream, bufferSize)
            var readLength = 0
            FLog.e("文件总长度:$allLength")
            while (bufferedInputStream.read(buffer, 0, bufferSize).also { readLength = it } != -1) {
                outputStream.write(buffer, 0, readLength)
                outputStream.flush()
                currentLength += readLength
                //★ 发射下载进度,主线程中收到后更新进度条
                FLog.e("发射进度:${currentLength.toFloat() / allLength.toFloat()}")
                emit(
                        DownloadStatus.DownloadProcess(
                                currentLength.toLongRetrofit+协程使用填坑和优化

Retrofit+协程使用填坑和优化

android animation——添加购物车动画(填坑和优化)

纯Socket(BIO)长链接编程的常见的坑和填坑套路

多线程编程总结:二Thread的那些坑和填坑的线程池

两种方式封装Retrofit+协程,实现优雅快速的网络请求