Retrofit 与 OkHttp 工程化:拦截器、错误处理与缓存策略
很多项目刚接入网络请求时,只要能把接口调通就算完成:Retrofit 定义接口,OkHttp 创建客户端,Repository 里调用一下,页面拿到数据就展示。这个阶段代码看起来很轻,但业务一复杂,问题会集中冒出来:Token 过期如何统一刷新,接口错误码如何转成页面可理解的状态,弱网下要不要读缓存,请求日志如何避免泄露敏感信息,多个模块如何复用同一套网络规则。
网络层不是简单的工具封装,它是应用和服务端之间的边界。这个边界做得清楚,业务代码会很干净;做得随意,页面、仓库、拦截器、异常处理会互相缠在一起,后期很难维护。本文从一个可落地的 Android 网络层出发,梳理 Retrofit 与 OkHttp 在项目里的分工,并把拦截器、统一错误处理和缓存策略串成一套工程化方案。
网络层先分清职责
Retrofit 和 OkHttp 经常一起出现,但它们解决的问题不同。
OkHttp 更靠近 HTTP 本身,负责连接、请求、响应、缓存、拦截器、超时、连接池等底层能力。Retrofit 更靠近业务接口,负责把 HTTP API 声明成 Kotlin/Java 方法,并通过 Converter 把 JSON 转成对象,通过 CallAdapter 把请求适配成协程、RxJava 或其他调用形式。
一个清晰的分层通常是这样:
UI / ViewModel
↓
Repository
↓
ApiService(Retrofit 接口)
↓
Retrofit(Converter / CallAdapter)
↓
OkHttpClient(Interceptor / Cache / Timeout)
↓
Server
每一层只做自己的事:
| 层级 | 该做什么 | 不该做什么 |
|---|---|---|
| ViewModel | 触发请求、组合 UI 状态 | 拼请求头、判断 HTTP 状态码 |
| Repository | 编排数据来源、转换领域模型 | 处理 Token 刷新细节 |
| Retrofit 接口 | 声明接口路径、参数、请求方式 | 写业务兜底逻辑 |
| Converter / CallAdapter | 数据转换、调用结果适配 | 操作 UI 状态 |
| OkHttp Interceptor | Header、日志、认证、缓存、重试 | 直接依赖页面或业务组件 |
这个分工一旦稳定,后续接入新接口、新模块、新环境会轻很多。
用统一的客户端构建入口
不要在每个模块里随手 new 一个 Retrofit 或 OkHttpClient。真实项目里至少会有这些公共配置:
- BaseUrl
- 连接、读取、写入超时
- 公共 Header
- Token 注入
- 日志拦截器
- 缓存目录和缓存大小
- JSON 解析器
- 错误结果适配
可以先把构建入口收拢到一个模块里:
object NetworkModule {
fun createOkHttpClient(
tokenProvider: TokenProvider,
cacheDir: File,
debug: Boolean
): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.cache(Cache(File(cacheDir, "http_cache"), 50L * 1024L * 1024L))
.addInterceptor(HeaderInterceptor(tokenProvider))
.addInterceptor(CacheControlInterceptor())
.apply {
if (debug) addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
}
.build()
}
fun createRetrofit(client: OkHttpClient, baseUrl: String): Retrofit {
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.addConverterFactory(MoshiConverterFactory.create())
.addCallAdapterFactory(ApiResultCallAdapterFactory())
.build()
}
}
这里的重点不是写一个巨大单例,而是让网络规则有一个明确入口。后面如果要切换环境、加签名、加灰度 Header、调整缓存策略,都能在同一条链路里处理。
拦截器要分层,不要写成万能拦截器
OkHttp 拦截器很强,但也容易被滥用。很多项目会把 Header、Token、错误码、日志、缓存、重试全部塞进一个 Interceptor,短期省事,长期很难测,也很难改。
更稳的方式是按职责拆开:
HeaderInterceptor:添加通用 HeaderAuthInterceptor:处理认证信息TokenRefreshAuthenticator:处理 401 后刷新 TokenCacheControlInterceptor:根据网络状态调整缓存策略RequestIdInterceptor:添加请求追踪 IDLoggingInterceptor:仅在调试环境打开
公共 Header 拦截器
class HeaderInterceptor(
private val tokenProvider: TokenProvider
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val origin = chain.request()
val builder = origin.newBuilder()
.header("Accept", "application/json")
.header("Platform", "Android")
.header("App-Version", BuildConfig.VERSION_NAME)
tokenProvider.currentToken()?.let { token ->
builder.header("Authorization", "Bearer $token")
}
return chain.proceed(builder.build())
}
}
这类拦截器只负责补充请求信息,不应该顺手处理业务错误码。职责越窄,越容易测试。
Token 刷新交给 Authenticator
Token 过期是网络层常见难点。很多人会在普通 Interceptor 里发现 401 后直接同步刷新,然后重新发起请求。这可以工作,但容易出现并发刷新、死循环、重复请求等问题。
OkHttp 提供了 Authenticator,更适合处理认证挑战:
class TokenRefreshAuthenticator(
private val tokenProvider: TokenProvider,
private val authApi: AuthApi
) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (responseCount(response) >= 2) return null
val oldToken = tokenProvider.currentToken() ?: return null
val newToken = synchronized(this) {
val latestToken = tokenProvider.currentToken()
if (latestToken != oldToken) {
latestToken
} else {
authApi.refreshTokenBlocking()?.also { tokenProvider.saveToken(it) }
}
} ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer $newToken")
.build()
}
private fun responseCount(response: Response): Int {
var count = 1
var prior = response.priorResponse
while (prior != null) {
count++
prior = prior.priorResponse
}
return count
}
}
这里有几个关键点:
- 限制重试次数,避免无限刷新
- 同步块内二次读取 Token,避免多个请求同时刷新
- 刷新失败时返回
null,把失败交给上层处理 - 刷新接口最好使用独立的 OkHttpClient,避免拦截器递归调用
统一错误模型,让业务少写 try/catch
接口失败通常有几类来源:
- HTTP 非 2xx,例如 401、403、500
- 服务端业务错误码,例如
code != 0 - JSON 解析失败
- 网络不可用、超时、DNS 失败
- 请求被取消
如果每个 Repository 都自己写 try/catch,很快会变成重复代码。更好的方式是统一成一个结果模型:
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class BizError(val code: Int, val message: String) : ApiResult<Nothing>()
data class HttpError(val code: Int, val message: String?) : ApiResult<Nothing>()
data class NetworkError(val throwable: IOException) : ApiResult<Nothing>()
data class UnknownError(val throwable: Throwable) : ApiResult<Nothing>()
}
然后让 Retrofit 接口直接返回这个模型:
interface UserApi {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: String): ApiResult<UserDto>
}
这背后可以通过 CallAdapter.Factory 统一适配,也可以先用一个安全调用函数落地。
从安全调用函数开始
如果项目还没准备好写 CallAdapter,可以先把错误转换收口到一个函数:
suspend inline fun <T> safeApiCall(
crossinline block: suspend () -> Response<ApiEnvelope<T>>
): ApiResult<T> {
return try {
val response = block()
if (!response.isSuccessful) {
return ApiResult.HttpError(response.code(), response.message())
}
val body = response.body()
?: return ApiResult.UnknownError(NullPointerException("empty body"))
if (body.code != 0) {
ApiResult.BizError(body.code, body.message)
} else {
ApiResult.Success(body.data)
}
} catch (e: IOException) {
ApiResult.NetworkError(e)
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
ApiResult.UnknownError(e)
}
}
注意 CancellationException 要重新抛出。协程取消不是普通失败,如果被吞掉,会破坏结构化并发,让页面退出后请求仍然继续跑。
Repository 中就可以这样写:
class UserRepository(private val api: UserApi) {
suspend fun loadUser(id: String): ApiResult<User> {
return when (val result = safeApiCall { api.getUserRaw(id) }) {
is ApiResult.Success -> ApiResult.Success(result.data.toDomain())
is ApiResult.BizError -> result
is ApiResult.HttpError -> result
is ApiResult.NetworkError -> result
is ApiResult.UnknownError -> result
}
}
}
业务层看到的是稳定的错误模型,不需要关心底层是超时、HTTP 状态码还是服务端业务码。
ViewModel 只处理页面状态
网络错误最终要落到 UI,但 ViewModel 不应该直接分析 OkHttp 异常类型。它更适合把 ApiResult 转换为页面状态:
sealed class UserUiState {
data object Loading : UserUiState()
data class Content(val user: User) : UserUiState()
data class Error(val message: String, val canRetry: Boolean) : UserUiState()
}
class UserViewModel(
private val repository: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow<UserUiState>(UserUiState.Loading)
val state: StateFlow<UserUiState> = _state.asStateFlow()
fun load(id: String) {
viewModelScope.launch {
_state.value = UserUiState.Loading
_state.value = when (val result = repository.loadUser(id)) {
is ApiResult.Success -> UserUiState.Content(result.data)
is ApiResult.BizError -> UserUiState.Error(result.message, canRetry = false)
is ApiResult.HttpError -> UserUiState.Error("服务暂时不可用", canRetry = true)
is ApiResult.NetworkError -> UserUiState.Error("网络连接异常,请稍后重试", canRetry = true)
is ApiResult.UnknownError -> UserUiState.Error("请求失败,请稍后再试", canRetry = true)
}
}
}
}
这样做的好处是:页面不用认识网络库,Repository 不用认识 UI 文案,网络层不用认识页面状态。每一层都可以独立演进。
缓存策略不要只靠服务端响应头
OkHttp 自带 HTTP 缓存,但它依赖响应头。如果服务端没有配置 Cache-Control,客户端默认不会随意缓存。很多移动端项目需要自己补充策略,例如:
- 首页配置、频道列表:短时间缓存,弱网可读旧数据
- 用户资料:按登录态缓存,退出登录必须清理
- 订单、支付、账户余额:不缓存或仅内存短暂缓存
- 静态字典、城市列表:长缓存,配合版本号更新
可以用拦截器对特定接口补充缓存头:
class CacheControlInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
val path = request.url.encodedPath
val maxAgeSeconds = when {
path.contains("/config") -> 10 * 60
path.contains("/dictionary") -> 24 * 60 * 60
else -> 0
}
return if (maxAgeSeconds > 0) {
response.newBuilder()
.header("Cache-Control", "public, max-age=$maxAgeSeconds")
.build()
} else {
response
}
}
}
如果要支持无网读取缓存,还需要根据网络状态改写请求:
class OfflineCacheInterceptor(
private val networkMonitor: NetworkMonitor
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
var request = chain.request()
if (!networkMonitor.isAvailable()) {
request = request.newBuilder()
.cacheControl(
CacheControl.Builder()
.onlyIfCached()
.maxStale(7, TimeUnit.DAYS)
.build()
)
.build()
}
return chain.proceed(request)
}
}
缓存策略要非常克制。能缓存什么、缓存多久、登录态变化如何清理,都应该明确写在规则里,而不是所有接口一刀切。
日志要能排查问题,也要保护敏感信息
调试网络问题时,请求日志很有用,但生产环境把完整 Header 和 Body 打出来风险很高。至少要遵守三条规则:
- 正式包关闭 BODY 级别日志
- Token、Cookie、手机号、身份证、地址等字段脱敏
- 上传文件、图片、二进制内容不要打印完整 Body
可以对日志拦截器做一层封装:
fun createLoggingInterceptor(debug: Boolean): Interceptor {
return HttpLoggingInterceptor { message ->
val safeMessage = message
.replace(Regex("Authorization: Bearer [^\\s]+"), "Authorization: Bearer ***")
.replace(Regex("\\\"phone\\\":\\\"[^\\\"]+\\\""), "\"phone\":\"***\"")
Log.d("ApiLog", safeMessage)
}.apply {
level = if (debug) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
}
这不是为了形式上的安全,而是为了避免日志系统、崩溃平台、远程排查工具里出现用户敏感信息。
多环境和动态 BaseUrl
开发、测试、预发、生产环境通常有不同的 BaseUrl。建议把环境选择和网络构建解耦:
data class ApiEnvironment(
val name: String,
val baseUrl: String
)
class EnvironmentProvider {
fun current(): ApiEnvironment {
return if (BuildConfig.DEBUG) {
ApiEnvironment("debug", "https://api-debug.example.com/")
} else {
ApiEnvironment("release", "https://api.example.com/")
}
}
}
如果项目需要运行时切换环境,不要在多个地方保存 BaseUrl。可以通过依赖注入重新创建 Retrofit,或在底层用专门的 HostSelectionInterceptor 处理,但要确保切换后旧请求、缓存和登录态不会串环境。
一个推荐的落地顺序
如果现有项目网络层比较散,不建议一次性重写。更稳的推进方式是:
- 先统一 OkHttpClient 和 Retrofit 创建入口
- 把 Header、日志、缓存拆成独立拦截器
- 引入统一
ApiResult,让新接口先使用 - 把高频 Repository 的 try/catch 收口到
safeApiCall - 再考虑自定义 CallAdapter,减少模板代码
- 补充 Token 刷新、缓存清理和日志脱敏测试
网络层改造最怕影响面不可控。按接口、模块、场景逐步迁移,比一次性全量替换更稳。
常见坑位清单
上线前可以按这份清单扫一遍:
- Token 刷新是否限制了重试次数
- 多个请求同时遇到 401 时是否只刷新一次
- 协程取消是否被错误地 catch 掉
- HTTP 错误、业务错误、网络异常是否能区分
- 正式包是否关闭详细网络日志
- 敏感 Header 和 Body 字段是否脱敏
- 缓存接口是否按登录态隔离
- 退出登录是否清理用户相关缓存
- BaseUrl 切换后是否会串缓存或串 Token
- Repository 是否还在大量重复 try/catch
这些问题不一定每天都出现,但一旦出现在生产环境,排查成本通常很高。
总结
Retrofit 与 OkHttp 的工程化核心不是多封装几层,而是把边界划清楚:OkHttp 处理 HTTP 能力,Retrofit 负责接口声明和调用适配,Repository 编排数据,ViewModel 只关心页面状态。
拦截器要按职责拆分,错误要统一成业务可理解的结果模型,缓存要按数据类型设计,日志要兼顾排查和隐私。这样网络层才不会随着业务增长变成一团隐形复杂度,而是成为项目里稳定、可测试、可演进的基础设施。