首頁 > 軟體

詳解如何魔改Retrofit範例

2022-11-17 14:01:07

前言

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範例的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com