从零实现一个支持本地缓存与网络同步的新闻列表:基于 Android Jetpack(MVVM + Room + Retrofit + 协程)
一、简介
本文将带你从零实现一个简单但架构规范的 Android 应用:
新闻列表 App,功能包括:
- 首次启动从网络拉取新闻列表并展示;
- 数据会缓存到本地数据库(Room);
- 下次进入应用时即使没有网络,也能看到上一次加载的内容(离线可用);
- 使用 MVVM + Repository + Room + Retrofit + Kotlin 协程实现。
重点不在 UI 炫酷程度,而在于:
- 架构是否清晰、可维护;
- 代码是否真实可用、逻辑是否正确;
- 是否符合当前主流 Android 开发实践。
二、相关概念简述
1. MVVM(模型-视图-视图模型)
- Model:数据层(网络、数据库等),如 Retrofit、Room。
- View:界面层(Activity/Fragment/自定义 View)。
- ViewModel:负责业务逻辑,持有 UI 状态,不直接引用 View,和生命周期解耦。
优点:
逻辑清晰、可测试性强,减少 Activity/Fragment 的复杂性。
2. 仓库(仓库模式)
Repository 作为唯一的数据入口,负责:
- 决定从网络还是从本地取数据;
- 负责数据合并、缓存策略(例如先本地后网络刷新)。
优点:
上层(ViewModel)不关心数据来源,降低耦合度。
3. Room(本地数据库)
- Google 官方推荐的 SQLite 封装库;
- 自动生成 SQLite 代码;
- 支持 Kotlin 协程 / Flow;
- 编译期检查 SQL 语句是否正确。
4. Retrofit(网络请求)
- 主流的 HTTP 客户端库;
- 配合协程可直接使用 函数发起网络请求;
suspend - 配合 Gson/Moshi 自动 JSON 解析。
5. Kotlin 协程 + Flow
- 协程:轻量级线程,用于异步、并发;
- Flow:可被收集的“数据流”,适合表示随时间推移变化的数据(如数据库数据变更)。
在本项目中:
- Retrofit 的网络请求使用 函数;
suspend - Room 的查询返回 ;
Flow<List<Article>> - ViewModel 收集 Flow,转为 LiveData 暴露给 UI。
6. 单一数据源(单一数据源)
核心思想:
UI 只观察一个“真相来源”(本地数据库),网络数据只是用来更新本地数据库。
本例中:
- 网络 -> Repository -> 写入 Room;
- UI -> 只观察 Room 中的数据。
好处:
数据一致、状态清晰、避免多处维护一份数据。
三、项目结构设计
假设包名为 ,推荐目录结构如下:com.example.newsapp
com.example.newsapp
├── data
│ ├── local
│ │ ├── AppDatabase.kt
│ │ └── ArticleDao.kt
│ ├── remote
│ │ ├── ApiService.kt
│ │ └── NetworkModule.kt (可选,集中管理 Retrofit)
│ ├── model
│ │ └── Article.kt
│ └── repository
│ └── ArticleRepository.kt
├── ui
│ ├── main
│ │ ├── MainActivity.kt
│ │ ├── ArticleAdapter.kt
│ │ └── ArticleViewModel.kt
│ └── common
│ └── UiState.kt
└── App.kt (Application)
四、实现步骤
步骤 1:创建项目与依赖配置
1.1 创建空项目
- 在 Android Studio 中创建 Empty Activity Kotlin 项目;
- 最低 SDK 可选 21+。
1.2 Gradle 依赖(module 级 )build.gradle
仅示意常用依赖,版本号请根据实际最新稳定版调整:
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
}
android {
namespace 'com.example.newsapp'
compileSdk 34
defaultConfig {
applicationId "com.example.newsapp"
minSdk 21
targetSdk 34
versionCode 1
versionName "1.0"
}
buildFeatures {
viewBinding true
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
// Kotlin 协程
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
// Retrofit + Gson
implementation "com.squareup.retrofit2:retrofit:2.11.0"
implementation "com.squareup.retrofit2:converter-gson:2.11.0"
implementation "com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.14"
// Room
implementation "androidx.room:room-runtime:2.6.1"
kapt "androidx.room:room-compiler:2.6.1"
implementation "androidx.room:room-ktx:2.6.1"
// Lifecycle (ViewModel + LiveData + 扩展)
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7"
implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.8.7"
// RecyclerView
implementation "androidx.recyclerview:recyclerview:1.3.2"
// Material Design
implementation "com.google.android.material:material:1.12.0"
// AppCompat
implementation "androidx.appcompat:appcompat:1.7.0"
// Core KTX
implementation "androidx.core:core-ktx:1.13.1"
}
步骤 2:数据模型定义(Article)
data/model/Article.kt
package com.example.newsapp.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "articles")
data class Article(
@PrimaryKey(autoGenerate = true)
val id: Long = 0L,
val title: String,
val description: String?,
val url: String?,
val imageUrl: String?,
val publishedAt: Long // 时间戳(毫秒)
)
说明:
- 使用 标记为 Room 表;
@Entity id自增主键;- 其他字段根据简单新闻结构设计。
步骤 3:本地数据库 Room
3.1 Dao 接口
data/local/ArticleDao.kt
package com.example.newsapp.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.example.newsapp.data.model.Article
import kotlinx.coroutines.flow.Flow
@Dao
interface ArticleDao {
// 观察所有文章(按时间倒序)
@Query("SELECT * FROM articles ORDER BY publishedAt DESC")
fun getAllArticles(): Flow<List<Article>>
// 清空表
@Query("DELETE FROM articles")
suspend fun clearAll()
// 批量插入(冲突时替换)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertArticles(articles: List<Article>)
}
3.2 数据库类
data/local/AppDatabase.kt
package com.example.newsapp.data.local
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import com.example.newsapp.data.model.Article
@Database(
entities = [Article::class],
version = 1,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
abstract fun articleDao(): ArticleDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"news_db"
).build().also { INSTANCE = it }
}
}
}
}
步骤 4:网络层 Retrofit
为保证示例完整性,这里假设有一个简单的 JSON 接口:
data/repository/ArticleRepository.kt
ui/main/ArticleViewModel.kt
- 网址:
https://example.com/api/news - 返回结构示例:
{ "articles": [ { "title": "Title 1", "description": "Desc 1", "url": "https://example.com/1", "imageUrl": "https://example.com/img1.jpg", "publishedAt": 1712400000000 } ] }实际项目中请替换为你自己的后端接口。
4.1 网络响应数据类
data/remote/NewsResponse.ktpackage com.example.newsapp.data.remote import com.example.newsapp.data.model.Article data class NewsResponse( val articles: List<Article> ) -
这里直接复用 作为网络模型和数据库模型,真实项目中通常会拆成 与 ,再做映射。
ArticleApiArticleDbArticle4.2 Retrofit 接口
data/remote/ApiService.ktpackage com.example.newsapp.data.remote import retrofit2.http.GET interface ApiService { @GET("api/news") suspend fun getNews(): NewsResponse }4.3 改造单例
data/remote/NetworkModule.ktpackage com.example.newsapp.data.remote import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory object NetworkModule { private const val BASE_URL = "https://example.com/" // TODO: 替换为真实地址 private val loggingInterceptor = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } private val okHttpClient = OkHttpClient.Builder() .addInterceptor(loggingInterceptor) .build() val apiService: ApiService by lazy { Retrofit.Builder() .baseUrl(BASE_URL) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .build() .create(ApiService::class.java) } }步骤 5:Repository(数据仓库)
负责:
- 对外暴露 Flow<List
>(来自 Room);
- 提供刷新函数,从网络取数据并写入 Room;
- 处理异常,返回结果标记刷新是否成功。
package com.example.newsapp.data.repository import com.example.newsapp.data.local.ArticleDao import com.example.newsapp.data.model.Article import com.example.newsapp.data.remote.NetworkModule import kotlinx.coroutines.flow.Flow class ArticleRepository( private val articleDao: ArticleDao ) { // 对外暴露本地数据库的 Flow(Single Source of Truth) fun getArticles(): Flow<List<Article>> = articleDao.getAllArticles() // 从网络刷新数据并存入本地 suspend fun refreshArticles(): Result<Unit> { return try { val response = NetworkModule.apiService.getNews() // 简单策略:清空再插入 articleDao.clearAll() articleDao.insertArticles(response.articles) Result.success(Unit) } catch (e: Exception) { Result.failure(e) } } }步骤 6:UI 状态封装(可选但推荐)
用于表示加载中、成功、失败等状态。
ui/common/UiState.ktpackage com.example.newsapp.ui.common sealed class UiState<out T> { object Loading : UiState<Nothing>() data class Success<T>(val data: T) : UiState<T>() data class Error(val throwable: Throwable) : UiState<Nothing>() }步骤 7:视图模型
- 暴露 ;
LiveData<UiState<List<Article>>> - 负责触发刷新;
- 在初始化时自动刷新一次。
package com.example.newsapp.ui.main import androidx.lifecycle.* import com.example.newsapp.data.model.Article import com.example.newsapp.data.repository.ArticleRepository import com.example.newsapp.ui.common.UiState import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch class ArticleViewModel( private val repository: ArticleRepository ) : ViewModel() { // 来自本地数据库的 Flow<List<Article>> private val articlesFlow = repository.getArticles() // 将 Flow 转为 LiveData,同时包上 UiState val articlesLiveData: LiveData<UiState<List<Article>>> = articlesFlow .map< List<Article>, UiState<List<Article>> > { list -> UiState.Success(list) } .asLiveData() private val _refreshState = MutableLiveData<UiState<Unit>>() val refreshState: LiveData<UiState<Unit>> = _refreshState init { refresh() } fun refresh() { viewModelScope.launch { _refreshState.value = UiState.Loading val result = repository.refreshArticles() _refreshState.value = result.fold( onSuccess = { UiState.Success(Unit) }, onFailure = { UiState.Error(it) } ) } } } /** * 简单的 ViewModelFactory,生产 ArticleViewModel */ class ArticleViewModelFactory( private val repository: ArticleRepository ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun <T : ViewModel> create(modelClass: Class<T>): T { if (modelClass.isAssignableFrom(ArticleViewModel::class.java)) { return ArticleViewModel(repository) as T } throw IllegalArgumentException("Unknown ViewModel class") } }步骤 8:RecyclerView 适配器与布局
8.1 item 布局
res/layout/item_article.xml<?xml version="1.0" encoding="utf-8"?> <androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:card_view="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_margin="8dp" card_view:cardUseCompatPadding="true"> <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical" android:padding="12dp"> <TextView android:id="@+id/tvTitle" android:layout_width="match_parent" android:layout_height="wrap_content" android:textSize="16sp" android:textStyle="bold" android:textColor="@android:color/black" /> <TextView android:id="@+id/tvDescription" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:textSize="14sp" android:textColor="@android:color/darker_gray" /> <TextView android:id="@+id/tvPublishedAt" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="4dp" android:textSize="12sp" android:textColor="@android:color/darker_gray" /> </LinearLayout> </androidx.cardview.widget.CardView>8.2 适配器
ui/main/ArticleAdapter.ktpackage com.example.newsapp.ui.main import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.example.newsapp.data.model.Article import com.example.newsapp.databinding.ItemArticleBinding import java.text.SimpleDateFormat import java.util.* class ArticleAdapter( private val onItemClick: (Article) -> Unit ) : RecyclerView.Adapter<ArticleAdapter.ArticleViewHolder>() { private val list = mutableListOf<Article>() private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) fun submitList(newList: List<Article>) { list.clear() list.addAll(newList) notifyDataSetChanged() } inner class ArticleViewHolder( private val binding: ItemArticleBinding ) : RecyclerView.ViewHolder(binding.root) { fun bind(item: Article) { binding.tvTitle.text = item.title binding.tvDescription.text = item.description ?: "" binding.tvPublishedAt.text = dateFormat.format(Date(item.publishedAt)) binding.root.setOnClickListener { onItemClick(item) } } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ArticleViewHolder { val binding = ItemArticleBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ArticleViewHolder(binding) } override fun onBindViewHolder(holder: ArticleViewHolder, position: Int) { holder.bind(list[position]) } override fun getItemCount(): Int = list.size }注意:使用了 ViewBinding,因此需要在 里启用 ,并在 命名保持一致。
build.gradleviewBinding trueitem_article.xml步骤 9:主界面布局与 活动
9.1 主布局
res/layout/activity_main.xml<?xml version="1.0" encoding="utf-8"?> <androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.swiperefreshlayout.widget.SwipeRefreshLayout android:id="@+id/swipeRefresh" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/rvArticles" android:layout_width="match_parent" android:layout_height="match_parent" android:padding="4dp" /> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout> <com.google.android.material.snackbar.Snackbar android:id="@+id/snackbar" /> </androidx.coordinatorlayout.widget.CoordinatorLayout>
说明: 实际不需要在布局里定义,可在代码中通过 动态创建。为简单起见,可以忽略上面的 元素,仅用 即可。
SnackbarSnackbar.make(...)<Snackbar>CoordinatorLayout + SwipeRefreshLayout + RecyclerView
建议改为:
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/rootLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvArticles"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="4dp" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
9.2 主要活动
ui/main/MainActivity.kt
package com.example.newsapp.ui.main
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import com.example.newsapp.data.local.AppDatabase
import com.example.newsapp.data.repository.ArticleRepository
import com.example.newsapp.databinding.ActivityMainBinding
import com.example.newsapp.ui.common.UiState
import com.google.android.material.snackbar.Snackbar
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private lateinit var articleAdapter: ArticleAdapter
private val viewModel: ArticleViewModel by viewModels {
// 简单手动注入 Repository
val db = AppDatabase.getInstance(applicationContext)
val repository = ArticleRepository(db.articleDao())
ArticleViewModelFactory(repository)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
setupRecyclerView()
setupSwipeRefresh()
observeViewModel()
}
private fun setupRecyclerView() {
articleAdapter = ArticleAdapter { article ->
article.url?.let { openUrl(it) }
}
binding.rvArticles.apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = articleAdapter
}
}
private fun setupSwipeRefresh() {
binding.swipeRefresh.setOnRefreshListener {
viewModel.refresh()
}
}
private fun observeViewModel() {
// 观察文章列表
viewModel.articlesLiveData.observe(this) { state ->
when (state) {
is UiState.Success -> {
articleAdapter.submitList(state.data)
}
is UiState.Loading -> {
// 这里通常不会有 Loading,因为 Flow 一开始就返回 Success(emptyList)
}
is UiState.Error -> {
showSnackBar("本地数据加载失败:${state.throwable.message}")
}
}
}
// 观察刷新状态
viewModel.refreshState.observe(this) { state ->
when (state) {
is UiState.Loading -> {
binding.swipeRefresh.isRefreshing = true
}
is UiState.Success -> {
binding.swipeRefresh.isRefreshing = false
}
is UiState.Error -> {
binding.swipeRefresh.isRefreshing = false
showSnackBar("刷新失败:${state.throwable.message}")
}
}
}
}
private fun showSnackBar(message: String) {
Snackbar.make(binding.rootLayout, message, Snackbar.LENGTH_LONG).show()
}
private fun openUrl(url: String) {
runCatching {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
startActivity(intent)
}
}
}
步骤 10:Application(可选)
如果你需要在应用启动时初始化某些全局资源(本例中不强制),可以:
App.kt
package com.example.newsapp
import android.app.Application
class App : Application() {
override fun onCreate() {
super.onCreate()
// 全局初始化,如日志、三方 SDK 等
}
}
并在 中配置:AndroidManifest.xml
<application
android:name=".App"
... >
...
</application>
五、方案优势分析
-
架构清晰(MVVM + Repository)
- Activity 只负责 UI 绑定和简单交互;
- 数据获取与缓存逻辑集中在 Repository;
- 容易进行单元测试和维护。
-
单一数据源(Room)
- UI 只观察数据库;
- 网络刷新仅用于更新数据库;
- 避免“UI 同时观察网络和本地”导致的一致性问题。
-
离线可用
- 首次成功加载后,就有本地缓存;
- 没有网络时仍能展示旧数据,提升用户体验。
-
现代异步方案(协程 + Flow)
- Retrofit + 协程:写法同步、执行异步;
- Room + Flow:数据变更自动推送到 UI,减少手动回调和监听。
-
易于扩展
- 可轻松增加分页(Paging 3)、错误重试、更多字段等;
- 可替换数据源(例如用别的 API 或 GraphQL)而不影响 ViewModel / UI。
六、总结与进一步扩展建议
本文完整演示了一个真实且可运行的 Android 示例项目,涵盖:
- 基本架构设计(MVVM + Repository);
- 数据层实现(Room + Retrofit);
- 异步与响应式数据流(Kotlin 协程 + Flow);
- UI 层(RecyclerView + SwipeRefreshLayout + Snackbar);
- 从“创建项目”到核心代码文件的全流程。
在此基础上,你可以继续扩展:
-
引入 Paging 3
- 实现分页加载新闻列表,优化大数据集的性能。
-
使用 Hilt 或 Koin 管理依赖
- 解除手动创建 Repository / Database 的耦合。
-
改用 Jetpack Compose 实现 UI
- 用 Compose 代替 RecyclerView + XML,更现代的声明式 UI 写法。
-
增加收藏、搜索、分类等功能
- 使用 Room 的更多查询能力,实现复杂过滤和排序。
-
增加单元测试与 UI 测试
- 为 Repository 和 ViewModel 编写单元测试,保证逻辑正确性。
更多推荐
所有评论(0)