实战项目一:新闻客户端:实现新闻列表、详情、搜索功能
终于到了实战环节。说实话,前面讲了那么多理论,我自己都手痒了。这个新闻客户端项目,我会带着你把 Retrofit、Coroutine、Flow 串起来,做一个真正能跑的东西。
项目不大,但五脏俱全。新闻列表、详情、搜索,这三个功能覆盖了日常开发中 80% 的网络请求场景。我当年刚学 Android 时,第一个练手项目就是新闻客户端,踩了不少坑。今天咱们争取少走弯路。
项目结构概览
先看看整体架构。我习惯按功能模块分包,而不是按层分包。这样后期维护起来更清晰。
com.news.app
├── data
│ ├── api // Retrofit 接口
│ ├── model // 数据模型
│ └── repository // 仓库层
├── domain
│ └── usecase // 用例(可选)
└── ui
├── list // 新闻列表
├── detail // 新闻详情
└── search // 搜索
嗯,这里要注意:domain 层在简单项目里可以省略。但如果你以后要做大型项目,建议保留。我见过太多人把所有逻辑塞进 ViewModel,最后改起来想哭。
第一步:定义 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 包装结果,确实好用。
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)
}
完整数据流回顾
咱们捋一遍整个流程:
- 用户打开新闻列表页面
- Fragment 调用 ViewModel 的
loadNews() - ViewModel 启动协程,调用 Repository
- Repository 通过 Retrofit 发起网络请求
- API 返回数据,Repository 包装成
Result通过 Flow 发射 - ViewModel 更新
StateFlow - Fragment 收集到新状态,更新 UI
整个过程都是异步的,而且生命周期安全。说白了,这套模式就是目前 Android 开发的最佳实践。我用了两年多,没出过什么大问题。
常见问题与避坑
| 问题 | 原因 | 解决方案 |
|---|---|---|
| Flow 不发射数据 | 忘记调用 collect 或作用域不对 |
检查是否在 repeatOnLifecycle 内 |
| 网络请求在主线程执行 | Retrofit 接口没有用 suspend |
确保接口函数是挂起函数 |
| 搜索结果延迟 | 没有做防抖处理 | 使用 debounce 操作符 |
| 内存泄漏 | Flow 没有在合适的生命周期停止 | 使用 viewModelScope 和 repeatOnLifecycle |
这个项目虽然简单,但把 Retrofit、Coroutine、Flow 的核心用法都覆盖了。你做完之后,可以试着加一些功能,比如下拉刷新、分页加载、缓存策略。每加一个功能,你对这套技术栈的理解就会更深一层。
好了,动手写代码吧。遇到问题别慌,先检查日志,再检查生命周期。大部分 bug 都是这两个地方出的问题。