很多项目刚接入网络请求时,只要能把接口调通就算完成: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 一个 `Retrofit` 或 `OkHttpClient`。真实项目里至少会有这些公共配置:
可以先把构建入口收拢到一个模块里:
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,短期省事,长期很难测,也很难改。
更稳的方式是按职责拆开:
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 过期是网络层常见难点。很多人会在普通 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
}
}这里有几个关键点:
接口失败通常有几类来源:
如果每个 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 状态码还是服务端业务码。
网络错误最终要落到 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 打出来风险很高。至少要遵守三条规则:
可以对日志拦截器做一层封装:
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。建议把环境选择和网络构建解耦:
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 处理,但要确保切换后旧请求、缓存和登录态不会串环境。
如果现有项目网络层比较散,不建议一次性重写。更稳的推进方式是:
网络层改造最怕影响面不可控。按接口、模块、场景逐步迁移,比一次性全量替换更稳。
上线前可以按这份清单扫一遍:
这些问题不一定每天都出现,但一旦出现在生产环境,排查成本通常很高。
Retrofit 与 OkHttp 的工程化核心不是多封装几层,而是把边界划清楚:OkHttp 处理 HTTP 能力,Retrofit 负责接口声明和调用适配,Repository 编排数据,ViewModel 只关心页面状态。
拦截器要按职责拆分,错误要统一成业务可理解的结果模型,缓存要按数据类型设计,日志要兼顾排查和隐私。这样网络层才不会随着业务增长变成一团隐形复杂度,而是成为项目里稳定、可测试、可演进的基础设施。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。