30、项目实战:综合运用所学知识,开发一个简易的新闻阅读App(网络请求、数据缓存、列表展示、详情页)
终于到了最后一章。说实话,每次带项目实战,我都挺期待这一刻的。
前面29章,我们聊了网络、缓存、列表、页面跳转……但都是分开讲的。现在,我们要把它们串起来,做一个真正的App——一个简易的新闻阅读器。
这个项目麻雀虽小,五脏俱全。它会用到:
- 网络请求:从API拉取新闻列表和详情
- 数据缓存:离线也能看,省流量
- 列表展示:RecyclerView + 下拉刷新
- 详情页:点击跳转,展示完整内容
嗯,我们一步步来。
整体架构:先画个图
动手写代码前,我习惯先画一张架构图。你想想看,如果连数据怎么流都不知道,写出来的代码大概率是一团乱麻。
这张图我画了好几次才满意。你看,数据从网络或缓存来,经过仓库层统一管理,再交给ViewModel,最后UI层只管展示。反过来,用户的操作(比如下拉刷新)通过事件往上传递。这就是典型的单向数据流。
第一步:搭建项目结构
我个人习惯按功能分包,而不是按类型。什么意思?就是不要搞一堆activities、fragments、adapters这样的包,而是按模块分:
com.example.newsapp/
├── data/
│ ├── api/ # Retrofit接口
│ ├── local/ # Room数据库
│ └── repository/ # 仓库实现
├── ui/
│ ├── list/ # 新闻列表页
│ └── detail/ # 新闻详情页
└── model/ # 数据模型
这样做的好处是,每个功能模块自包含。你改列表页的时候,不会不小心动到详情页的代码。我在项目中吃过这个亏——一个巨型包下几百个文件,找东西找到崩溃。
第二步:定义数据模型
新闻数据长什么样?我们看看API返回的JSON:
{
"code": 200,
"data": {
"newsList": [
{
"id": "1001",
"title": "Android 15 正式发布",
"summary": "新版本带来了更好的性能...",
"content": "完整内容...",
"publishTime": "2025-03-20 10:00",
"imageUrl": "https://example.com/image.jpg"
}
]
}
}
对应的Kotlin数据类:
// 网络响应
data class NewsResponse(
val code: Int,
val data: NewsData
)
data class NewsData(
val newsList: List<NewsItem>
)
// 新闻条目
@Entity(tableName = "news")
data class NewsItem(
@PrimaryKey val id: String,
val title: String,
val summary: String,
val content: String,
val publishTime: String,
val imageUrl: String
)
注意,我把@Entity直接加在了数据类上。这样同一个类既能解析网络数据,又能存到Room数据库。省事,但有个坑——后面会讲。
第三步:网络请求与缓存
Retrofit接口定义:
interface NewsApi {
@GET("news/list")
suspend fun getNewsList(): NewsResponse
@GET("news/detail")
suspend fun getNewsDetail(@Query("id") newsId: String): NewsResponse
}
Room的DAO:
@Dao
interface NewsDao {
@Query("SELECT * FROM news ORDER BY publishTime DESC")
suspend fun getAllNews(): List<NewsItem>
@Query("SELECT * FROM news WHERE id = :newsId")
suspend fun getNewsById(newsId: String): NewsItem?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(newsList: List<NewsItem>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(newsItem: NewsItem)
}
仓库层的核心逻辑:
class NewsRepository(
private val api: NewsApi,
private val dao: NewsDao
) {
// 获取新闻列表:先读缓存,再请求网络
suspend fun getNewsList(refresh: Boolean = false): List<NewsItem> {
if (!refresh) {
val cached = dao.getAllNews()
if (cached.isNotEmpty()) {
return cached // 有缓存就用缓存
}
}
// 网络请求
val response = api.getNewsList()
if (response.code == 200) {
val newsList = response.data.newsList
dao.insertAll(newsList) // 写入缓存
return newsList
}
throw Exception("网络请求失败")
}
// 获取新闻详情:同上
suspend fun getNewsDetail(newsId: String, refresh: Boolean = false): NewsItem {
if (!refresh) {
val cached = dao.getNewsById(newsId)
if (cached != null) {
return cached
}
}
val response = api.getNewsDetail(newsId)
if (response.code == 200) {
val detail = response.data.newsList.first()
dao.insert(detail)
return detail
}
throw Exception("获取详情失败")
}
}
核心思想:先读缓存,没有缓存或强制刷新时才请求网络。网络数据回来后再更新缓存。这样用户即使离线,也能看到之前加载过的内容。
第四步:ViewModel 与 UI 层
ViewModel负责管理UI状态。我用的是StateFlow:
class NewsViewModel(private val repository: NewsRepository) : ViewModel() {
private val _newsList = MutableStateFlow<List<NewsItem>>(emptyList())
val newsList: StateFlow<List<NewsItem>> = _newsList.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
fun loadNews(refresh: Boolean = false) {
viewModelScope.launch {
_isLoading.value = true
try {
val list = repository.getNewsList(refresh)
_newsList.value = list
} catch (e: Exception) {
// 可以在这里处理错误,比如显示Toast
} finally {
_isLoading.value = false
}
}
}
}
列表页的Fragment里,观察这个StateFlow:
viewModel.newsList.observe(viewLifecycleOwner) { list ->
adapter.submitList(list)
}
viewModel.isLoading.observe(viewLifecycleOwner) { loading ->
swipeRefreshLayout.isRefreshing = loading
}
下拉刷新时,调用viewModel.loadNews(refresh = true)。嗯,就这么简单。
第五步:列表展示与详情页跳转
列表用RecyclerView,这个我们前面讲过。关键点是点击事件:
// 在Adapter中
holder.itemView.setOnClickListener {
val news = getItem(adapterPosition)
val intent = Intent(context, DetailActivity::class.java).apply {
putExtra("news_id", news.id)
}
context.startActivity(intent)
}
详情页接收ID,然后通过ViewModel加载数据:
class DetailViewModel(private val repository: NewsRepository) : ViewModel() {
private val _detail = MutableStateFlow<NewsItem?>(null)
val detail: StateFlow<NewsItem?> = _detail.asStateFlow()
fun loadDetail(newsId: String) {
viewModelScope.launch {
val item = repository.getNewsDetail(newsId)
_detail.value = item
}
}
}
详情页的布局很简单:一个标题TextView,一个内容TextView,再加一个发布时间。内容可能很长,记得用ScrollView包裹。
避坑指南:我踩过的几个坑
坑1:Room实体类与网络数据类混用
我曾经直接把网络返回的类加上@Entity,结果数据库字段和JSON字段对不上。比如后端返回image_url,Room默认映射到imageUrl,但如果你用了@SerializedName,Room不认。解决办法:要么用@ColumnInfo(name = "image_url"),要么把网络模型和数据库模型分开。
坑2:缓存过期策略
我一开始没做缓存过期,结果用户看到的永远是旧新闻。后来加了一个时间戳字段,每次读缓存时判断是否超过30分钟。如果超时,就强制走网络。
小技巧:在Repository里加一个cacheExpireTime参数,方便调试时改成1分钟,测试缓存刷新逻辑。
总结一下
这个项目实战,说白了就是把我们前面学的所有东西串起来。你想想看:
- 网络请求 → Retrofit
- 数据缓存 → Room
- 列表展示 → RecyclerView
- 页面跳转 → Intent + ViewModel
每一个技术点单独拿出来都不难,但组合在一起,就需要你理清数据流向。我建议你动手写一遍,哪怕照着上面的代码敲一遍也行。写完之后,试着加一个功能:比如新闻分类(科技、体育、娱乐),看看你能不能自己搞定。
嗯,30章的内容,到这里就结束了。感谢你一路跟下来。如果你在实战中遇到问题,欢迎来找我交流。
最后送你一句话:代码是写给人看的,顺便能在机器上运行。保持代码整洁,比什么都重要。
公众号:蓝海资料掘金营,微信deep3321