在网络不可用时,如何优雅地向用户展示缓存数据并提示重试?

解读

国内面试场景下,这道题考察的是“弱网/断网”这一高频痛点。面试官想确认三件事:

  1. 数据层:本地缓存策略是否成熟(Room、MMKV、Datastore、文件缓存、图片三级缓存等)。
  2. 表现层:UI 状态机是否完备(Loading/Content/Empty/Error/Offline),能否在断网时自动降级到缓存,同时给用户“可感知、可操作”的重试入口。
  3. 体验层:是否兼顾合规(隐私弹窗、敏感数据加密)、性能(IO 线程、内存占用)、国产 ROM 兼容(后台弹窗限制、通知通道)以及国产推送(厂商通道)带来的额外复杂度。

一句话总结:让“无网”像“有网”一样不阻断主流程,同时让“重试”像“下拉刷新”一样自然。

知识点

  1. 离线优先架构:Repository 层统一返回 Flow<NetworkBoundResource>,先发射 DB 缓存,再发射网络回源;网络失败时自动降级。
  2. 缓存选型:
    • 结构化数据:Room + SQLite,支持事务、索引、FTS、自动过期(触发器或 WorkManager 周期清理)。
    • 高频读写的轻量 KV:MMKV/Datastore,比 SharedPreferences 性能高且支持协程。
    • 大文件/图片:Glide/Coil 自带三级缓存(内存+磁盘+网络),可自定义 DiskCache 策略。
  3. 网络状态感知:
    • 官方:ConnectivityManager.NetworkCallback(API 24+),搭配 LiveData/StateFlow 做生命周期感知。
    • 国产兼容:华为/小米/OPPO 等深度定制系统对后台网络切换广播限制,需注册前台 Service 或使用 WorkManager 的 NetworkType 约束做兜底。
  4. UI 状态机:
    • 单 Activity + MVI 模式,ViewState 含 data/isFromCache/error/networkState 四元组;Compose 可直接用 when(state) 切换 Scaffold 的 topBar/snackbar/content。
    • 传统 View 体系用 ViewStub 懒加载 ErrorView,避免首次 inflate 耗时。
  5. 重试入口:
    • 非侵入:Snackbar + 悬浮按钮(CoordinatorLayout 嵌套 behavior),用户点击后触发 repository.refresh()
    • 侵入:页面顶部常驻“离线提示条”(类似淘宝),点击直接跳系统设置;需适配挖孔屏、折叠屏。
  6. 数据合规:
    • 缓存敏感数据(身份证、Token)需用 AndroidKeystore + TEE 加密后落库,合规审计(工信部 164 号文)要求“本地加密、可清除、可注销”。
  7. 性能与电量:
    • 网络请求失败退避策略(ExponentialBackoff + WorkManager 的 setBackoffCriteria),避免频繁唤醒 Radio。
    • 缓存读写在 IO Dispatcher,防止主线程卡顿;Room 支持 CoroutinesRoom.createFlow() 自动切换。
  8. 测试:
    • 单元测试:使用 Robolectric + Turbine 测试 Flow 发射顺序(Cache→Loading→Error)。
    • 弱网模拟:Charles 限速 + adb shell svc wifi disable,或国内常用的“弱网工具”如 QNET、腾讯 WeTest。

答案

以 Jetpack Compose + MVI + Room 为例,给出可直接落地的“五步曲”:

  1. Repository 层统一收口
fun getNewsList(): Flow<NetworkBoundResource<List<News>>> = flow {
    // 1. 先发射本地缓存
    val cache = newsDao.getAll()
    if (cache.isNotEmpty()) emit(Success(cache, isFromCache = true))

    // 2. 尝试网络回源
    try {
        val net = api.getNews()
        newsDao.insert(net)
        emit(Success(newsDao.getAll(), isFromCache = false))
    } catch (e: IOException) {
        // 3. 网络异常,若缓存为空则发射错误,否则保持缓存
        if (cache.isNullOrEmpty()) emit(Error(e))
        // 4. 同时记录重试事件
        retryBus.emit(Unit)
    }
}.flowOn(Dispatchers.IO)
  1. ViewModel 持有状态
val viewState = repo.getNewsList()
    .map { res ->
        when (res) {
            is Success -> ViewState.Content(res.data, res.isFromCache)
            is Error -> ViewState.Error(res.exception, isFromCache = false)
        }
    }
    .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ViewState.Loading)
  1. Compose UI 状态切换
val state by vm.viewState.collectAsStateWithLifecycle()
Box {
    when (val s = state) {
        is Content -> NewsList(s.data)
        is Error -> ErrorTip(s.exception, onRetry = { vm.retry() })
    }
    // 离线提示条
    if (state.isFromCache) {
        OfflineBar(
            onClick = { vm.retry() },
            modifier = Modifier.align(Alignment.TopCenter)
        )
    }
}
  1. 重试实现
fun retry() {
    viewModelScope.launch {
        // 清掉 DB 标记位,强制网络回源
        repo.clearCacheFlag()
        repo.getNewsList().collect() // 重新收集
    }
}
  1. 网络监听补充
    在 Application 中注册 ConnectivityManager.NetworkCallback,将网络可用事件通过共享 StateFlow 下发,UI 层自动触发 retry(),实现“网络恢复即刷新”。
    国产 ROM 兼容:若 App 处于后台被杀,用 WorkManager 的 NetworkType.CONNECTED 约束做兜底,网络恢复后启动任务把最新数据刷到 DB,用户下次打开即可看到更新。

拓展思考

  1. 增量更新与缓存淘汰:
    对列表类接口可引入“时间戳+分页”策略,Room 新增 lastUpdate 字段,WorkManager 每日凌晨清理 7 天前数据;对内容类接口可用 ETag/Last-Modified 做 304 回源,减少流量。
  2. 多端一致性:
    车载、TV、手机三端共用同一 Repository 模块,通过 Kotlin Multiplatform 把缓存逻辑下沉到 commonMain,各端仅实现平台相关网络层,降低维护成本。
  3. 折叠屏/大屏适配:
    双窗格场景下,左窗格列表可提前缓存更多数据(Room 的 Paging 3 RemoteMediator 预取),右窗格详情页在无网时直接读缓存,避免空白;同时利用 Jetpack WindowManager 监听折叠状态,动态调整缓存容量。
  4. 合规与隐私沙盒:
    Android 14 引入“部分存储权限”与“照片选择器”,缓存图片时需迁移到 MediaStore + SAF,避免直接读 /sdcard/Android/data/<pkg>/cache 被合规扫描工具判定为“违规存储”。
  5. AI 加速:
    对图文混排内容,可预加载 TensorFlow Lite 模型到 TEE,离线时利用缓存数据做本地推荐,提升“无网”场景下的用户粘性;模型本身也作为“缓存”通过 BinaryPreferences 存储,启动时 mmap 加载,兼顾安全与速度。