图片来自:unsplash.com
本文作者: lizongjun

前语

Retrofit 是 Square 公司开源的网络结构,在 Android 日常开发中被广泛运用,开发者们关于 Retrofit 的原理、源码都现已有相当深化的剖析。

本文也是从一次简略的功能优化开端,发掘了 Retrofit 的完成细节,并在此基础上,探究了对 Retrofit 的更多玩法。

因而,本文将首要叙述从发现、优化到探究这一完好的进程,以及进程的一些感悟。

Retrofit 的功能问题

问题源自一次 App 冷发动优化,常规发动优化的思路,一般是剖析主线程耗时,然后把这些耗时操作打包丢到IO线程中履行。短期来看这不失是一种见效最快的优化办法,但站在长时间优化的视点,也是性价比最低的一种办法。由于就功能优化而言,咱们不能仅考虑主线程的履行,更多还要考虑对全体资源分配的优化,尤其在并发场景,还要考虑锁的影响。而 Retrofit 的问题正归于后者。

咱们在排查发动速度时发现,主页接口恳求的耗时总是高于接口平均值,导致首屏数据加载很慢。针对这个问题,咱们运用 systrace 进行了具体的剖析,其间一次成果如下图,

如何魔改Retrofit

能够看到,这一次恳求中有大段耗时是在等锁,并没有真实履行网络恳求;假如观察同一时间段的其他恳求,也能发现相似现象。

那么这儿的恳求是在等什么锁?配合 systrace 能够在 Retrofit 源码(下文相关源码都是依据 Retrofit 2.7.x 版别,不同版别逻辑或许略有出入)中定位到,是如下的一把锁,

// retrofit2/Retrofit.java
public <T> T create(final Class<T> service) {
  validateServiceInterface(service);
  return (T) Proxy.newProxyInstance(service.getClassLoader(), new Class<?>[] { service },
      new InvocationHandler() {
        @Override public @Nullable Object invoke(Object proxy, Method method,
            @Nullable Object[] args) throws Throwable {
          ...
          return loadServiceMethod(method).invoke(args != null ? args : emptyArgs);
        }
      });
}
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 相关的完成原理这儿就不再赘述,简而言之 loadServiceMethod 这个办法的作用是:经过恳求 interface 的入参、回来值、注解等信息,生成 Converter、CallAdapter,并包装成一个 ServiceMethod 回来,之后会经过这个 ServiceMethod 来建议真实的网络恳求。

从上述源码也能够看到,ServiceMethod 是有内存缓存的,但问题也正在这儿—— ServiceMethod 的生成是在锁内完成的。

因而问题就变成,生成 ServiceMethod 为什么会有耗时?以云音乐的项目为例,各个团队都是运用 moshi 进行 json 解析,大部分 meta 类是经过 kotlin 完成,但也存在必定 kotlin、 Java 混用的状况。

这部分耗时首要来自 moshi 生成 JsonAdapter。生成 JsonAdapter 需求递归遍历 meta 类中的所有 field,进程中除了 kotlin 反射自身的功率和受并发的影响,还触及 kotlin 的 builtins 机制,以及冷发动进程中,类加载的耗时。

上述说到的几个耗时点,每一个都能够单开一篇文章评论,篇幅原因这儿一言以蔽之——冷发动进程中,moshi 生成 JsonAdapter 是一个十分耗时的进程(而且这个耗时,跟运用 moshi 解析结构自身也没有必然联系,运用其他 json 解析结构,或多或少也会遇到相似问题)。

锁+不可避免的耗时,引发的必然成果是:在冷发动进程中,经过 Retrofit 建议的网络恳求,会部分劣化成一个串行进程。因而呈现 systrace 中呈现的成果,恳求大部分时间在等锁,这儿等候的是前一个恳求生成 ServiceMethod 的耗时,并以此类推耗时不断向后传递。

测验优化

已然定位到了原因,咱们能够测验优化了。

首要能够从 JsonAdapter 的生成功率下手,比方 moshi 原生就支持 @JsonClass 注解,经过 apt 在编译时生成 meta的 解析器,从而显著削减反射耗时。

