网络层是 Android 应用最容易被写「散」的一层:请求散落在各处、错误处理各写各的、超时和重试靠默认值、测试只能靠真机连线上接口。Retrofit 把 HTTP 调用抽象成接口,OkHttp 负责底层连接与拦截,两者组合能把网络层收敛成可注入、可测试、可观测的一块。本文按「接口定义、序列化选型、客户端配置、鉴权与错误、测试与混淆」的顺序,把工程化的要点逐个讲透。
一句话总结: Retrofit 只是「接口到 HTTP 的翻译层」,真正决定网络层质量的,是 OkHttpClient 的配置、拦截器链的设计和错误处理的一致性。
一、网络层的分层职责
一个健康的网络层大致分四层,每层只做一件事。
| 层 | 职责 | 典型产物 |
|---|---|---|
| API 接口 | 声明端点与参数 | @GET、@POST 注解接口 |
| 客户端 | 连接、超时、拦截、鉴权 | OkHttpClient 单例 |
| 数据层 | DTO 转换、错误归一、缓存 | Repository、Result 封装 |
| 调用方 | 触发、订阅、状态渲染 | ViewModel + Flow |
分层的意义在于:换序列化库只动 ConverterFactory,换鉴权方式只动拦截器,UI 层完全无感。
二、依赖与版本
// gradle/libs.versions.toml
[versions]
retrofit = "2.11.0"
okhttp = "4.12.0"
kotlinxSerialization = "1.7.3"
[libraries]
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
dependencies {
implementation(libs.retrofit)
implementation(libs.retrofit.serialization)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
testImplementation(libs.okhttp.mockwebserver)
}
Retrofit 2.11 自带 converter-kotlinx-serialization,不再需要社区维护的第三方转换器;OkHttp 4.12 是 4.x 的稳定末版,5.x 仍在演进。
三、接口声明与注解
3.1 基本注解
interface ArticleApi {
@GET("articles")
suspend fun list(
@Query("page") page: Int,
@Query("size") size: Int = 20
): List<ArticleDto>
@GET("articles/{id}")
suspend fun detail(@Path("id") id: String): ArticleDto
@POST("articles")
suspend fun create(@Body body: CreateArticleRequest): ArticleDto
@FormUrlEncoded
@POST("login")
suspend fun login(
@Field("username") username: String,
@Field("password") password: String
): TokenDto
}
| 注解 | 用途 | 注意 |
|---|---|---|
@Path | 替换 URL 占位符 | 默认会做 URL 编码,encoded = true 关闭 |
@Query | 拼查询参数 | 传 null 则省略该参数 |
@QueryMap | 批量查询参数 | 适合动态筛选 |
@Body | 请求体 | 由 ConverterFactory 序列化 |
@Field | 表单字段 | 必须配 @FormUrlEncoded |
@Header / @Headers | 请求头 | 动态用 @Header,静态用 @Headers |
3.2 suspend 与 Call 的取舍
| 形式 | 返回 | 取消 | 异常 | 建议 |
|---|---|---|---|---|
suspend fun | 直接返回体 | 随协程取消 | 抛异常 | 首选 |
Call<T> | 需 enqueue/execute | 手动 cancel | 回调 onFailure | 仅在需要进度或流式时用 |
suspend 接口由 Retrofit 内部通过 KotlinExtensions 适配,取消协程时会同步取消底层 Call,无需手动管理。这与 Kotlin 协程的结构化并发
天然契合——不过要注意,异常会直接抛出,必须用 try/catch 或 runCatching 包住。
四、ConverterFactory 选型
Retrofit 用 ConverterFactory 把 HTTP body 与 Kotlin 对象互转。三种主流方案的差异如下。
| 维度 | kotlinx.serialization | Moshi | Gson |
|---|---|---|---|
| 原理 | 编译期生成序列化器 | 反射 + 代码生成 | 纯反射 |
| Kotlin 空安全 | 原生支持 | 支持(Kotlin 代码生成) | 不识别,易出现 null |
| 默认值 | 支持 | 支持 | 不支持 |
| 混淆 | 无需 keep | 代码生成无需 keep | 需大量 keep |
| 性能 | 高 | 高 | 中 |
| 多平台 | 支持 KMP | 部分 | 否 |
4.1 kotlinx.serialization 的接入
@Serializable
data class ArticleDto(
val id: String,
val title: String,
@SerialName("updated_at") val updatedAt: Long,
val tags: List<String> = emptyList()
)
val json = Json {
ignoreUnknownKeys = true // 服务端加字段不崩
explicitNulls = false
coerceInputValues = true
}
val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.client(okHttpClient)
.build()
ignoreUnknownKeys = true 是必开项:服务端随时可能加字段,关掉它会导致反序列化直接抛异常。
一句话总结: 新项目优先 kotlinx.serialization——它编译期生成、无需反射、天然支持 Kotlin 空安全与默认值,是三者中与 Kotlin 最契合的。
五、OkHttpClient 单例与连接池
5.1 为什么必须单例
每个 OkHttpClient 都持有自己的连接池与线程池。若每次请求都 OkHttpClient() 新建,会不断创建线程与连接,导致内存与 fd 耗尽。正确做法是全局一个实例(或用 newBuilder() 派生共享连接池的变体)。
object HttpClientFactory {
val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.callTimeout(60, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.connectionPool(ConnectionPool(5, 5, TimeUnit.MINUTES))
.addInterceptor(AuthInterceptor())
.addInterceptor(HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG)
HttpLoggingInterceptor.Level.BODY
else
HttpLoggingInterceptor.Level.NONE
})
.build()
}
}
5.2 四类超时的区别
connectTimeout 管建立 TCP 连接(建议 10s),readTimeout 管等待响应字节、writeTimeout 管发送请求体(各 30s),callTimeout 则是整个调用的总闸(60s),能防止某个慢接口在 read 阶段反复重试而无限等待。四者缺一不可。
六、拦截器与鉴权
6.1 Interceptor 与 Authenticator 的分工
| 组件 | 触发时机 | 典型用途 |
|---|---|---|
| Interceptor | 每次请求前后 | 加 header、日志、公共参数 |
| Authenticator | 收到 401 时 | 刷新 token 并重放请求 |
class AuthInterceptor(private val tokenStore: TokenStore) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenStore.accessToken
val request = chain.request().newBuilder()
.apply { if (token != null) header("Authorization", "Bearer $token") }
.build()
return chain.proceed(request)
}
}
class TokenAuthenticator(
private val tokenStore: TokenStore,
private val refreshApi: RefreshApi
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= 2) return null // 防死循环
val newToken = runBlocking { refreshApi.refresh(tokenStore.refreshToken) }
?: return null
tokenStore.save(newToken)
return response.request.newBuilder()
.header("Authorization", "Bearer ${newToken.accessToken}")
.build()
}
private fun responseCount(response: Response): Int {
var count = 1
var prior = response.priorResponse
while (prior != null) { count++; prior = prior.priorResponse }
return count
}
}
坑:
Authenticator里刷新失败若直接返回同一个请求,会导致无限 401 循环。必须用responseCount限制重试次数,并确保刷新接口本身不带该 Authenticator。
6.2 动态 header
@GET("articles")
suspend fun list(
@Header("X-Client-Version") version: String,
@HeaderMap extras: Map<String, String>
): List<ArticleDto>
静态 header 用 @Headers("Cache-Control: no-cache") 写在方法上。
七、错误处理
7.1 HttpException
suspend 接口在非 2xx 时抛出 HttpException,网络故障则抛 IOException。两者要分开处理。
sealed interface ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>
data class HttpError(val code: Int, val message: String) : ApiResult<Nothing>
data class NetworkError(val cause: IOException) : ApiResult<Nothing>
data class UnknownError(val cause: Throwable) : ApiResult<Nothing>
}
7.2 统一封装
suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> =
try {
ApiResult.Success(block())
} catch (e: HttpException) {
ApiResult.HttpError(e.code(), e.message())
} catch (e: IOException) {
ApiResult.NetworkError(e)
} catch (e: CancellationException) {
throw e // 取消必须重新抛出
} catch (e: Throwable) {
ApiResult.UnknownError(e)
}
CancellationException 必须原样抛出,否则协程的取消信号会被吞掉——这是异常处理里最容易犯的错,与 Java 异常处理与防御式编程
中「不要吞掉不该吞的异常」原则一致。
八、重试与指数退避
对幂等请求(GET)可在拦截器里做重试,用指数退避避免雪崩。
class RetryInterceptor(
private val maxRetries: Int = 3
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
var attempt = 0
var lastError: IOException? = null
while (attempt <= maxRetries) {
try {
val response = chain.proceed(request)
if (response.isSuccessful || response.code < 500) return response
response.close() // 5xx 才重试
} catch (e: IOException) {
lastError = e
}
attempt++
if (attempt > maxRetries) break
val backoff = (1L shl attempt) * 200L // 400ms, 800ms, 1600ms
Thread.sleep(backoff)
}
throw lastError ?: IOException("retry exhausted")
}
}
权衡: 重试只对幂等请求安全。POST 创建订单若超时后重试,可能产生重复订单。非幂等请求应交给服务端幂等键,而非客户端盲目重试。
九、证书固定
防中间人攻击可用 CertificatePinner 固定服务端证书公钥。
val pinner = CertificatePinner.Builder()
.add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("api.example.com", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
.build()
val client = OkHttpClient.Builder()
.certificatePinner(pinner)
.build()
必须同时配置主证书与备用证书的 pin,否则证书轮换当天全量用户请求失败。CertificatePinner 的 pin 是公钥哈希,不是证书哈希,更换证书但保留密钥对不会失效。
十、用 MockWebServer 做测试
MockWebServer 让你在 JVM 单测里模拟 HTTP 响应,无需真机与线上环境。
class ArticleApiTest {
private lateinit var server: MockWebServer
private lateinit var api: ArticleApi
@Before fun setUp() {
server = MockWebServer()
server.start()
api = Retrofit.Builder()
.baseUrl(server.url("/"))
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(ArticleApi::class.java)
}
@After fun tearDown() = server.shutdown()
@Test
fun `list parses response`() = runTest {
server.enqueue(MockResponse()
.setResponseCode(200)
.setBody("""[{"id":"1","title":"hi","updated_at":1}]""")
.addHeader("Content-Type", "application/json"))
val result = api.list(page = 1)
assertEquals(1, result.size)
assertEquals("hi", result[0].title)
}
}
MockWebServer 还能用 setSocketPolicy(SocketPolicy.NO_RESPONSE) 模拟超时,用 setResponseCode(500) 验证重试逻辑。
十一、日志拦截器只在 debug 开启
HttpLoggingInterceptor 的 BODY 级别会打印完整请求体与响应体,包含 token、密码等敏感信息,因此必须用 BuildConfig.DEBUG 包一层:debug 用 Level.BODY,release 用 Level.NONE。否则 release 包的日志可能被第三方 SDK 或系统日志收集。若需要线上可观测,应改用脱敏的自定义拦截器,只记录 URL、状态码与耗时。
十二、R8 与 keep 规则
kotlinx.serialization 在编译期生成序列化器,通常无需 keep;但反射型序列化(Gson)依赖字段名与泛型签名,R8 会混淆它们。若用 Gson,需要保留 Signature 与 *Annotation* 属性、保留 DTO 包全部成员,并为带 @SerializedName 的字段开例外。Retrofit 自身则需要保留泛型签名与 Call、Response 的类名。
-keepattributes Signature, InnerClasses, EnclosingMethod, *Annotation*
-keep class com.example.data.dto.** { *; }
-keepclassmembers,allowobfuscation class * {
@com.google.gson.annotations.SerializedName <fields>;
}
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class retrofit2.Response
这也是选 kotlinx.serialization 的现实理由之一:省去一堆 keep 规则与由此带来的包体积和排错成本。
十三、与协程调度器的关系
Retrofit 的 suspend 接口内部使用 OkHttp 的异步机制,本身不占用调用线程。因此不需要手动 withContext(Dispatchers.IO)——多此一举反而增加线程切换。只有当调用方在 Dispatchers.Main 上、且下游有阻塞操作(如解析大 JSON)时,才考虑切换。
| 场景 | 是否需要切 IO |
|---|---|
| Retrofit suspend 接口 | 不需要 |
| 手动 OkHttp 同步 execute | 需要 |
| 大响应体解析 | 视情况 |
十四、常见坑清单
| 坑 | 表现 | 规避方式 |
|---|---|---|
| 每次新建 OkHttpClient | 连接与线程泄漏 | 全局单例 |
| baseUrl 缺尾斜杠 | 路径拼接错乱 | baseUrl 以 / 结尾 |
| 未开 ignoreUnknownKeys | 服务端加字段即崩 | Json 配置打开 |
| 401 无限重试 | 请求风暴 | Authenticator 限次 |
| 吞掉 CancellationException | 取消失效、泄漏 | 原样抛出 |
| 在 release 打 BODY 日志 | 敏感信息泄漏 | BuildConfig.DEBUG 判断 |
| POST 超时后盲目重试 | 重复下单 | 幂等键或禁重试 |
| 只配一个证书 pin | 证书轮换全量失败 | 主备双 pin |
| Gson 未加 keep | release 解析为 null | 加 keep 规则 |
| 同步 execute 在主线程 | NetworkOnMainThreadException | 切 IO 或用 suspend |
小结
网络层的质量不取决于用了什么库,而取决于三件事是否做到位:客户端单例与超时配置是否合理、鉴权与错误处理是否统一、测试与混淆规则是否覆盖。Retrofit 负责声明式的接口定义,OkHttp 负责连接与拦截,二者配合 Android 数据持久化与 Room 便构成了「网络拉取、数据库落地、UI 订阅」的离线优先闭环。先把这层收敛干净,后续换序列化库、加缓存、接重试都只是局部改动。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。