如何在 Retrofit 中实现请求参数的动态拼接(如分页查询)?

解读

国内面试中,分页是列表场景的高频考点。面试官想确认两点:

  1. 你是否真的用过 Retrofit 做分页,而不是只会写死 @Query("page=1")
  2. 你能否在“动态”维度上给出工程级方案(代码可维护、易测试、与业务解耦)。
    因此,回答必须覆盖“接口定义—调用层—拦截器—单元测试”完整闭环,并体现 Kotlin 协程、Jetpack Paging3 的国内主流用法。

知识点

  1. Retrofit 两种传参方式:
    • @QueryMap / @Query 动态拼接 URL 查询串;
    • @Url + OkHttp HttpUrl 完全手动拼装。
  2. QueryMap 的 Map 可变性:value 为 null 时 Retrofit 会自动忽略,可用来“条件缺省”。
  3. 拦截器层统一追加公共参数(如 deviceIdtokent=时间戳),避免业务方手动重复。
  4. Paging3 的 PagingSource<Key, Value> 与 Retrofit 结合时,Key 就是“下一页参数实体”,Retrofit 接口只需接受一个 @Body@QueryMap 即可。
  5. 国内多渠道包(华为、小米、OPPO)可能要求不同的 baseUrl,使用 @UrlRetrofit.Builder().baseUrl() 动态切换。
  6. 单元测试:MockWebServer + Kotlin CoroutineTest,验证参数是否按预期拼接。

答案

  1. 定义实体封装分页参数
data class PageParam(
    @SerializedName("page") val page: Int,
    @SerializedName("page_size") val pageSize: Int = 20,
    @SerializedName("keyword") val keyword: String? = null
)
  1. Retrofit 接口使用 @QueryMap
interface ArticleApi {
    @GET("article/list")
    suspend fun getArticles(
        @QueryMap(encoded = false) param: Map<String, @JvmSuppressWildcards Any>
    ): ApiResponse<ArticleList>
}

调用层把 PageParam 转成 Map

val map = buildMap {
    put("page", pageParam.page)
    put("page_size", pageParam.pageSize)
    pageParam.keyword?.let { put("keyword", it) }
}
val resp = articleApi.getArticles(map)
  1. 统一拦截器补充公共参数(可选)
class CommonParamInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val old = chain.request()
        val newUrl = old.url.newBuilder()
            .addQueryParameter("t", System.currentTimeMillis().toString())
            .addQueryParameter("token", TokenManager.get())
            .build()
        return chain.proceed(old.newBuilder().url(newUrl).build())
    }
}

加到 OkHttpClient 即可,业务层无感。

  1. 与 Paging3 结合
class ArticlePagingSource(
    private val api: ArticleApi,
    private val keyword: String?
) : PagingSource<Int, Article>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        val next = params.key ?: 1
        val map = buildMap {
            put("page", next)
            put("page_size", params.loadSize)
            keyword?.let { put("keyword", it) }
        }
        return try {
            val resp = api.getArticles(map)
            LoadResult.Page(
                data = resp.data.list,
                prevKey = if (next == 1) null else next - 1,
                nextKey = if (resp.data.hasMore) next + 1 else null
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }
}

ViewModel 层只需:

val pager = Pager(
    config = PagingConfig(pageSize = 20),
    pagingSourceFactory = { ArticlePagingSource(api, keyword) }
).flow.cachedIn(viewModelScope)
  1. 单元测试
@Test
fun `分页参数被正确拼接`() = runTest {
    val server = MockWebServer()
    server.enqueue(MockResponse().setBody("""{"data":{"list":[]}}"""))
    val retrofit = Retrofit.Builder()
        .baseUrl(server.url("/"))
        .addConverterFactory(GsonConverterFactory.create())
        .build()
    val api = retrofit.create(ArticleApi::class.java)

    api.getArticles(mapOf("page" to 2, "page_size" to 20, "keyword" to "北京"))

    val request = server.takeRequest()
    assert(request.requestUrl!!.queryParameter("page") == "2")
    assert(request.requestUrl!!.queryParameter("keyword") == "北京")
}

拓展思考

  1. 如果后端分页协议是 RESTful 风格 /article/list/{page}/{pageSize},可用 @Path 动态替换,但需保证路径中不含特殊字符,否则要做 URL 编码。
  2. 对于 GraphQL 分页(国内部分厂已试点),Retrofit 不再适用,需改用 Apollo Android,通过 @Input 变量传递游标。
  3. 当参数过多导致 URL 长度超 2 KB,部分国产 ROM 的 WebView 会截断,应改用 @POST + @Body 方式,把分页参数放到请求体。
  4. 高并发场景下,拦截器里 System.currentTimeMillis() 会创建大量对象,可提前在 ThreadLocal 缓存秒级时间戳,降低 GC 抖动。
  5. 合规角度,若分页接口涉及用户隐私(如“附近的人”),需在拦截器层动态添加合规字段(gp=1 表示用户同意精准定位),否则国内应用商店审核会被打回。