二来,还是测验从根本上解决问题。其实从发现这个问题开端,咱们就一直在考虑这种写法的合理性:首要加锁必定是为了拜访 serviceMethodCache 时的线程安全;其次,生成 ServiceMethod 的进程时,的确有一些反射操作内部是有缓存的,假如发生并发是有必定功能损耗的。

但就咱们的实践项目而言,不同 Retrofit interface 之间,简直没有堆叠的部分,反射操作都是以 Class 为单位在进行。以此为基础,咱们能够测验优化一下这儿的写法。

那么,在不修正 Retrofit 源码的基础上,有什么办法能够修正恳求流程吗?

在云音乐的项目中,关于创立 Retrofit 动态署理,是有共同封装的。也便是说,项目中除个别特殊写法,绝大多数恳求的创立,都是经过同一段封装。只要咱们改写了 Retrofit 创立动态署理的流程,是不是就能够优化掉前面的问题?

先观察一下 Retrofit.create 办法的内部完成,能够发现大部分办法的可见性都是包可见的。众所周知,在 Java 的国际里,包可见就等于 public,所以咱们能够自己完成 Retrofit.create 办法,写法大约如下,

private ServiceMethod<?> loadServiceMethod(Method method) {
    // 反射取到Retrofit内部的缓存
    Map<Method, ServiceMethod<?>> serviceMethodCache = null;
    try {
        serviceMethodCache = cacheField != null ? (Map<Method, ServiceMethod<?>>) cacheField.get(retrofit) : null;
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }
    if (serviceMethodCache == null) {
        return retrofit.loadServiceMethod(method);
    }
    ServiceMethod<?> result = serviceMethodCache.get(method);
    if (result != null) return result;
    synchronized (serviceMethodCache) {
        result = serviceMethodCache.get(method);
        if (result != null) return result;
    }
    synchronized (service) { // 这儿替换成类锁
        result = ServiceMethod.parseAnnotations(retrofit, method);
    }
    synchronized (serviceMethodCache) {
        serviceMethodCache.put(method, result);
    }
    return result;
}

能够看到,除了需求反射获取 serviceMethodCache 这个私有成员 ,其他办法都能够直接拜访。这儿把耗时的 ServiceMethod.parseAnnotations 办法从锁中移出,改为对 interface Class 加锁。(当然这儿急进一点,也能够彻底不加锁,需求依据实践项意图状况来定)

修正之后,在发动进程中重新抓取 systrace,现已看不到之前等锁的耗时了,主页恳求速度也回落到正常区间内。

或许从这也能看出 kotlin 为什么要束缚包可见性和泛型的上下边界—— Java 原有的束缚太弱,尽管方便了 hook,但同样也说明代码边界更简略被损坏;一起这儿也说明晰代码标准的重要性,只要确保共同的编码标准,即使不运用什么“黑科技”,也能对代码运转功率完成有效的管控。

不是AOP的AOP

到这儿,咱们会忽然发现一个问题:已然咱们都自己来完成 Retrofit 的动态署理了,那不是意味着咱们能够获取到每一次恳求的成果,乃至操控每一次恳求的流程?

咱们知道,传统的接口缓存,一般是依据网络库完成的,比方在 okhttp 中的 CacheInterceptor

这种网络库层级缓存的缺陷是:网络恳求毕竟是一个IO进程,它很难是面向目标的;而且 Response 的 body 也不能被多次 read,在 cache 进程中,一般需求把数据深拷贝一次,有必定功能损耗。

比方,CacheInterceptor 中就有如下缓存相关的逻辑,在 body 被 read 的一起,再 copy一份到 cache 中。

val cacheWritingSource = object : Source {
  var cacheRequestClosed: Boolean = false
  @Throws(IOException::class)
  override fun read(sink: Buffer, byteCount: Long): Long {
    val bytesRead: Long
    try {
      bytesRead = source.read(sink, byteCount)
    } catch (e: IOException) {
      if (!cacheRequestClosed) {
        cacheRequestClosed = true
        cacheRequest.abort() // Failed to write a complete cache response.
      }
      throw e
    }
    if (bytesRead == -1L) {
      if (!cacheRequestClosed) {
        cacheRequestClosed = true
        cacheBody.close() // The cache response is complete!
      }
      return -1
    }
    sink.copyTo(cacheBody.buffer, sink.size - bytesRead, bytesRead)
    cacheBody.emitCompleteSegments()
    return bytesRead
  }
  ...
}

