OkHttp 拦截器:自定义网络日志、缓存与重试
初识 OkHttp 拦截器
拦截器是 OkHttp 中非常强大的功能,它允许你监控、修改和重试 HTTP 调用。无论是添加统一的请求头、记录网络日志、实现缓存策略还是自动重试失败请求,都可以通过拦截器优雅地完成,无需在业务代码中到处重复逻辑。
OkHttp 的拦截器分为两种:
- 应用拦截器(Application Interceptors):通过
addInterceptor()添加,作用于最顶层,能拦截最终发出的请求和最初返回的响应,不会重定向和重试影响。 - 网络拦截器(Network Interceptors):通过
addNetworkInterceptor()添加,更接近网络层,能捕获到重定向和重试等中间过程。
理解它们的区别后,我们将通过三个典型场景手把手教你自定义拦截器:记录网络日志、实现透明缓存、构建失败重试机制。
环境准备
确保你的项目已添加 OkHttp 依赖。以 Gradle 为例:
implementation("com.squareup.okhttp3:okhttp:4.12.0")
创建 OkHttpClient 实例,并添加我们的自定义拦截器:
val client = OkHttpClient.Builder()
.addInterceptor(/* 应用拦截器 */)
.addNetworkInterceptor(/* 网络拦截器 */)
.build()
下面我们逐步实现三种常用拦截器。
自定义网络日志拦截器
很多时候需要查看请求的详细信息和响应时间,用来调试接口。我们创建一个 LoggingInterceptor 来打印完整的请求行、请求头和请求体(如果安全),以及响应码、响应头和响应体。
拦截器实现
import okhttp3.Interceptor
import okhttp3.Response
import okhttp3.internal.http.promisesBody
import okio.Buffer
import java.io.IOException
import java.nio.charset.Charset
class LoggingInterceptor : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val startTime = System.nanoTime()
// 打印请求信息
println("--> ${request.method} ${request.url} HTTP/1.1")
if (request.headers.size > 0) {
println("Headers: ${request.headers}")
}
if (request.body != null && request.body!!.contentType() != null) {
println("Content-Type: ${request.body!!.contentType()}")
}
if (request.body != null && request.body!!.contentLength() != -1L) {
println("Content-Length: ${request.body!!.contentLength()}")
}
// 可选:打印请求体(限于非加密或文本类型)
request.body?.let { body ->
if (body.contentType()?.let { it.type == "text" || it.subtype == "json" || it.subtype == "x-www-form-urlencoded" } == true) {
val buffer = Buffer()
body.writeTo(buffer)
val charset = body.contentType()?.charset(Charset.forName("UTF-8")) ?: Charset.forName("UTF-8")
println("Body: ${buffer.readString(charset)}")
buffer.close()
}
}
println("--> END ${request.method}")
val response: Response
try {
response = chain.proceed(request)
} catch (e: Exception) {
println("<-- HTTP FAILED: $e")
throw e
}
val tookMs = (System.nanoTime() - startTime) / 1_000_000
println("<-- ${response.code} ${request.url} (${tookMs}ms)")
if (response.headers.size > 0) {
println("Headers: ${response.headers}")
}
response.body?.let { body ->
if (body.contentType() != null) {
println("Content-Type: ${body.contentType()}")
}
if (body.contentLength() != -1L) {
println("Content-Length: ${body.contentLength()}")
}
// 打印响应体(同样只处理文本类)
if (body.contentType()?.let { it.type == "text" || it.subtype == "json" } == true) {
val source = body.source()
source.request(Long.MAX_VALUE)
val buffer = source.buffer
val charset = body.contentType()?.charset(Charset.forName("UTF-8")) ?: Charset.forName("UTF-8")
if (buffer.size > 0) {
println("Body: ${buffer.clone().readString(charset)}")
}
}
}
println("<-- END HTTP")
return response
}
}
添加到客户端
建议使用应用拦截器,这样即使发生重定向,也能看到最初的请求和最终的响应。或者同时添加网络拦截器观察中间步骤。
val client = OkHttpClient.Builder()
.addInterceptor(LoggingInterceptor())
.build()
运行后,你将在控制台看到类似这样的输出:
--> GET https://httpbin.org/get HTTP/1.1
Headers: Connection: Keep-Alive, User-Agent: okhttp/4.12.0
--> END GET
<-- 200 https://httpbin.org/get (152ms)
Headers: Content-Type: application/json, Content-Length: 255
Body: { "args": {}, ... }
<-- END HTTP
缓存拦截器
OkHttp 本身支持 HTTP 缓存,但有时我们需要更灵活的控制,比如强制使用缓存一定时间、离线时使用缓存数据,或者根据业务逻辑自定义缓存键。这里我们通过拦截器实现一个简单但实用的内存缓存策略。
设计思路
- 使用
LruCache(或Cache接口,这里为演示用内存缓存)保存请求的响应。 - 拦截器的
intercept方法中,先用请求的url和method构建缓存键。 - 若缓存存在且未过期,直接返回缓存的响应。
- 若不存在或过期,则执行网络请求,将响应存入缓存后再返回。
- 可以添加
Cache-Control头让 OkHttp 内置缓存来配合,但这里完全自定义演示。
拦截器实现
import okhttp3.*
import okhttp3.internal.cache.CacheStrategy
import okhttp3.internal.cache.InternalCache
import okio.Buffer
import java.io.IOException
class ForceCacheInterceptor(private val maxAgeSeconds: Int) : Interceptor {
private val cache = object : LinkedHashMap<String, CachedResponse>() {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, CachedResponse>?): Boolean {
// 保持缓存数量,防止内存溢出
return size > 100
}
}
data class CachedResponse(val response: Response, val timestamp: Long)
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val cacheKey = "${request.method}:${request.url}"
// 检查缓存
cache[cacheKey]?.let { cached ->
val ageSeconds = (System.currentTimeMillis() - cached.timestamp) / 1000
if (ageSeconds < maxAgeSeconds) {
// 缓存未过期,直接返回
println("Cache hit for $cacheKey (age ${ageSeconds}s)")
return cached.response.newBuilder()
.removeHeader("Content-Encoding") // 防止双重编码
.build()
} else {
// 过期移除
cache.remove(cacheKey)
}
}
// 执行网络请求
val networkResponse = chain.proceed(request)
// 缓存可缓存的响应(状态码 200 且方法为 GET)
if (request.method == "GET" && networkResponse.code == 200) {
val body = networkResponse.body ?: return networkResponse
val bodyBytes = body.bytes() // 读取响应体,注意会消耗原始body
val newBody = ResponseBody.create(body.contentType(), bodyBytes)
val cachedResponse = networkResponse.newBuilder()
.body(newBody)
.build()
cache[cacheKey] = CachedResponse(cachedResponse, System.currentTimeMillis())
println("Cached response for $cacheKey")
return networkResponse.newBuilder()
.body(ResponseBody.create(body.contentType(), bodyBytes))
.build()
}
return networkResponse
}
}
使用示例
val client = OkHttpClient.Builder()
.addInterceptor(ForceCacheInterceptor(maxAgeSeconds = 60)) // 60 秒内复用缓存
.build()
// 第一次请求会访问网络
val response1 = client.newCall(Request.Builder().url("https://httpbin.org/get").build()).execute()
// 第二次立即请求会命中缓存
val response2 = client.newCall(Request.Builder().url("https://httpbin.org/get").build()).execute()
对于更正式的生产环境,推荐使用 OkHttp 自带的 Cache 类并设置缓存目录,此拦截器更适合演示自定义逻辑。
失败重试拦截器
网络波动时,自动重试请求可以大大提高成功率。我们需要小心处理重试逻辑,避免对非幂等请求(如 POST)盲目重试。
设计重试规则
- 只重试
GET请求(幂等)。 - 遇到网络异常(如
IOException)或服务器返回5xx错误时重试。 - 设置最大重试次数和重试间隔(指数退避)。
拦截器实现
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import kotlin.math.pow
class RetryInterceptor(private val maxRetry: Int = 3) : Interceptor {
@Throws(IOException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
var tryCount = 0
var lastException: IOException? = null
var response: Response? = null
while (tryCount <= maxRetry) {
if (tryCount > 0) {
// 指数退避等待:1s, 2s, 4s...
val waitSeconds = 2.0.pow(tryCount - 1).toLong() * 1000L
println("Retry ($tryCount/$maxRetry) after ${waitSeconds}ms for ${request.url}")
Thread.sleep(waitSeconds)
}
try {
// 如果是重试,需要重新创建 network 层面的请求?这里直接 proceed
response = chain.proceed(request)
if (response.isSuccessful || request.method != "GET" || tryCount >= maxRetry) {
// 成功或非 GET 请求或达到最大次数终止重试
return response
}
// 如果是 5xx 错误并且是 GET 请求,继续重试
if (response.code in 500..599) {
println("Retrying due to server error ${response.code}")
response.close()
tryCount++
continue
}
// 其他情况直接返回
return response
} catch (e: IOException) {
println("IOException: ${e.message}")
if (request.method != "GET" || tryCount >= maxRetry) {
throw e
}
lastException = e
tryCount++
}
}
// 如果所有重试都失败,抛出最后遇到的异常或返回最后的响应
if (lastException != null) throw lastException
return response!! // 理论上不会到这里
}
}
添加拦截器
推荐添加为应用拦截器,这样重试逻辑对所有的网络交互都生效。同时注意不要和内置自动重连混淆。
val client = OkHttpClient.Builder()
.addInterceptor(RetryInterceptor(maxRetry = 3))
.build()
测试效果
你可以模拟一个不稳定的端点,或使用 httpbin.org/status/500 观察重试日志:
--> GET https://httpbin.org/status/500
Retrying due to server error 500
Retry (1/3) after 1000ms for https://httpbin.org/status/500
Retrying due to server error 500
Retry (2/3) after 2000ms for ...
这样即使接口偶发故障,也能通过自动重试提高用户体验。
拦截器组合与注意事项
在实际项目中,你可能会将多个拦截器搭配使用:
val client = OkHttpClient.Builder()
.addInterceptor(LoggingInterceptor())
.addInterceptor(RetryInterceptor())
.addInterceptor(ForceCacheInterceptor(60))
.build()
- 执行顺序:应用拦截器按添加顺序依次执行,每个拦截器的
intercept里chain.proceed()会调用下一个拦截器,最终到达网络。网络拦截器同理。 - 不要消耗响应体:如果拦截器读取了响应体用于日志或缓存,必须用
peekBody或复制一份新的 ResponseBody,否则下游将无法读取。 - 线程安全:拦截器可能在多线程中执行,共享可变状态时要保证线程安全(如使用同步集合)。
- 重试与缓存冲突:重试拦截器最好放在缓存拦截器下方(即更早添加),这样缓存命中后不会触发重试逻辑。
小结
通过自定义拦截器,你可以在 OkHttp 的请求-响应链上注入强大而统一的横切关注点:
- 日志拦截器提供了全透明的网络调试能力。
- 缓存拦截器实现了高效的本地数据复用,减少网络消耗。
- 重试拦截器增强了应用的健壮性,优雅处理短暂故障。
这只是拦截器能力的冰山一角。你可以扩展实现签名认证、动态域名替换、请求加密等更高级的功能。掌握拦截器,你就能真正掌控 OkHttp 的每一次网络交互。