一、项目背景:为什么需要离线缓存?
做新闻类App,最怕什么?用户打开应用,转圈圈,然后一片空白。我早期做过一个资讯产品,上线第一天就被用户骂「没网就不能看」。说实话,那时候我还没意识到离线缓存的重要性。
后来我反思:新闻内容本身就有「可预知性」——用户关心的头条、分类新闻,其实可以提前存下来。WorkManager正好干这个。它能在后台定期拉数据,存到本地数据库,用户没网也能刷。
这个综合项目,我会带你从头搭一个新闻离线缓存系统。包含四个核心模块:
- 定期缓存:WorkManager周期性任务,每30分钟拉一次新闻列表
- 缓存清理:LRU策略,只保留最近50条,超出的自动删除
- 网络监听:有网时自动触发同步,没网时静默等待
- 手动同步:用户下拉刷新,立即触发一次WorkManager任务
嗯,说白了,就是让用户在任何网络环境下都能流畅看新闻。
核心思路:WorkManager + Room + NetworkCallback,三剑客搞定离线缓存。
二、整体架构设计
先画一张图,让你对整个流程有个直观印象。我个人习惯在动手写代码前,先理清楚数据流向。
你看,整个流程很清晰:用户操作触发WorkManager,WorkManager调度三个子任务,最终数据落地到Room数据库。每个模块各司其职,互不干扰。
三、数据库设计:Room实体与DAO
先建表。新闻数据需要存什么?标题、摘要、发布时间、来源、内容正文。我习惯再加一个 cachedAt 字段,记录缓存时间,方便后续清理。
@Entity(tableName = "news")
data class NewsEntity(
@PrimaryKey val id: String,
val title: String,
val summary: String,
val content: String,
val source: String,
val publishedAt: Long,
val cachedAt: Long = System.currentTimeMillis()
)
DAO层,我设计了三个核心方法:
@Dao
interface NewsDao {
@Query("SELECT * FROM news ORDER BY publishedAt DESC LIMIT :limit")
suspend fun getRecentNews(limit: Int = 50): List<NewsEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(news: List<NewsEntity>)
@Query("DELETE FROM news WHERE id NOT IN (SELECT id FROM news ORDER BY cachedAt DESC LIMIT :keepCount)")
suspend fun cleanOldCache(keepCount: Int = 50)
}
这里有个细节:cleanOldCache 方法用的是子查询,保留最近缓存的N条记录,删除其余。比逐条判断效率高很多。我曾经在项目里用循环删除,数据量一大就卡UI,后来改成这种SQL批量删除,性能提升明显。
小提示:OnConflictStrategy.REPLACE 很适合新闻场景——同一条新闻ID重复拉取时,直接覆盖旧数据,保证内容最新。
四、WorkManager任务:定期缓存
核心任务来了。创建一个 NewsCacheWorker,负责拉取远程新闻并存入数据库。
class NewsCacheWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
return try {
// 1. 检查网络(可选,WorkManager自带约束)
// 2. 拉取远程新闻
val news = newsApi.fetchTopHeadlines()
// 3. 存入数据库
newsDao.insertAll(news.map { it.toEntity() })
// 4. 清理旧缓存
newsDao.cleanOldCache(50)
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
}
然后注册周期性任务:
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = PeriodicWorkRequestBuilder<NewsCacheWorker>(
30, TimeUnit.MINUTES
).setConstraints(constraints)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
"news_cache",
ExistingPeriodicWorkPolicy.KEEP,
request
)
为什么用 enqueueUniquePeriodicWork?因为要保证只有一个缓存任务在运行。如果用户反复触发,不会重复创建多个任务。我见过有人用 enqueue 导致任务堆积,最后手机发烫——嗯,这个坑我踩过。
注意:PeriodicWorkRequest 的最小间隔是15分钟。你设10分钟也没用,系统会强制拉长。这是Android系统为了省电做的限制。
五、缓存清理策略:LRU + 时间双重保障
缓存不能无限膨胀。我采用「数量上限 + 时间过期」双重策略:
- 数量上限:最多保留50条新闻。超过时删除最旧的。
- 时间过期:缓存超过24小时的新闻,即使没到50条也清理。
在 cleanOldCache 方法里,我加了一个时间参数:
@Query("""
DELETE FROM news
WHERE id NOT IN (
SELECT id FROM news
ORDER BY cachedAt DESC
LIMIT :keepCount
)
OR cachedAt < :expireTime
""")
suspend fun cleanOldCache(keepCount: Int = 50, expireTime: Long = System.currentTimeMillis() - 24 * 60 * 60 * 1000)
这样既控制了存储大小,又保证了新闻的时效性。用户看到的永远是最近一天内的热门新闻。
六、网络状态监听:智能触发同步
WorkManager虽然自带网络约束,但我想让体验更好——用户从无网切换到有网时,立即触发一次同步,不用等30分钟。
实现方式是用 ConnectivityManager.NetworkCallback:
class NetworkChangeReceiver : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
// 网络恢复,触发一次立即同步
val request = OneTimeWorkRequestBuilder<NewsCacheWorker>()
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
"immediate_sync",
ExistingWorkPolicy.REPLACE,
request
)
}
}
注册时机我建议在Application的 onCreate 里:
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
connectivityManager.registerNetworkCallback(
NetworkRequest.Builder().build(),
networkChangeReceiver
)
这里有个坑:Android 10以上,registerNetworkCallback 在后台可能被延迟。如果你需要高实时性,可以考虑用 WorkManager.addOnNetworkStateChangedListener,但那个API比较新。我个人还是倾向于传统Callback,兼容性更好。
七、用户触发立即同步
下拉刷新时,用户希望立刻看到最新新闻。这时候用 OneTimeWorkRequest 触发一次立即同步:
fun triggerImmediateSync() {
val request = OneTimeWorkRequestBuilder<NewsCacheWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(
"immediate_sync",
ExistingWorkPolicy.REPLACE,
request
)
}
注意这里用了 ExistingWorkPolicy.REPLACE。如果用户连续下拉多次,只保留最后一次的同步任务。避免重复拉取浪费流量。
同步完成后,怎么通知UI更新?我一般用 WorkManager.getWorkInfoByIdLiveData() 观察任务状态:
WorkManager.getInstance(context)
.getWorkInfoByIdLiveData(request.id)
.observe(viewLifecycleOwner) { workInfo ->
if (workInfo.state == WorkInfo.State.SUCCEEDED) {
// 刷新UI
viewModel.refreshNews()
}
}
这样UI层和后台任务完全解耦。WorkManager负责干活,LiveData负责通知,各司其职。
八、总结一下
这个综合项目,其实就做了四件事:
| 模块 | 技术实现 | 关键点 |
|---|---|---|
| 定期缓存 | PeriodicWorkRequest | 30分钟间隔,网络约束 |
| 缓存清理 | Room SQL + LRU | 数量上限50,过期24h |
| 网络监听 | ConnectivityManager.Callback | 网络恢复自动触发同步 |
| 手动同步 | OneTimeWorkRequest | 下拉刷新,REPLACE策略 |
说实话,这套方案我用了好几年,从最初的JobScheduler到Firebase JobDispatcher,最后全部迁移到WorkManager。WorkManager最大的好处是——你不用操心Android版本差异,它帮你统一了API。
你想想看,用户在地铁里打开新闻App,即使没信号,也能看到半小时前缓存的头条。这种体验,才是用户想要的。
一句话总结:WorkManager + Room + 网络监听,让新闻App在任何网络环境下都能流畅运行。