如何在 Retrofit 中实现请求参数的动态拼接(如分页查询)?
解读
国内面试中,分页是列表场景的高频考点。面试官想确认两点:
- 你是否真的用过 Retrofit 做分页,而不是只会写死
@Query("page=1"); - 你能否在“动态”维度上给出工程级方案(代码可维护、易测试、与业务解耦)。
因此,回答必须覆盖“接口定义—调用层—拦截器—单元测试”完整闭环,并体现 Kotlin 协程、Jetpack Paging3 的国内主流用法。
知识点
- Retrofit 两种传参方式:
- ①
@QueryMap/@Query动态拼接 URL 查询串; - ②
@Url+OkHttp HttpUrl完全手动拼装。
- ①
QueryMap的 Map 可变性:value 为 null 时 Retrofit 会自动忽略,可用来“条件缺省”。- 拦截器层统一追加公共参数(如
deviceId、token、t=时间戳),避免业务方手动重复。 - Paging3 的
PagingSource<Key, Value>与 Retrofit 结合时,Key 就是“下一页参数实体”,Retrofit 接口只需接受一个@Body或@QueryMap即可。 - 国内多渠道包(华为、小米、OPPO)可能要求不同的
baseUrl,使用@Url或Retrofit.Builder().baseUrl()动态切换。 - 单元测试:MockWebServer + Kotlin CoroutineTest,验证参数是否按预期拼接。
答案
- 定义实体封装分页参数
data class PageParam(
@SerializedName("page") val page: Int,
@SerializedName("page_size") val pageSize: Int = 20,
@SerializedName("keyword") val keyword: String? = null
)
- 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)
- 统一拦截器补充公共参数(可选)
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 即可,业务层无感。
- 与 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)
- 单元测试
@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") == "北京")
}
拓展思考
- 如果后端分页协议是 RESTful 风格
/article/list/{page}/{pageSize},可用@Path动态替换,但需保证路径中不含特殊字符,否则要做 URL 编码。 - 对于 GraphQL 分页(国内部分厂已试点),Retrofit 不再适用,需改用 Apollo Android,通过
@Input变量传递游标。 - 当参数过多导致 URL 长度超 2 KB,部分国产 ROM 的 WebView 会截断,应改用
@POST+@Body方式,把分页参数放到请求体。 - 高并发场景下,拦截器里
System.currentTimeMillis()会创建大量对象,可提前在ThreadLocal缓存秒级时间戳,降低 GC 抖动。 - 合规角度,若分页接口涉及用户隐私(如“附近的人”),需在拦截器层动态添加合规字段(
gp=1表示用户同意精准定位),否则国内应用商店审核会被打回。