一、项目背景:为什么需要离线缓存?

做新闻类App,最怕什么?用户打开应用,转圈圈,然后一片空白。我早期做过一个资讯产品,上线第一天就被用户骂「没网就不能看」。说实话,那时候我还没意识到离线缓存的重要性。

后来我反思:新闻内容本身就有「可预知性」——用户关心的头条、分类新闻,其实可以提前存下来。WorkManager正好干这个。它能在后台定期拉数据,存到本地数据库,用户没网也能刷。

这个综合项目,我会带你从头搭一个新闻离线缓存系统。包含四个核心模块:

  • 定期缓存:WorkManager周期性任务,每30分钟拉一次新闻列表
  • 缓存清理:LRU策略,只保留最近50条,超出的自动删除
  • 网络监听:有网时自动触发同步,没网时静默等待
  • 手动同步:用户下拉刷新,立即触发一次WorkManager任务

嗯,说白了,就是让用户在任何网络环境下都能流畅看新闻。

核心思路:WorkManager + Room + NetworkCallback,三剑客搞定离线缓存。

二、整体架构设计

先画一张图,让你对整个流程有个直观印象。我个人习惯在动手写代码前,先理清楚数据流向。

新闻离线缓存架构图 用户操作 手动同步(下拉刷新) WorkManager 调度中心 PeriodicWorkRequest / OneTimeWorkRequest 定期缓存任务 每30分钟执行一次 缓存清理策略 LRU保留50条 网络状态监听 ConnectivityManager Room 本地数据库(新闻表)

你看,整个流程很清晰:用户操作触发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在任何网络环境下都能流畅运行。


公众号:蓝海资料掘金营,微信deep3321