在网络不可用时,如何优雅地向用户展示缓存数据并提示重试?
解读
国内面试场景下,这道题考察的是“弱网/断网”这一高频痛点。面试官想确认三件事:
- 数据层:本地缓存策略是否成熟(Room、MMKV、Datastore、文件缓存、图片三级缓存等)。
- 表现层:UI 状态机是否完备(Loading/Content/Empty/Error/Offline),能否在断网时自动降级到缓存,同时给用户“可感知、可操作”的重试入口。
- 体验层:是否兼顾合规(隐私弹窗、敏感数据加密)、性能(IO 线程、内存占用)、国产 ROM 兼容(后台弹窗限制、通知通道)以及国产推送(厂商通道)带来的额外复杂度。
一句话总结:让“无网”像“有网”一样不阻断主流程,同时让“重试”像“下拉刷新”一样自然。
知识点
- 离线优先架构:Repository 层统一返回 Flow<NetworkBoundResource>,先发射 DB 缓存,再发射网络回源;网络失败时自动降级。
- 缓存选型:
- 结构化数据:Room + SQLite,支持事务、索引、FTS、自动过期(触发器或 WorkManager 周期清理)。
- 高频读写的轻量 KV:MMKV/Datastore,比 SharedPreferences 性能高且支持协程。
- 大文件/图片:Glide/Coil 自带三级缓存(内存+磁盘+网络),可自定义 DiskCache 策略。
- 网络状态感知:
- 官方:ConnectivityManager.NetworkCallback(API 24+),搭配 LiveData/StateFlow 做生命周期感知。
- 国产兼容:华为/小米/OPPO 等深度定制系统对后台网络切换广播限制,需注册前台 Service 或使用 WorkManager 的 NetworkType 约束做兜底。
- UI 状态机:
- 单 Activity + MVI 模式,ViewState 含 data/isFromCache/error/networkState 四元组;Compose 可直接用
when(state)切换 Scaffold 的 topBar/snackbar/content。 - 传统 View 体系用 ViewStub 懒加载 ErrorView,避免首次 inflate 耗时。
- 单 Activity + MVI 模式,ViewState 含 data/isFromCache/error/networkState 四元组;Compose 可直接用
- 重试入口:
- 非侵入:Snackbar + 悬浮按钮(CoordinatorLayout 嵌套 behavior),用户点击后触发
repository.refresh()。 - 侵入:页面顶部常驻“离线提示条”(类似淘宝),点击直接跳系统设置;需适配挖孔屏、折叠屏。
- 非侵入:Snackbar + 悬浮按钮(CoordinatorLayout 嵌套 behavior),用户点击后触发
- 数据合规:
- 缓存敏感数据(身份证、Token)需用 AndroidKeystore + TEE 加密后落库,合规审计(工信部 164 号文)要求“本地加密、可清除、可注销”。
- 性能与电量:
- 网络请求失败退避策略(ExponentialBackoff + WorkManager 的 setBackoffCriteria),避免频繁唤醒 Radio。
- 缓存读写在 IO Dispatcher,防止主线程卡顿;Room 支持
CoroutinesRoom.createFlow()自动切换。
- 测试:
- 单元测试:使用 Robolectric + Turbine 测试 Flow 发射顺序(Cache→Loading→Error)。
- 弱网模拟:Charles 限速 + adb shell svc wifi disable,或国内常用的“弱网工具”如 QNET、腾讯 WeTest。
答案
以 Jetpack Compose + MVI + Room 为例,给出可直接落地的“五步曲”:
- 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)
- 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)
- 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)
)
}
}
- 重试实现
fun retry() {
viewModelScope.launch {
// 清掉 DB 标记位,强制网络回源
repo.clearCacheFlag()
repo.getNewsList().collect() // 重新收集
}
}
- 网络监听补充
在 Application 中注册ConnectivityManager.NetworkCallback,将网络可用事件通过共享 StateFlow 下发,UI 层自动触发retry(),实现“网络恢复即刷新”。
国产 ROM 兼容:若 App 处于后台被杀,用 WorkManager 的NetworkType.CONNECTED约束做兜底,网络恢复后启动任务把最新数据刷到 DB,用户下次打开即可看到更新。
拓展思考
- 增量更新与缓存淘汰:
对列表类接口可引入“时间戳+分页”策略,Room 新增lastUpdate字段,WorkManager 每日凌晨清理 7 天前数据;对内容类接口可用 ETag/Last-Modified 做 304 回源,减少流量。 - 多端一致性:
车载、TV、手机三端共用同一 Repository 模块,通过 Kotlin Multiplatform 把缓存逻辑下沉到commonMain,各端仅实现平台相关网络层,降低维护成本。 - 折叠屏/大屏适配:
双窗格场景下,左窗格列表可提前缓存更多数据(Room 的 Paging 3 RemoteMediator 预取),右窗格详情页在无网时直接读缓存,避免空白;同时利用 Jetpack WindowManager 监听折叠状态,动态调整缓存容量。 - 合规与隐私沙盒:
Android 14 引入“部分存储权限”与“照片选择器”,缓存图片时需迁移到 MediaStore + SAF,避免直接读/sdcard/Android/data/<pkg>/cache被合规扫描工具判定为“违规存储”。 - AI 加速:
对图文混排内容,可预加载 TensorFlow Lite 模型到 TEE,离线时利用缓存数据做本地推荐,提升“无网”场景下的用户粘性;模型本身也作为“缓存”通过 BinaryPreferences 存储,启动时 mmap 加载,兼顾安全与速度。