如何编写一个自定义拦截器来统一添加请求头(如 Token)?

解读

在国内面试中,这道题表面问“怎么写拦截器”,实则考察三层能力:

  1. 对主流网络框架(OkHttp、Retrofit)的掌握深度;
  2. 对 Android 鉴权体系(Token 存储、刷新、并发安全)的理解;
  3. 对“统一”二字的工程化落地:进程内单例、线程安全、容错降级、灰度可控。
    面试官通常以“你项目里怎么做的”切入,随后追问“Token 失效 401 怎么办”“并发请求刷新 Token 怎样避免雪崩”“多 BaseUrl 如何动态切换”,因此答案必须体现真实踩坑经验,而非官网文档级别的“Hello Interceptor”。

知识点

  1. OkHttp 责任链模式:intercept(Chain) 是唯一入口,proceed() 驱动链条。
  2. 拦截器顺序:自定义头拦截器应置于 RetryAndFollowUpInterceptor 之后、Connect 之前,避免重定向时重复添加。
  3. 头字段规范:国内厂商 CDN 普遍要求 User-Agent、X-Requested-With、X-App-Channel 等字段,需与 Token 一起统一治理。
  4. Token 存储安全:Android 10+ 限制明文 SharedPreferences,需用 EncryptedSharedPreferences + Keystore;敏感业务需存 TEE(Trusty OS)。
  5. 401 重试策略:
    • 同步锁 + 单例 RefreshTokenWorker,防止多线程并发刷新;
    • 采用 OkHttp 的 Authenticator 接口,但注意它只会在 401 触发一次,需手动更新全局 Token 并 retry;
    • 失败次数上限与退避算法,避免死循环。
  6. 包体优化:拦截器内禁止做耗时 I/O(如直接读 DB),应使用内存级 TokenHolder(LiveData/StateFlow)做缓存,后台线程异步刷新。
  7. 合规:工信部 164 号文要求网络请求需埋点“统一社会信用代码”与“渠道号”,拦截器是天然埋点位置。
  8. 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 的扩展函数,业务线无感集成。

拓展思考

  1. 多域名/多环境场景:拦截器里能否直接修改 request.url
    答:可以,但需重新 request.newBuilder().url(newUrl).build(),且注意证书校验与 HostnameVerifier 的配套更新;更优雅的做法是使用 OkHttp 的 Dns + CertificatePinner 做环境分流,拦截器只负责头信息。

  2. Token 过期时间极短(5 min)如何优化?
    答:

  • 采用“并发窗口”机制:在 Token 过期前 30 s 由 WorkManager 提前静默刷新,减少 401 触发;
  • 利用 HTTP/2 的多路复用,刷新请求与业务请求共享连接,降低 RTT。
  1. 合规层面:工信部要求“可关闭个性化推荐”,拦截器如何动态下发放行开关?
    答:把开关配置放在远端 MMS(Mobile Management Service),通过推送同步到本地 MMKV;拦截器内读取开关,若关闭则自动添加 X-Recommend-Off: 1,后端据此降级推荐数据。

  2. 面试反向提问:
    “贵公司是否采用 mTLS 双向证书?如果采用,拦截器里还需要额外处理客户端证书轮换吗?”
    通过反向追问,可把话题引入更底层的 SSL/TLS 优化,体现深度。