但假如咱们能整个操控 Retrofit 恳求,在动态署理这一层取到的是真实恳求成果的 meta 目标,假如把这个目标缓存起来,连 json 解析的进程都能够省去;而且拿到实在的回来目标后,依据目标对数据做一些 hook 操作,也愈加简略。

当然,直接缓存目标也有风险风险,比方假如 meta 自身不是 immutable 的,会损坏恳求的幂等性,这也是需求在后续的封装中留意的,避免能力被滥用。

那么咱们能在动态署理层拿到 Retrofit 的恳求成果吗?答案是必定的。

咱们知道 ServiceMethod.invoke 这个办法回来的成果,取决于 CallAdapter 的完成。Retrofit 有两种原生的 CallAdpater,一种是依据 okhttp 原生的 RealCall,一种是依据 kotlin 的 suspend 办法。

也便是说咱们在经过 Retrofit 建议网络恳求时,一般只要如下两种写法(各个写法其实都还有几个不同的小变种,这儿就不展开了)。

interface Api {
    @FormUrlEncoded
    @POST("somePath")
    suspend fun get1(@Field("field") field: String): Result
    @FormUrlEncoded
    @POST("somePath")
    fun get2(@Field("field") field: String): Call<Result>
}

这儿 intreface 界说的回来值,其实便是动态署理那里的回来值,

如何魔改Retrofit

关于回来值为 Call 的写法 ,hook 逻辑相似下面的写法,只要对回调运用装饰器包装一下,就能拿到回来成果或者异常。

class WrapperCallback<T>(private val cb : Callback<T>) : Callback<T> {
    override fun onResponse(call: Call<T>, response: Response<T>) {
        val result = response.body() // 这儿response.body()便是回来的meta
        cb.onResponse(call, response)
    }
}

但关于 suspend 办法呢?调试一下会发现,当恳求界说为 suspend 办法时,回来值如下,

如何魔改Retrofit

这儿的 COROUTINE_SUSPENDED 是什么?

获取 suspend 办法的回来值

要解说 COROUTINE_SUSPENDED 是什么,略微触及协程的完成原理。咱们能够先看看 Retrofit 自身在生成动态署理时,是怎样适配 suspend 办法的。

Retrofit 中关于 suspend 办法的回来,是经过 SuspendForBodySuspendForResponse 这两个 ServiceMethod 来封装的。两者逻辑相似,咱们以 SuspendForBody 为例,

static final class SuspendForBody<ResponseT> extends HttpServiceMethod<ResponseT, Object> {
  ...
  @Override protected Object adapt(Call<ResponseT> call, Object[] args) {
    call = callAdapter.adapt(call);
    //noinspection unchecked Checked by reflection inside RequestFactory.
    Continuation<ResponseT> continuation = (Continuation<ResponseT>) args[args.length - 1];
    ...
    try {
      return isNullable
          ? KotlinExtensions.awaitNullable(call, continuation)
          : KotlinExtensions.await(call, continuation);
    } catch (Exception e) {
      return KotlinExtensions.suspendAndThrow(e, continuation);
    }
  }
}

首要,代码中的 Continuation 是什么? Continuation 可理解为挂起办法的回调。咱们知道,suspend 办法在编译时,会被编译成一个一般的 Java 办法,除了回来值被改写成 Object,它与一般 Java 办法的另一个区别是,编译器会在办法结尾刺进一个入参,这个入参的类型便是 Continuation

如何魔改Retrofit

能够看到,一个 suspend 办法,在编译之后,多了一个入参。

kotlin 协程正是凭借 Continuation 来向下传递协程上下文,再向上回来成果的;所以 suspend 办法真实的回来成果,一般不是经过办法自身的回来值来回来的。

