Android 网络层与 Retrofit 实践

网络层是 Android 应用最脆弱的一环,超时、重试与错误封装稍有疏漏就会引发线上问题。本文用 Retrofit 2.11 与 OkHttp 4.12 搭建可维护的网络层:接口注解与挂起函数设计、转换器工厂在三种序列化方案间的取舍、拦截器与认证器、超时与连接池配置、统一错误封装与退避重试,以及证书固定、模拟服务器测试与混淆保留规则。文末给出网络层常见坑清单与排查思路。

网络层是 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.serializationMoshiGson
原理编译期生成序列化器反射 + 代码生成纯反射
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 未加 keeprelease 解析为 null加 keep 规则
同步 execute 在主线程NetworkOnMainThreadException切 IO 或用 suspend

小结

网络层的质量不取决于用了什么库,而取决于三件事是否做到位:客户端单例与超时配置是否合理、鉴权与错误处理是否统一、测试与混淆规则是否覆盖。Retrofit 负责声明式的接口定义,OkHttp 负责连接与拦截,二者配合 Android 数据持久化与 Room 便构成了「网络拉取、数据库落地、UI 订阅」的离线优先闭环。先把这层收敛干净,后续换序列化库、加缓存、接重试都只是局部改动。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Android 开发」更多文章

  1. Kotlin Multiplatform 跨平台共享
  2. Android R8 混淆与 Baseline Profile
  3. Android 测试体系:单元测试、Espresso 与 Compose 测试