Retrofit 与 OkHttp 工程化:拦截器、错误处理与缓存策略

简介: 本文系统梳理 Retrofit 与 OkHttp 的工程化实践:明确二者职责边界(OkHttp 管 HTTP 底层,Retrofit 专注接口声明),详解分层拦截器设计、统一 ApiResult 错误模型、按场景定制缓存策略、敏感日志脱敏及多环境管理,助力构建稳定、可维护的 Android 网络基础设施。

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 一个 RetrofitOkHttpClient。真实项目里至少会有这些公共配置:

  • 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:添加通用 Header
  • AuthInterceptor:处理认证信息
  • TokenRefreshAuthenticator:处理 401 后刷新 Token
  • CacheControlInterceptor:根据网络状态调整缓存策略
  • RequestIdInterceptor:添加请求追踪 ID
  • LoggingInterceptor:仅在调试环境打开

公共 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 处理,但要确保切换后旧请求、缓存和登录态不会串环境。

一个推荐的落地顺序

如果现有项目网络层比较散,不建议一次性重写。更稳的推进方式是:

  1. 先统一 OkHttpClient 和 Retrofit 创建入口
  2. 把 Header、日志、缓存拆成独立拦截器
  3. 引入统一 ApiResult,让新接口先使用
  4. 把高频 Repository 的 try/catch 收口到 safeApiCall
  5. 再考虑自定义 CallAdapter,减少模板代码
  6. 补充 Token 刷新、缓存清理和日志脱敏测试

网络层改造最怕影响面不可控。按接口、模块、场景逐步迁移,比一次性全量替换更稳。

常见坑位清单

上线前可以按这份清单扫一遍:

  • Token 刷新是否限制了重试次数
  • 多个请求同时遇到 401 时是否只刷新一次
  • 协程取消是否被错误地 catch 掉
  • HTTP 错误、业务错误、网络异常是否能区分
  • 正式包是否关闭详细网络日志
  • 敏感 Header 和 Body 字段是否脱敏
  • 缓存接口是否按登录态隔离
  • 退出登录是否清理用户相关缓存
  • BaseUrl 切换后是否会串缓存或串 Token
  • Repository 是否还在大量重复 try/catch

这些问题不一定每天都出现,但一旦出现在生产环境,排查成本通常很高。

总结

Retrofit 与 OkHttp 的工程化核心不是多封装几层,而是把边界划清楚:OkHttp 处理 HTTP 能力,Retrofit 负责接口声明和调用适配,Repository 编排数据,ViewModel 只关心页面状态。

拦截器要按职责拆分,错误要统一成业务可理解的结果模型,缓存要按数据类型设计,日志要兼顾排查和隐私。这样网络层才不会随着业务增长变成一团隐形复杂度,而是成为项目里稳定、可测试、可演进的基础设施。

相关文章
|
5天前
|
缓存 人工智能 安全
GPT-5.6 Terra与GPT-5.5性能实测:成本减半后的跑分对比与快速迁移指南
GPT-5.6 Terra 的定价为每百万 token 输入 2.50/输出 15。GPT-5.5 则是 5/ 30。Terra 的每一项费率,包括 $0.25/M 的缓存读取,都恰好是 GPT-5.5 的一半,因此在任何工作负载组合下,Terra 都固定 便宜 2.0x。以每天 10 万次请求、3K token 提示词计算,大约是 Terra 每天 2,000,GPT−5.5每天 4,000,即每月约 60,000对 120,000。问题在于:OpenAI 没有发布任何针对 Terra 的编码基准。那个著名的 91.9% Terminal-Bench 数字是 Sol 在 Ul
|
4天前
|
SQL 人工智能 自然语言处理
大模型内容安全实时防护:恶意Prompt注入拦截、越权阻断与熔断机制方案.166
本文系统阐述大模型输入安全防护体系,涵盖提示词注入、恶意Prompt拦截、越权阻断与输入熔断四大核心风险及应对方案。提出四层防护架构(预处理、检测、鉴权、熔断),结合规则引擎、语义识别与RBAC权限控制,实现全链路实时防护,保障业务合规、数据安全与服务稳定。
242 1
|
12天前
|
存储 关系型数据库 MySQL
云数据库自研存储引擎:阿里云 PolarDB 相比开源 MySQL 性能提升数倍
云数据库自研存储引擎已成为云原生数据库竞争的核心技术高地。阿里云 PolarDB 通过 X-Engine 高压缩存储、PolarStore 用户态 IO + RDMA、IMCI 列存 HTAP 三大自研引擎组合,配合物理日志、跨节点 Buffer Pool 共享、Parallel Query 五大优化,实现写性能 6 倍、压缩 3-5 倍、AP 加速 100 倍、IO 延迟 -80% 的全面超越,让客户存储成本下降 65%、DBA 人力节省 80%。
453 114
|
5天前
|
人工智能 缓存 JSON
刚刚 GPT-5.6 发布,吊打 Claude 5 和 Grok 4.5?一手实测来啦!
GPT-5.6 刚发布,跑分号称超越 Fable 5,真的假的?附一手实测,让 3 个最强 AI 编程模型 GPT-5.6 Sol、Claude Fable 5、Grok 4.5 在 Cursor 里同时一把梭开发网页游戏,看看最新模型到底谁更能打。
307 0
|
5天前
|
存储 人工智能 JSON
Qwen 本地部署搭配 ComfyUI 生成 AI 漫剧完整实操指南(小白零基础可落地,零成本无限生成+角色一致性天花板)
2026全网最优本地漫剧流水线:零成本、离线运行、角色统一、低配(8G显卡)可跑。融合Qwen本地大模型+ComfyUI双引擎,实现剧本生成→分镜绘图→动态成片全自动,隐私安全、无审核限流,新手30分钟上手,日更无忧。(239字)
|
12天前
|
人工智能 编解码 物联网
2026 最新Stable Diffusion 本地部署教程 下载安装使用详细图解(含官网安装包)
Stable Diffusion(SD)是2022年发布的开源文生图模型,由Stability AI等联合开发。支持文生图、图生图、局部重绘等,依托VAE降低算力需求,可在消费级显卡运行。本文提供秋葉aaaki制作的Windows整合包(含图形界面与插件),开箱即用,零配置启动。
|
28天前
|
Linux 程序员 数据格式
【2026最新】Notepad++下载、安装和使用一篇搞定(附中文版安装包)
Notepad++ 是一款免费开源、轻量高效的 Windows 文本编辑器,支持 C/Python/HTML 等 80+ 语言语法高亮、代码折叠、正则替换、编码转换及插件扩展,专为程序员与文本处理用户打造,完美替代系统记事本。(239字)
|
1天前
|
缓存 UED 开发者
Codex109天重置23次,明天还要再送一次
Codex近109天完成23次额度重置,7月14日将迎来第24次。Tibo高频响应用户反馈:优化GPT-5.6高消耗问题、补发失效福利、调整重置时间——形成“反馈→回应→修复→补偿”正向闭环,彰显以用户为中心的产品哲学。(239字)
406 11