简体中文 | English
克隆 · 运行 · 亲眼看到网络层怎么干活。
开飞行模式、故意过期 Token、并发打出一堆
401—— 看一套生产可用的 OkHttp 栈该怎么表现。
大多数 Retrofit 示例停在「请求成功、JSON 能解析」。这个项目专攻两个真实项目里仍在踩坑的问题:
| 痛点 | 本 Cookbook 演示什么 |
|---|---|
| 弱网 / 断网 | Wi-Fi 始终校验;蜂窝缓存 60 秒;离线可读四周内的旧数据 |
| Access Token 过期 | 只刷新一次、只重试一次;不递归;并发 401 不炸 |
Compose Demo 对接进程内 MockWebServer:无需账号、云端 API、密钥,甚至不需要公网。
- 运行 App → 选 CELLULAR 拉取一次 → 来源显示
NETWORK - 切到 OFFLINE 再拉 → 来源显示
CACHE(本地 Server 时间线不再增加请求) - 点 Expire access token → 在线再拉 → 时间线里看到
401 → refresh → retry
响应来源、缓存策略、Token 状态、本地服务命中全部可见 —— 网络层不再是黑盒。
环境: Android Studio · JDK 17 · Android SDK 36
git clone https://github.com/cheng2016/Retrofit2RxJava-Android-Simples.git
cd Retrofit2RxJava-Android-Simples
./gradlew assembleDebug本地质量检查:
./gradlew lintDebug testDebugUnitTest assembleDebugflowchart LR
ComposeUI[Compose界面] --> ViewModel[NetworkDemoViewModel]
ViewModel --> Repository[ProfileRepository]
Repository --> Retrofit[RetrofitApi]
Retrofit --> OkHttp[OkHttpClient]
OkHttp --> Cache[NetworkAwareCacheInterceptor]
OkHttp --> Auth[TokenAuthenticator]
Auth --> Store[DataStoreTokenStore]
OkHttp --> Mock[LocalMockWebServer]
技术栈: Kotlin · Jetpack Compose · MVVM · Coroutines / StateFlow · Hilt · Retrofit · OkHttp · DataStore · MockWebServer
完整源码见:
NetworkAwareCacheInterceptor.kt ·
TokenAuthenticator.kt ·
NetworkModule.kt ·
DemoRepository.kt
按网络状态改写 Cache-Control。生产里把 NetworkStatusProvider 换成 ConnectivityManager 即可。
| 模式 | 行为 |
|---|---|
WIFI |
public, max-age=0 — 每次重新验证 |
CELLULAR |
public, max-age=60 — 一分钟软缓存 |
OFFLINE |
FORCE_CACHE + max-stale=2419200(四周) |
| 离线无缓存 | OkHttp 504 → 明确的 UI 错误 |
override fun intercept(chain: Interceptor.Chain): Response {
val mode = networkStatusProvider.current()
var request = chain.request()
// 离线:强制只读缓存;没有缓存时 OkHttp 返回 504
if (mode == NetworkMode.OFFLINE) {
request = request.newBuilder()
.cacheControl(CacheControl.FORCE_CACHE)
.build()
}
val originalResponse = chain.proceed(request)
val cacheControl = when (mode) {
NetworkMode.WIFI -> "public, max-age=0"
NetworkMode.CELLULAR -> "public, max-age=60"
NetworkMode.OFFLINE -> "public, only-if-cached, max-stale=2419200"
}
return originalResponse.newBuilder()
.removeHeader("Pragma")
.removeHeader("Cache-Control")
.header("Cache-Control", cacheControl)
.build()
}接入 OkHttp 时,应用拦截器 + 网络拦截器都要挂上,这样写入磁盘的缓存头才是改写后的策略:
OkHttpClient.Builder()
.cache(Cache(cacheDir, 10L * 1024 * 1024))
.addInterceptor(cacheInterceptor) // 离线 FORCE_CACHE
.addInterceptor(authorizationInterceptor) // Bearer token
.addNetworkInterceptor(cacheInterceptor) // 把 Cache-Control 写入缓存
.authenticator(tokenAuthenticator)
.build()TokenAuthenticator 使用不带 Authenticator 的独立 refresh Client(避免递归):
- 无 Refresh Token / 刷新失败 → 返回
null并停止 - 原请求最多自动重试 一次
- 并发
401single-flight,同一时刻只刷新一次 - 若其他请求已刷新 → 直接用新 Token 重试
override fun authenticate(route: Route?, response: Response): Request? {
// 原请求 + 一次重试后仍 401 → 放弃,防止死循环
if (responseCount(response) >= 2) return null
val requestToken = extractBearerToken(
response.request.header("Authorization"),
)
synchronized(refreshLock) {
val currentAccess = tokenStore.getAccessToken()
// 别的请求已经刷新过:直接换新 token 重试,不再打 refresh
if (!currentAccess.isNullOrBlank() &&
requestToken != null &&
currentAccess != requestToken
) {
return response.request.newBuilder()
.header("Authorization", "Bearer $currentAccess")
.build()
}
val refreshToken = tokenStore.getRefreshToken()
if (refreshToken.isNullOrBlank()) return null
// 必须走独立 Client(无 Authenticator),同步刷新
val refreshResponse = tokenRefreshApi
.refresh(RefreshTokenRequest(refreshToken))
.execute()
val body = refreshResponse.body()
if (!refreshResponse.isSuccessful || body == null) return null
tokenStore.saveTokens(body.accessToken, body.refreshToken)
return response.request.newBuilder()
.header("Authorization", "Bearer ${body.accessToken}")
.build()
}
}请求侧注入 Access Token:
class AuthorizationInterceptor(
private val tokenStore: TokenStore,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val accessToken = tokenStore.getAccessToken()
if (accessToken.isNullOrBlank()) return chain.proceed(chain.request())
val authenticated = chain.request().newBuilder()
.header("Authorization", "Bearer $accessToken")
.build()
return chain.proceed(authenticated)
}
}Debug 的 BODY 日志会脱敏 Authorization;Release 关闭日志。
UI 不该只知道「成功/失败」。DemoRepository 把缓存命中、Token 是否刷新一并暴露出来:
suspend fun getProfile(): DemoOutcome<ProfileNetworkResult> {
val accessBefore = tokenStore.getAccessToken()
val response = demoApi.getProfile()
val raw = response.raw()
val tokenRefreshed = accessBefore != tokenStore.getAccessToken()
if (response.code() == 504) {
return DemoOutcome.Failure(DemoError.OfflineNoCache())
}
val source = when {
raw.networkResponse != null -> ResponseSource.NETWORK
raw.cacheResponse != null -> ResponseSource.CACHE
else -> ResponseSource.NETWORK
}
return DemoOutcome.Success(
ProfileNetworkResult(
body = response.body()!!,
source = source,
cacheMetadata = CacheMetadata(
cacheResponsePresent = raw.cacheResponse != null,
networkResponsePresent = raw.networkResponse != null,
sentRequestAtMillis = raw.sentRequestAtMillis,
receivedResponseAtMillis = raw.receivedResponseAtMillis,
cacheControl = raw.header("Cache-Control"),
),
tokenRefreshed = tokenRefreshed,
),
)
}ViewModel 持有 StateFlow<DemoUiState>,Composable 只渲染;Demo 里用按钮切换网络模式 / 过期 Token,即可在界面上验证上述行为。
app/src/main/java/com/cheng/networkcookbook/
├── data/local/ # DataStore Token 持久化
├── data/network/ # Retrofit / OkHttp / 缓存 / 鉴权
├── data/repository/ # 领域结果
├── mock/ # 确定性本地 API
└── ui/ # Compose + MVVM Demo
这是聚焦的 Cookbook,不是可原样塞进生产的 SDK。接入真实项目时请替换 Mock Server、DTO、网络状态来源 —— 但请保留重试上限与独立刷新 Client。
为什么可以放心抄:
- 缓存与 Authenticator 的 MockWebServer 单测
- Repository 集成路径(在线预热 → 离线命中;401 → 刷新)
- ViewModel + Compose 冒烟测试
- GitHub Actions:lint、单测、
assembleDebug - Apache-2.0 · 无生产账号硬编码
仓库始于 2016 年的 Java / RxJava 1 / EventBus 示例。v2 用 Kotlin + Compose 彻底重写:保留「网络感知缓存 + Token 刷新」的核心价值,去掉过时依赖与失效的 Azure API。
欢迎贡献,见 CONTRIBUTING.md。采用 Apache-2.0 许可证。