此时,咱们只要依据协程状态,任意回来一个占位的回来值即可,比方在 suspendCancellableCoroutine 闭包中,

// CancellableContinuationImpl.kt
@PublishedApi
internal fun getResult(): Any? {
    setupCancellation()
    if (trySuspend()) return COROUTINE_SUSPENDED
    // otherwise, onCompletionInternal was already invoked & invoked tryResume, and the result is in the state
    val state = this.state
    if (state is CompletedExceptionally) throw recoverStackTrace(state.cause, this)
    ...
    return getSuccessfulResult(state)
}

这也便是前文 COROUTINE_SUSPENDED 这个回来成果的来源。

回到前面 Retrofit 桥接 suspend 的代码,假如咱们写一段相似下面的测验代码,会发现这儿的 context 与入参 continuation.getContext 回来的是同一个目标。

val ret = runBlocking {
    val context = coroutineContext // 上一级协程的上下文
    val ret = api.getUserDetail(uid)
    ret
}

而 Retrofit 中的 KotlinExtensions.await 办法的完成如下,

suspend fun <T : Any> Call<T>.await(): T {
  return suspendCancellableCoroutine { continuation ->
    continuation.invokeOnCancellation {
      cancel()
    }
    enqueue(object : Callback<T> {
      override fun onResponse(call: Call<T>, response: Response<T>) {
        if (response.isSuccessful) {
          val body = response.body()
          if (body == null) {
            ...
            continuation.resumeWithException(e)
          } else {
            continuation.resume(body)
          }
        } else {
          continuation.resumeWithException(HttpException(response))
        }
      }
      override fun onFailure(call: Call<T>, t: Throwable) {
        continuation.resumeWithException(t)
      }
    })
  }
}

结合前面临 Continuation 的了解,把这段代码翻译成 Java 伪代码,大约是这样的,

public Object await(Call<T> call, Object[] args, Continuation<T> continuation) {
    call.enqueue(object : Callback<T> {
      override fun onResponse(call: Call<T>, response: Response<T>) {
        continuation.resumeWith(Result.success(response.body));
      }
      override fun onFailure(call: Call<T>, t: Throwable) {
        continuation.resumeWith(Result.failure(t));
      }
    })
    return COROUTINE_SUSPENDED;
}

能够看到,suspend 办法是一种更优雅完成回调的语法糖,无论是在它的规划意图上,还是完成原理上,都是这样。

所以,依据这个原理,咱们也能够按相似如下方式 hook suspend 办法,从而获得回来值。

@Nullable
public T hookSuspend(Method method, Object[] args) {
    Continuation<T> realContinuation = (Continuation<T>) args[args.length - 1];
    Continuation<T> hookedContinuation = new Continuation<T>() {
        @NonNull
        @Override
        public CoroutineContext getContext() {
            return realContinuation.getContext();
        }
        @Overrid
        public void resumeWith(@NonNull Object o) {
            realContinuation.resumeWith(o); // 这儿的object便是回来成果
        }
    };
    args[args.length - 1] = hookedContinuation;
    return method.invoke(args);
}

缓存恳求成果

到这儿现已距离成功很近了,已然咱们能拿到每一种恳求类型的回来成果,再加亿点点细节,就意味着咱们能够完成依据 Retrofit 的预加载、缓存封装了。

Cache 封装大差不差,首要是处理以下这条逻辑链路:

Request -> Cache Key -> Store -> Cached Response

由于咱们只做内存缓存,所以也不需求考虑数据的持久化,直接运用Map来管理缓存即可。

  • 先封装入参,咱们在动态署理层以此入参为标志,触发预加载或缓存机制,
sealed class LoadInfo(
    val id: String = "", // 恳求id,默许不需求设置
    val timeout: Long // 超时时间
)
// 用来写缓存/预加载
class CacheWriter(
    id: String = "",
    timeout: Long = 10000
) : LoadInfo(id, timeout)
// 用来读缓存
class CacheReader(
    id: String = "",
    timeout: Long = 10000,
    val asCache: Boolean = false // 未命中时,是否要产生一个新的缓存,可供下一次恳求运用
) : LoadInfo(id, timeout)
  • 刺进 hook 代码,处理缓存读写逻辑,(这儿还需求处理并发,依据协程比较简略,这儿就不展开了)
