30、项目实战:综合运用所学知识,开发一个简易的新闻阅读App(网络请求、数据缓存、列表展示、详情页)

终于到了最后一章。说实话,每次带项目实战,我都挺期待这一刻的。

前面29章,我们聊了网络、缓存、列表、页面跳转……但都是分开讲的。现在,我们要把它们串起来,做一个真正的App——一个简易的新闻阅读器。

这个项目麻雀虽小,五脏俱全。它会用到:

  • 网络请求:从API拉取新闻列表和详情
  • 数据缓存:离线也能看,省流量
  • 列表展示:RecyclerView + 下拉刷新
  • 详情页:点击跳转,展示完整内容

嗯,我们一步步来。

整体架构:先画个图

动手写代码前,我习惯先画一张架构图。你想想看,如果连数据怎么流都不知道,写出来的代码大概率是一团乱麻。

简易新闻阅读App 架构图 数据层 网络API / 本地缓存 仓库层 Repository ViewModel层 状态管理 / 业务逻辑 UI层 Activity / Fragment 数据 数据 状态 事件 单向数据流:UI -> ViewModel -> Repository -> 数据源 缓存策略:先读缓存,再请求网络,最后更新UI Room本地数据库(缓存) ← Retrofit网络请求(数据源) 列表页:下拉刷新强制走网络;详情页:优先展示缓存内容

这张图我画了好几次才满意。你看,数据从网络或缓存来,经过仓库层统一管理,再交给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