实战项目一:新闻客户端:实现新闻列表、详情、搜索功能

终于到了实战环节。说实话,前面讲了那么多理论,我自己都手痒了。这个新闻客户端项目,我会带着你把 Retrofit、Coroutine、Flow 串起来,做一个真正能跑的东西。

项目不大,但五脏俱全。新闻列表、详情、搜索,这三个功能覆盖了日常开发中 80% 的网络请求场景。我当年刚学 Android 时,第一个练手项目就是新闻客户端,踩了不少坑。今天咱们争取少走弯路。

项目结构概览

先看看整体架构。我习惯按功能模块分包,而不是按层分包。这样后期维护起来更清晰。

com.news.app
├── data
│   ├── api          // Retrofit 接口
│   ├── model        // 数据模型
│   └── repository   // 仓库层
├── domain
│   └── usecase      // 用例(可选)
└── ui
    ├── list         // 新闻列表
    ├── detail       // 新闻详情
    └── search       // 搜索

嗯,这里要注意:domain 层在简单项目里可以省略。但如果你以后要做大型项目,建议保留。我见过太多人把所有逻辑塞进 ViewModel,最后改起来想哭。

新闻客户端架构图 UI 层 NewsListFragment NewsDetailActivity SearchFragment ViewModel + Flow Repository 层 NewsRepository SearchRepository 挂起函数 + Flow 网络层 NewsApiService Retrofit 实例 OkHttp 拦截器 数据流方向 UI 层 → ViewModel → Repository → ApiService ApiService → Repository → ViewModel → UI 层 全程使用 Flow 进行响应式数据传递 Coroutine 管理异步任务,自动处理生命周期

第一步:定义 API 接口

先写 Retrofit 接口。我个人习惯把接口定义得尽量简洁,参数多的用 @QueryMap

interface NewsApiService {

    @GET("top-headlines")
    suspend fun getTopHeadlines(
        @Query("country") country: String = "us",
        @Query("page") page: Int = 1,
        @Query("pageSize") pageSize: Int = 20
    ): NewsResponse

    @GET("everything")
    suspend fun searchNews(
        @Query("q") query: String,
        @Query("page") page: Int = 1,
        @Query("pageSize") pageSize: Int = 20
    ): NewsResponse
}

这里我用了挂起函数,而不是返回 Call<NewsResponse>。为什么?因为挂起函数配合 Coroutine 用起来更自然,不需要手动 enqueue。我以前用 Call 的时候,经常忘记切线程,导致在主线程回调里更新 UI 崩溃。

第二步:数据模型

数据模型直接用 data class。注意用 @SerializedName 处理字段名不一致的情况。

data class NewsResponse(
    val status: String,
    val totalResults: Int,
    val articles: List<Article>
)

data class Article(
    val source: Source,
    val author: String?,
    val title: String,
    val description: String?,
    val url: String,
    @SerializedName("urlToImage")
    val imageUrl: String?,
    val publishedAt: String,
    val content: String?
)

data class Source(
    val id: String?,
    val name: String
)

嗯,这里有个坑:API 返回的字段名是 urlToImage,但 Kotlin 命名习惯是驼峰。不加 @SerializedName 的话,解析出来永远是 null。我曾经因为这个排查了半天,最后发现是命名问题。

第三步:Repository 层

Repository 是连接数据源和 UI 的桥梁。我习惯在这里做数据转换和错误处理。

class NewsRepository(
    private val api: NewsApiService
) {
    fun getTopHeadlines(page: Int = 1): Flow<Result<List<Article>>> = flow {
        emit(Result.Loading)
        try {
            val response = api.getTopHeadlines(page = page)
            if (response.status == "ok") {
                emit(Result.Success(response.articles))
            } else {
                emit(Result.Error("API 返回异常: ${response.status}"))
            }
        } catch (e: Exception) {
            emit(Result.Error(e.message ?: "未知错误"))
        }
    }

    fun searchNews(query: String, page: Int = 1): Flow<Result<List<Article>>> = flow {
        emit(Result.Loading)
        try {
            val response = api.searchNews(query = query, page = page)
            if (response.status == "ok") {
                emit(Result.Success(response.articles))
            } else {
                emit(Result.Error("搜索失败: ${response.status}"))
            }
        } catch (e: Exception) {
            emit(Result.Error(e.message ?: "未知错误"))
        }
    }
}

你可能会问:为什么用 Flow<Result<T>> 而不是直接返回数据?说白了,这样 UI 层可以统一处理加载、成功、失败三种状态。我见过很多人用 sealed class 包装结果,确实好用。

Result 密封类定义:
sealed class Result<out T> {
    object Loading : Result<Nothing>()
    data class Success<T>(val data: T) : Result<T>()
    data class Error(val message: String) : Result<Nothing>()
}

第四步:ViewModel 层

ViewModel 负责管理 UI 状态。我用 StateFlow 来暴露数据,这样 Fragment 可以安全地观察。

