Skip to content

Latest commit

 

History

History
270 lines (207 loc) · 9.72 KB

File metadata and controls

270 lines (207 loc) · 9.72 KB

Android 离线感知网络层 Cookbook

Android CI License Kotlin Compose

简体中文 | English

登录与网络请求演示 Token / Profile 结果演示

克隆 · 运行 · 亲眼看到网络层怎么干活。

开飞行模式、故意过期 Token、并发打出一堆 401 —— 看一套生产可用的 OkHttp 栈该怎么表现。

大多数 Retrofit 示例停在「请求成功、JSON 能解析」。这个项目专攻两个真实项目里仍在踩坑的问题:

痛点 本 Cookbook 演示什么
弱网 / 断网 Wi-Fi 始终校验;蜂窝缓存 60 秒;离线可读四周内的旧数据
Access Token 过期 只刷新一次、只重试一次;不递归;并发 401 不炸

Compose Demo 对接进程内 MockWebServer:无需账号、云端 API、密钥,甚至不需要公网。


30 秒上手演示

  1. 运行 App → 选 CELLULAR 拉取一次 → 来源显示 NETWORK
  2. 切到 OFFLINE 再拉 → 来源显示 CACHE(本地 Server 时间线不再增加请求)
  3. 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 assembleDebug

架构

flowchart 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]
Loading

技术栈: 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()

配方二:安全刷新 Token

TokenAuthenticator 使用不带 Authenticator 的独立 refresh Client(避免递归):

  • 无 Refresh Token / 刷新失败 → 返回 null 并停止
  • 原请求最多自动重试 一次
  • 并发 401 single-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 许可证。