fun <T> ServiceMethod<T>.hookInvoke(args: Array<Any?>): T? {
    val loadInfo = args.find { it is LoadInfo } as? LoadInfo
    // 这儿咱们能够用办法签名做缓存key,办法签名必定是唯一的
    val id = method.toString()
    if (loadInfo is CacheReader) {
        // 测验找缓存
        val cache = map[id]
        if (isSameRequest(cache?.args, args)) {
            // 找到缓存,而且恳求参数共同,则直接回来
            return cache?.result as? T
        }
    }
    // 正常建议恳求
    val result = invoke(args)
    if (loadInfo is CacheWriter) {
        // 存缓存
        map[id] = Cache(id, result)
    }
    return result
}

这儿运用 map 缓存恳求成果,丰富一下缓存超时逻辑和前文说到的并发处理,即可投入运用。

  • 界说恳求,

咱们能够利用 Retrofit 中的 @Tag 注解来传入 LoadInfo 参数,这样不会影响真实的网络恳求。

interface TestApi {
    @FormUrlEncoded
    @POST("moyi/user/center/detail")
    suspend fun getUserDetail(
        @Field("userId") userId: String,
        @Tag loadInfo: LoadInfo // 缓存配置
    ): UserDetail
}
  • have a try,
suspend fun preload(preload: Boolean) {
    launch {
        // 预加载
        api.getUserDetail("123", CacheWriter(timeout = 5000))
    }
    delay(3000)
    // 读预加载的成果
    api.getUserDetail("123", CacheReader()) // 读到上一次的缓存
}

履行代码能够看到,两次 api 调用,只会建议一次真实的网络恳求,而且两次回来成果是同一个目标,跟咱们的预期共同。

相比传统网络缓存,这种写法的优点,除了前面说到的削减 IO 开支之外,简直能够做到零侵入,相比常规网络恳求写法,仅仅多了一个入参;而且写法十分简练,常规写法或许用到的预加载、超时、并发等大量的胶水代码,都被隐藏在 Retrofit 动态署理内部,上层事务代码并不需求感知。当然 AOP 带来的便当性,与动态署理写法的优势也是相辅相成。

One more thing?

云音乐内部一直在推动 Backend-for-Frontend (BFF) 的建造,BFF 与 Android 时下新式的 MVI 结构十分契合,凭借 BFF 能够让 Model 层变的十分简练。

但 BFF 自身关于服务端是一个比较重的方案,特别关于大型项目,需求考虑 RPC 数据敏感性、接口功能、容灾降级等一系列工程化问题,而且 BFF 在大型项目里一般也只用在一些非 P0 场景上。特别关于团队规划比较小的事务来说,考虑到这些本钱后,BFF 自身带来的便当简直全被抵消了。

那么有什么办法能够不凭借其他端完成一个轻量级的 BFF 吗?相信你现已猜到了,咱们现已 AOP 了 Retrofit,完成网络缓存能够看作是小试牛刀,那么完成 BFF 也不过是更进一步。

与前文凭借动态署理层完成网络缓存的思路相似,咱们也选择把 BFF 层隐藏在动态署理层中。

能够先梳理一下大约的思路:

  1. 运用注解定位需求 BFF 的 Retrofit 恳求;
  2. 运用 apt 生成 BFF 需求的胶水代码,将多个一般 Retrofit 恳求,合并成一个 BFF 恳求;
  3. 经过 AGP Transform 搜集所有 BFF 生成类,建立映射表;
  4. 在 Retrofit 动态署理层,凭借映射表,把恳求完成替换成生成好的 BFF 代码。

实践上,现在主流的各种零入侵代码结构(比方路由、埋点、数据库、发动结构、依靠注入等),都是用相似的思路完成的,咱们触类旁通即可。

这儿为对此思路还不太熟悉的小伙伴,简略过一遍全体规划流程,