class NewsListViewModel(
    private val repository: NewsRepository
) : ViewModel() {

    private val _newsState = MutableStateFlow<Result<List<Article>>>(Result.Loading)
    val newsState: StateFlow<Result<List<Article>>> = _newsState.asStateFlow()

    private var currentPage = 1

    fun loadNews() {
        viewModelScope.launch {
            repository.getTopHeadlines(currentPage)
                .collect { result ->
                    _newsState.value = result
                }
        }
    }

    fun loadMore() {
        currentPage++
        loadNews()
    }
}

这里有个细节:viewModelScope 会自动在 ViewModel 销毁时取消协程。你想想看,如果不用这个,还得手动管理生命周期,多麻烦。

第五步:UI 层(Fragment)

Fragment 里用 repeatOnLifecycle 来收集 Flow。这是官方推荐的做法,能避免内存泄漏。

class NewsListFragment : Fragment() {

    private val viewModel: NewsListViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        viewModel.loadNews()

        viewLifecycleOwner.lifecycleScope.launch {
            viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.newsState.collect { result ->
                    when (result) {
                        is Result.Loading -> showLoading()
                        is Result.Success -> showNews(result.data)
                        is Result.Error -> showError(result.message)
                    }
                }
            }
        }
    }
}
小技巧: 如果你用 collect 时不在 repeatOnLifecycle 里包裹,当 Fragment 进入后台时,Flow 仍然会发射数据,可能导致界面更新崩溃。这个坑我踩过,后来养成了习惯:所有 Flow 收集都放在 repeatOnLifecycle 里。

第六步:搜索功能实现

搜索功能需要处理防抖。用户输入太快时,不要频繁请求 API。我用 debounce 操作符来解决。

class SearchViewModel(
    private val repository: NewsRepository
) : ViewModel() {

    private val _query = MutableStateFlow("")
    val query: StateFlow<String> = _query.asStateFlow()

    private val _searchResult = MutableStateFlow<Result<List<Article>>>(Result.Loading)
    val searchResult: StateFlow<Result<List<Article>>> = _searchResult.asStateFlow()

    init {
        viewModelScope.launch {
            _query
                .debounce(500)  // 防抖 500ms
                .filter { it.isNotEmpty() }
                .flatMapLatest { query ->
                    repository.searchNews(query)
                }
                .collect { result ->
                    _searchResult.value = result
                }
        }
    }

    fun onQueryChanged(query: String) {
        _query.value = query
    }
}

嗯,flatMapLatest 是关键。当用户输入新内容时,它会自动取消上一个请求。你想想看,如果不用这个,用户快速输入时会产生多个并发请求,不仅浪费流量,还可能显示旧数据。

第七步:依赖注入(简单版)

为了演示方便,我用手动 DI。实际项目中建议用 Hilt 或 Koin。

object AppModule {
    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(HttpLoggingInterceptor().apply {
            level = HttpLoggingInterceptor.Level.BODY
        })
        .build()

    private val retrofit = Retrofit.Builder()
        .baseUrl("https://newsapi.org/v2/")
        .client(okHttpClient)
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    val newsApi: NewsApiService = retrofit.create(NewsApiService::class.java)
    val newsRepository = NewsRepository(newsApi)
}
注意: API Key 不要硬编码在代码里。建议用 BuildConfig 字段或者本地配置文件。我曾经不小心把 API Key 提交到 GitHub,结果被爬虫抓取,一天之内额度用光,还被封了账号。

完整数据流回顾

咱们捋一遍整个流程:

  1. 用户打开新闻列表页面
  2. Fragment 调用 ViewModel 的 loadNews()
  3. ViewModel 启动协程,调用 Repository
  4. Repository 通过 Retrofit 发起网络请求
  5. API 返回数据,Repository 包装成 Result 通过 Flow 发射
  6. ViewModel 更新 StateFlow
  7. Fragment 收集到新状态,更新 UI

整个过程都是异步的,而且生命周期安全。说白了,这套模式就是目前 Android 开发的最佳实践。我用了两年多,没出过什么大问题。

常见问题与避坑

问题 原因 解决方案
Flow 不发射数据 忘记调用 collect 或作用域不对 检查是否在 repeatOnLifecycle
网络请求在主线程执行 Retrofit 接口没有用 suspend 确保接口函数是挂起函数
搜索结果延迟 没有做防抖处理 使用 debounce 操作符
内存泄漏 Flow 没有在合适的生命周期停止 使用 viewModelScoperepeatOnLifecycle

这个项目虽然简单,但把 Retrofit、Coroutine、Flow 的核心用法都覆盖了。你做完之后,可以试着加一些功能,比如下拉刷新、分页加载、缓存策略。每加一个功能,你对这套技术栈的理解就会更深一层。

好了,动手写代码吧。遇到问题别慌,先检查日志,再检查生命周期。大部分 bug 都是这两个地方出的问题。