詳解如何魔改Retrofit實例

前言

Retrofit 是 Square 公司開源的網絡框架,在 Android 日常開發中被廣泛使用,開發者們對於 Retrofit 的原理、源碼都已經有相當深入的分析。

本文也是從一次簡單的性能優化開始,挖掘瞭 Retrofit 的實現細節,並在此基礎上,探索瞭對 Retrofit 的更多玩法。

因此,本文將主要講述從發現、優化到探索這一完整的過程,以及過程的一些感悟。

Retrofit 的性能問題

問題源自一次 App 冷啟動優化,常規啟動優化的思路,一般是分析主線程耗時,然後把這些耗時操作打包丟到IO線程中執行。短期來看這不失是一種見效最快的優化方法,但站在長期優化的角度,也是性價比最低的一種方法。因為就性能優化而言,我們不能僅考慮主線程的執行,更多還要考慮對整體資源分配的優化,尤其在並發場景,還要考慮鎖的影響。而 Retrofit 的問題正屬於後者。

我們在排查啟動速度時發現,首頁接口請求的耗時總是高於接口平均值,導致首屏數據加載很慢。針對這個問題,我們使用 systrace 進行瞭具體的分析,其中一次結果如下圖,

可以看到,這一次請求中有大段耗時是在等鎖,並沒有真正執行網絡請求;如果觀察同一時間段的其他請求,也能發現類似現象。

那麼這裡的請求是在等什麼鎖?配合 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 定義的返回值,其實就是動態代理那裡的返回值,

對於返回值為 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 方法時,返回值如下,

這裡的 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

可以看到,一個 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 層隱藏在動態代理層中。

可以先梳理一下大概的思路:

  • 使用註解定位需要 BFF 的 Retrofit 請求;
  • 使用 apt 生成 BFF 需要的膠水代碼,將多個普通 Retrofit 請求,合並成一個 BFF 請求;
  • 通過 AGP Transform 收集所有 BFF 生成類,建立映射表;
  • 在 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,大傢掌握類似的設計思路之後,可以把它們應用在更多場景中,對於日常的開發、編碼效率提升和性能優化,都會很有幫助,希望對各位能有所啟發,更多關於魔改Retrofit實例的資料請關註WalkonNet其它相關文章!

推薦閱讀: