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 只关心页面状态。

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

相关文章
|
1天前
|
缓存 API 调度
[鸿蒙从零到一] 页面路由与 Navigation 实战
本文详解HarmonyOS页面路由实战,对比`router`轻量跳转与`Navigation`栈式导航适用场景;涵盖登录拦截、参数传递、返回结果处理、底部Tab独立栈、横竖屏恢复等核心问题;倡导路由集中管理、参数最小化、页面解耦与工程化目录结构,助开发者构建稳定可扩展的鸿蒙应用骨架。
34 0
|
1天前
|
运维 安全 API
电商自动化提质增效:API接口落地的核心价值与实战用法
在电商精细化运营趋势下,中小商家常困于订单、库存、物流等高频重复操作,易出错、效率低、风险高。合规电商API接口可实现全流程自动化、多平台数据实时互通、标准化风控与轻量化落地,显著降本增效,提升履约力与店铺健康度。
27 0
|
1天前
|
传感器 人工智能 自然语言处理
GEO优化:内容源被引用,你要的内容却没被引用?
这篇文章想把这层机制彻底讲透——为什么被引用的是"你",却不是"你要的内容"
36 2
|
1天前
|
弹性计算 Java 开发工具
阿里云国际站:OSS SDK上传文件报错?
开发者集成阿里云OSS SDK时,真正的难点往往不在业务逻辑,而在上传那一刻跳出的红色报错。InvalidAccessKeyId、SignatureDoesNotMatch、ConnectionTimeout……这些报错看似碎片化,实则每一次都指向Endpoint、AccessKey与配置项的耦合问题。阿里云OSS SDK上传文件报错排查,本质上是对三者关系的逐层还原。下面从最常见的报错类型切入,梳理几条被反复验证过的定位路径。
|
1天前
|
人工智能 运维 自然语言处理
最新版通义千问(Qwen3.7-Max)功能介绍
通义千问Qwen3.7-Max(2026上线)是阿里云新一代旗舰大模型,支持百万级上下文、原生全模态、自主智能体、顶尖代码能力、全链路办公与阿里生态办事六大升级,实现从问答工具到通用AI代理的跃迁,个人端永久免费。
|
1天前
|
人工智能 BI API
2026 最新版通义千问付费版全功能介绍
通义千问付费体系含Pro会员、API按量付费、企业团队版三大模块,基于Qwen3.7旗舰模型,全面升级算力、文件处理、多模态与商用权限。覆盖个人创作、开发者集成及政企数字化场景,实现从日常辅助到AI生产底座的全链路赋能。(239字)
|
1天前
|
文字识别 Java API
【AgentScope Java新手村系列】(19)多模态-图像音频视频
多模态 — 废弃 ImageBlock/AudioBlock/VideoBlock,统一用 DataBlock + Base64Source 内联或 URLSource 远程引用图片。一条消息可拼多个 Block,REST 端点仅稳定支持图片。
39 0
|
8月前
|
弹性计算 安全 网络协议
阿里云云服务器ECS:安全、稳定、购买灵活、低成本
阿里云ECS是安全稳定、弹性灵活的明星级云服务器,支持多种实例规格与付费模式,可快速创建并部署。本文详解ECS介绍、购买流程(含地域、网络、实例、镜像、存储、安全组等设置)及使用教程,助您轻松上手云端应用搭建。
509 10
|
4月前
|
IDE Linux 开发工具
Qt Creator 19 发布 - Qt、QML 与 C++ 的 跨平台 IDE
Qt Creator 19.0.0 (macOS, Linux, Windows) - Qt、QML 与 C++ 的 跨平台 IDE
530 1
Qt Creator 19 发布 - Qt、QML 与 C++ 的 跨平台 IDE