首要,界说需求的注解,用 @BFF 来标识需求进行 BFF 操作的 meta 类或接口,

@Retention(RetentionPolicy.CLASS)
@Target({ElementType.FIELD, ElementType.METHOD})
public @interface BFF {
    String source() default ""; // 数据源信息,默许不需求
    boolean primary() default false; // 是否为必要数据
}

@BFFSource 注解来标识数据预处理的逻辑(在大部分简略场景下,是不需求运用此注解的,因而把这部分拆分红一个独自的注解,以下降学习本钱),

@Retention(RetentionPolicy.CLASS)
@Target({ElementType.FIELD})
public @interface BFFSource {
    Class clazz() default String.class; // 现在数据
    String name() default ""; // 别名
    String logic() default ""; // 预处理逻辑
}

界说数据源,数据源的写法跟一般 Retrofit 恳求相同,仅仅办法上额外加一个 @BFF 注解作为 apt 的标识,

@JvmSuppressWildcards
interface TestApi {
    @BFF
    @FormUrlEncoded
    @POST("path/one")
    suspend fun getPartOne(@Field("position") position: Int): PartOne
    @BFF
    @FormUrlEncoded
    @POST("path/two")
    suspend fun getPartTwo(@Field("id") id: Int): PartTwo
}

界说目标数据结构,这儿仍然经过 @BFF 注解,与前面的恳求做关联,

data class MyMeta(
    @BFF(primary = true) val one: PartOne,
    @BFF val two: PartTwo?
) {
    @BFFSource(clazz = PartOne::class, logic = "total > 0")
    var valid: Boolean = false
}

界说BFF恳求,

@JvmSuppressWildcards
interface BFFApi {
    @BFF
    @POST("path/all") // 在这个方案中,BFF api的path没有实践意义
    suspend fun getAll(
        @Field("position") position: Int,
        @Field("id") id: Int
    ): MyMeta
}

经过上述注解,在编译时生成胶水代码如下,(这儿生成代码的逻辑其实跟依靠注入是彻底共同的,囿于篇幅就不具体评论了)

public class GetAllBFF(
  private val creator: RetrofitCreate,
  scope: CoroutineScope
) : BFFSource(scope) {
  private val testApi: TestApi by lazy {
    creator.create(UserApi::class.java)
  }
  public suspend fun getAll(
    position: Int,
    id: Int
  ): MyMeta {
    val getPartOneDeferred = loadAsync { testApi.getPartOne(position) }
    val getPartTwoDeferred = loadAsync { testApi.getPartTwo(id) }
    val getPartOneResult = getPartOneDeferred.await()
    val getPartTwoResult = getPartTwoDeferred.await()
    val result = MyMeta(getPartOneResult!!,
        getPartTwoResult)
    result.valid = getPartOneResult!!.total > 0
    return result
  }
}

在运用时,直接把 BFF api 当作一个一般的接口调用即可,Retrofit 内部会完成替换。

private val bffApi by lazy {
    creator.create(BFFApi::class.java)
}
public suspend fun getAllMeta(
    position: Int,
    id: Int
): MyMeta {
    return bffApi.getAll(position, id) // 直接回来BFF合成好的成果
}

能够看到,与前文规划接口缓存封装相似,能够做到零侵入、零胶水代码,运用起来十分简练、直接。

总结

至此,咱们回顾了关于 Retrofit 的功能问题,从发现问题到解决问题的进程,并简略讲解了咱们是怎样进一步开发 Retrofit 的潜力,以及常用的低侵入结构的规划思路。文章触及的依据 Retrofit 的缓存、BFF 规划,更多是抛砖引玉,而且不仅仅是 Retrofit,大家把握相似的规划思路之后,能够把它们应用在更多场景中,关于日常的开发、编码功率提高和功能优化,都会很有帮助,期望对各位能有所启发。

本文发布自网易云音乐技能团队,文章未经授权制止任何形式的转载。咱们终年接收各类技能岗位,假如你准备换作业,又恰好喜爱云音乐,那就参加咱们 grp.music-fe(at)corp.netease.com!