如何编写一个自定义拦截器来统一添加请求头(如 Token)?
解读
在国内面试中,这道题表面问“怎么写拦截器”,实则考察三层能力:
- 对主流网络框架(OkHttp、Retrofit)的掌握深度;
- 对 Android 鉴权体系(Token 存储、刷新、并发安全)的理解;
- 对“统一”二字的工程化落地:进程内单例、线程安全、容错降级、灰度可控。
面试官通常以“你项目里怎么做的”切入,随后追问“Token 失效 401 怎么办”“并发请求刷新 Token 怎样避免雪崩”“多 BaseUrl 如何动态切换”,因此答案必须体现真实踩坑经验,而非官网文档级别的“Hello Interceptor”。
知识点
- OkHttp 责任链模式:intercept(Chain) 是唯一入口,proceed() 驱动链条。
- 拦截器顺序:自定义头拦截器应置于 RetryAndFollowUpInterceptor 之后、Connect 之前,避免重定向时重复添加。
- 头字段规范:国内厂商 CDN 普遍要求 User-Agent、X-Requested-With、X-App-Channel 等字段,需与 Token 一起统一治理。
- Token 存储安全:Android 10+ 限制明文 SharedPreferences,需用 EncryptedSharedPreferences + Keystore;敏感业务需存 TEE(Trusty OS)。
- 401 重试策略:
- 同步锁 + 单例 RefreshTokenWorker,防止多线程并发刷新;
- 采用 OkHttp 的 Authenticator 接口,但注意它只会在 401 触发一次,需手动更新全局 Token 并 retry;
- 失败次数上限与退避算法,避免死循环。
- 包体优化:拦截器内禁止做耗时 I/O(如直接读 DB),应使用内存级 TokenHolder(LiveData/StateFlow)做缓存,后台线程异步刷新。
- 合规:工信部 164 号文要求网络请求需埋点“统一社会信用代码”与“渠道号”,拦截器是天然埋点位置。
- AAR 化:中大型公司会把网络模块打成独立 AAR,拦截器通过 SPI 插件化注册,业务方无侵入。
答案
以下代码基于 OkHttp 4.x + Kotlin,覆盖“统一添加 Token、401 自动刷新、并发安全、灰度降级”四个核心场景,可直接用于面试白板手写。
// 1. 内存级 Token 缓存,线程安全
object TokenHolder {
@Volatile
private var accessToken: String? = null
private val tokenFlow = MutableStateFlow<String?>(null)
fun token(): String? = accessToken
suspend fun refresh(): Boolean {
// 使用单例 Worker,多协程并发时只会有一个真正刷新
return mutex.withLock {
val newToken = AuthApi.refresh() // 同步挂起函数
accessToken = newToken
tokenFlow.value = newToken
true
}
}
private val mutex = Mutex()
}
// 2. 统一头拦截器
class GlobalHeaderInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val builder = request.newBuilder()
.header("X-Requested-With", "XMLHttpRequest")
.header("X-App-Channel", BuildConfig.FLAVOR)
.header("User-Agent", System.getProperty("http.agent") ?: "")
// 只在需要认证的接口添加 Token
if (request.header("No-Auth") == null) {
TokenHolder.token()?.let {
builder.header("Authorization", "Bearer $it")
}
}
return chain.proceed(builder.build())
}
}
// 3. 401 自动刷新 Authenticator
class TokenAuthenticator : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
if (response.code != 401) return null
// 防止无限重试
if (response.request.header("Authorization") == null) return null
return runBlocking {
val refreshed = TokenHolder.refresh()
if (refreshed) {
response.request.newBuilder()
.header("Authorization", "Bearer ${TokenHolder.token()}")
.build()
} else {
// 刷新失败,跳转登录页
EventBus.getDefault().post(LogoutEvent)
null
}
}
}
}
// 4. 注册
val okHttp = OkHttpClient.Builder()
.addInterceptor(GlobalHeaderInterceptor())
.authenticator(TokenAuthenticator())
.build()
// 5. 使用示例:个别接口不需要 Token
@GET("config/v1/splash")
@Headers("No-Auth: true")
suspend fun getSplash(): SplashConfig
亮点讲解(面试口语化):
- 用 StateFlow 做内存缓存,避免每次读磁盘;
- Mutex 保证并发刷新时只有一个网络请求,防止雪崩;
- 通过自定义 header
No-Auth让业务方灵活跳过鉴权,比白名单 URL 维护成本低; - Authenticator 返回 null 即放弃重试,配合 EventBus 统一跳登录,防止循环弹窗;
- 整个模块打成 network.aar,对外只暴露
OkHttpClient.Builder的扩展函数,业务线无感集成。
拓展思考
-
多域名/多环境场景:拦截器里能否直接修改
request.url?
答:可以,但需重新request.newBuilder().url(newUrl).build(),且注意证书校验与 HostnameVerifier 的配套更新;更优雅的做法是使用 OkHttp 的Dns+CertificatePinner做环境分流,拦截器只负责头信息。 -
Token 过期时间极短(5 min)如何优化?
答:
- 采用“并发窗口”机制:在 Token 过期前 30 s 由 WorkManager 提前静默刷新,减少 401 触发;
- 利用 HTTP/2 的多路复用,刷新请求与业务请求共享连接,降低 RTT。
-
合规层面:工信部要求“可关闭个性化推荐”,拦截器如何动态下发放行开关?
答:把开关配置放在远端 MMS(Mobile Management Service),通过推送同步到本地 MMKV;拦截器内读取开关,若关闭则自动添加X-Recommend-Off: 1,后端据此降级推荐数据。 -
面试反向提问:
“贵公司是否采用 mTLS 双向证书?如果采用,拦截器里还需要额外处理客户端证书轮换吗?”
通过反向追问,可把话题引入更底层的 SSL/TLS 优化,体现深度。