案例3:新闻阅读App完整实现¶
📋 案例概述¶
项目简介¶
构建一个功能完整的新闻阅读应用,展示新闻列表、详情、分类浏览、搜索、收藏等功能。本案例重点展示分页加载、WebView集成、离线阅读等高级特性。
预计开发时间: 6-8小时 难度等级: ⭐⭐⭐⭐ 涉及知识点: Paging3、WebView、Room数据库、搜索功能、分享功能
功能特性¶
- 📰 新闻分类浏览(科技、体育、财经、娱乐等)
- 🔍 实时搜索与搜索历史
- 📄 新闻详情WebView展示
- ❤️ 新闻收藏与离线阅读
- 📤 分享功能集成
- 🌙 深色模式适配
- 📶 离线模式支持
🏗️ 项目架构¶
Text Only
app/
├── data/
│ ├── local/
│ │ ├── NewsDatabase.kt
│ │ ├── NewsDao.kt
│ │ ├── SearchHistoryDao.kt
│ │ └── entity/
│ │ ├── NewsEntity.kt
│ │ └── SearchHistoryEntity.kt
│ ├── remote/
│ │ ├── NewsApiService.kt
│ │ ├── NewsPagingSource.kt
│ │ └── dto/
│ │ ├── NewsResponse.kt
│ │ └── ArticleDto.kt
│ ├── repository/
│ │ └── NewsRepository.kt
│ └── model/
│ └── News.kt
├── ui/
│ ├── home/
│ │ ├── HomeScreen.kt
│ │ ├── HomeViewModel.kt
│ │ └── components/
│ │ ├── NewsItem.kt
│ │ ├── CategoryTabs.kt
│ │ └── BreakingNewsSection.kt
│ ├── search/
│ │ ├── SearchScreen.kt
│ │ ├── SearchViewModel.kt
│ │ └── components/
│ │ ├── SearchBar.kt
│ │ └── SearchHistoryList.kt
│ ├── detail/
│ │ ├── NewsDetailScreen.kt
│ │ └── NewsDetailViewModel.kt
│ ├── favorites/
│ │ ├── FavoritesScreen.kt
│ │ └── FavoritesViewModel.kt
│ └── components/
│ ├── EmptyState.kt
│ ├── ErrorMessage.kt
│ └── LoadingIndicator.kt
└── navigation/
└── NewsNavGraph.kt
🛠️ 技术实现¶
1. 数据层实现¶
News.kt (Domain Model)¶
Kotlin
// 新闻领域模型,用于UI层展示(与数据库实体和网络DTO解耦)
data class News(
val id: String,
val title: String,
val description: String,
val url: String,
val imageUrl: String?,
val author: String?,
val publishedAt: LocalDateTime,
val category: String,
val source: String,
val isFavorite: Boolean = false
)
NewsEntity.kt¶
Kotlin
// Room数据库实体,对应news表
@Entity(tableName = "news")
data class NewsEntity(
@PrimaryKey // 使用新闻ID作为主键,避免重复插入
val id: String,
val title: String,
val description: String,
val url: String,
val imageUrl: String?,
val author: String?,
val publishedAt: Long,
val category: String,
val source: String,
val isFavorite: Boolean,
val cachedAt: Long = System.currentTimeMillis() // 缓存时间戳,用于清理过期数据
)
// 扩展函数:将数据库实体转换为领域模型(时间戳转LocalDateTime)
fun NewsEntity.toNews(): News = News(
id = id,
title = title,
description = description,
url = url,
imageUrl = imageUrl,
author = author,
// 将毫秒时间戳转换为本地日期时间
publishedAt = Instant.ofEpochMilli(publishedAt)
.atZone(ZoneId.systemDefault())
.toLocalDateTime(),
category = category,
source = source,
isFavorite = isFavorite
)
NewsDao.kt¶
Kotlin
@Dao
interface NewsDao {
// 按分类查询新闻,返回PagingSource支持分页加载
@Query("SELECT * FROM news WHERE category = :category ORDER BY publishedAt DESC")
fun getNewsByCategory(category: String): PagingSource<Int, NewsEntity>
// 查询所有收藏新闻,返回Flow实现数据变化自动通知
@Query("SELECT * FROM news WHERE isFavorite = 1 ORDER BY publishedAt DESC")
fun getFavoriteNews(): Flow<List<NewsEntity>>
// 模糊搜索标题和描述,支持本地离线搜索
@Query("SELECT * FROM news WHERE title LIKE '%' || :query || '%' OR description LIKE '%' || :query || '%'")
suspend fun searchNews(query: String): List<NewsEntity>
// 批量插入,冲突时替换旧数据(REPLACE策略)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(news: List<NewsEntity>)
@Update
suspend fun update(news: NewsEntity)
// 更新单条新闻的收藏状态(局部更新,性能优于全量Update)
@Query("UPDATE news SET isFavorite = :isFavorite WHERE id = :newsId")
suspend fun updateFavoriteStatus(newsId: String, isFavorite: Boolean)
// 清理过期缓存,传入截止时间戳
@Query("DELETE FROM news WHERE cachedAt < :timestamp")
suspend fun deleteOldNews(timestamp: Long)
@Query("SELECT COUNT(*) FROM news WHERE category = :category")
suspend fun getNewsCount(category: String): Int
}
NewsApiService.kt¶
Kotlin
// Retrofit API接口定义,对接NewsAPI服务
interface NewsApiService {
// 获取分类头条新闻(支持分页)
@GET("v2/top-headlines")
suspend fun getTopHeadlines(
@Query("category") category: String,
@Query("page") page: Int,
@Query("pageSize") pageSize: Int,
@Query("apiKey") apiKey: String = BuildConfig.NEWS_API_KEY // API密钥从BuildConfig读取
): NewsResponse
// 全文搜索新闻(默认按发布时间排序)
@GET("v2/everything")
suspend fun searchNews(
@Query("q") query: String,
@Query("page") page: Int,
@Query("pageSize") pageSize: Int,
@Query("sortBy") sortBy: String = "publishedAt",
@Query("apiKey") apiKey: String = BuildConfig.NEWS_API_KEY
): NewsResponse
}
NewsPagingSource.kt¶
Kotlin
// Paging3分页数据源:负责从网络加载新闻并缓存到本地
class NewsPagingSource(
private val api: NewsApiService,
private val category: String,
private val newsDao: NewsDao
) : PagingSource<Int, News>() {
// 刷新时确定从哪一页开始加载(基于当前锚点位置计算)
override fun getRefreshKey(state: PagingState<Int, News>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
// 核心加载方法:尝试网络请求,失败则回退到本地缓存
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, News> {
return try {
val page = params.key ?: 1 // 首次加载从第1页开始
val response = api.getTopHeadlines(
category = category,
page = page,
pageSize = params.loadSize
)
val news = response.articles.map { it.toNews(category) }
// 缓存到本地数据库
newsDao.insertAll(news.map { it.toEntity() })
// 构造分页结果:第1页无前页,空数据表示已到末尾
LoadResult.Page(
data = news,
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.articles.isEmpty()) null else page + 1
)
} catch (e: Exception) {
// 网络错误时尝试从本地加载
val localNews = newsDao.getNewsByCategory(category)
.load(params as PagingSource.LoadParams<Int>)
when (localNews) {
is LoadResult.Page -> localNews
is LoadResult.Error -> LoadResult.Error(e)
else -> LoadResult.Error(e)
}
}
}
}
2. Repository层¶
Kotlin
// 仓库层:统一管理数据来源(网络+本地),对ViewModel暴露干净的API
class NewsRepository @Inject constructor(
private val api: NewsApiService,
private val newsDao: NewsDao,
private val searchHistoryDao: SearchHistoryDao
) {
// 获取分页新闻流,UI层通过collectAsLazyPagingItems收集
fun getNewsStream(category: String): Flow<PagingData<News>> {
return Pager(
config = PagingConfig(
pageSize = 20, // 每页加载20条
enablePlaceholders = false, // 不使用占位符
maxSize = 100 // 内存中最多缓存100条
),
pagingSourceFactory = { NewsPagingSource(api, category, newsDao) }
).flow
}
fun getFavoriteNews(): Flow<List<News>> {
return newsDao.getFavoriteNews()
.map { entities -> entities.map { it.toNews() } }
}
suspend fun toggleFavorite(newsId: String, isFavorite: Boolean) {
newsDao.updateFavoriteStatus(newsId, isFavorite)
}
// 搜索新闻:优先网络搜索,失败时降级为本地搜索
suspend fun searchNews(query: String): List<News> {
// 保存搜索历史
searchHistoryDao.insert(SearchHistoryEntity(query = query))
return try {
val response = api.searchNews(query = query, page = 1, pageSize = 50)
response.articles.map { it.toNews("search") }
} catch (e: Exception) {
// 网络异常时回退到本地数据库模糊搜索
newsDao.searchNews(query).map { it.toNews() }
}
}
fun getSearchHistory(): Flow<List<String>> {
return searchHistoryDao.getRecentSearches()
.map { entities -> entities.map { it.query } }
}
suspend fun clearSearchHistory() {
searchHistoryDao.clearAll()
}
suspend fun deleteSearchQuery(query: String) {
searchHistoryDao.deleteQuery(query)
}
}
3. UI层实现¶
HomeScreen.kt¶
Kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel(),
onNewsClick: (News) -> Unit,
onSearchClick: () -> Unit
) {
// 新闻分类列表
val categories = listOf("general", "technology", "business", "sports", "entertainment", "science", "health")
var selectedCategory by remember { mutableStateOf(categories.first()) }
// 将Paging数据流收集为LazyPagingItems,供LazyColumn消费
val newsItems = viewModel.getNewsStream(selectedCategory).collectAsLazyPagingItems()
Scaffold(
topBar = {
TopAppBar(
title = { Text("新闻资讯") },
actions = {
IconButton(onClick = onSearchClick) {
Icon(Icons.Default.Search, contentDescription = "搜索")
}
}
)
}
) { padding ->
Column(modifier = Modifier.padding(padding)) {
// 分类标签
CategoryTabs(
categories = categories,
selectedCategory = selectedCategory,
onCategorySelected = { selectedCategory = it }
)
// 新闻列表
NewsList(
newsItems = newsItems,
onNewsClick = onNewsClick,
onFavoriteClick = { news, isFavorite ->
viewModel.toggleFavorite(news.id, isFavorite)
}
)
}
}
}
@Composable
fun NewsList(
newsItems: LazyPagingItems<News>,
onNewsClick: (News) -> Unit,
onFavoriteClick: (News, Boolean) -> Unit
) {
LazyColumn {
items(
count = newsItems.itemCount,
key = { index -> newsItems[index]?.id ?: index } // 使用新闻ID作为key,优化列表重组性能
) { index ->
val news = newsItems[index]
if (news != null) {
NewsItemCard(
news = news,
onClick = { onNewsClick(news) },
onFavoriteClick = { onFavoriteClick(news, !news.isFavorite) }
)
}
}
// Paging加载状态处理:分别处理首次加载(refresh)和追加加载(append)
newsItems.apply {
when {
loadState.refresh is LoadState.Loading -> {
item { FullScreenLoading() } // 首次加载显示全屏loading
}
loadState.refresh is LoadState.Error -> {
item {
ErrorMessage(
message = "加载失败,请重试",
onRetry = { retry() }
)
}
}
loadState.append is LoadState.Loading -> {
item { LoadingIndicator() }
}
loadState.append is LoadState.Error -> {
item {
ErrorMessage(
message = "加载更多失败",
onRetry = { retry() }
)
}
}
}
}
}
}
NewsItemCard.kt¶
Kotlin
@Composable
fun NewsItemCard(
news: News,
onClick: () -> Unit,
onFavoriteClick: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
onClick = onClick,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 新闻图片(使用Coil异步加载)
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(news.imageUrl)
.crossfade(true) // 启用淡入动画
.build(),
contentDescription = null,
modifier = Modifier
.size(100.dp)
.clip(RoundedCornerShape(8.dp)), // 圆角裁剪
contentScale = ContentScale.Crop,
placeholder = painterResource(R.drawable.placeholder_news), // 加载中占位图
error = painterResource(R.drawable.placeholder_news) // 加载失败兜底图
)
Spacer(modifier = Modifier.width(12.dp))
// 新闻内容
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = news.title,
style = MaterialTheme.typography.titleSmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = news.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = news.source,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = formatTimeAgo(news.publishedAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.weight(1f))
// 收藏按钮:根据收藏状态切换图标和颜色
IconButton(onClick = onFavoriteClick) {
Icon(
imageVector = if (news.isFavorite)
Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = "收藏",
tint = if (news.isFavorite) Color.Red else LocalContentColor.current
)
}
}
}
}
}
}
// 将发布时间格式化为"x分钟前/x小时前/x天前"的友好显示
fun formatTimeAgo(dateTime: LocalDateTime): String {
val now = LocalDateTime.now()
val duration = Duration.between(dateTime, now)
return when {
duration.toMinutes() < 60 -> "${duration.toMinutes()}分钟前"
duration.toHours() < 24 -> "${duration.toHours()}小时前"
duration.toDays() < 7 -> "${duration.toDays()}天前"
else -> dateTime.format(DateTimeFormatter.ofPattern("MM-dd")) // 超过7天显示日期
}
}
SearchScreen.kt¶
Kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(
viewModel: SearchViewModel = hiltViewModel(),
onNewsClick: (News) -> Unit,
onBackClick: () -> Unit
) {
var query by remember { mutableStateOf("") } // 搜索关键词状态
val searchHistory by viewModel.searchHistory.collectAsState() // 搜索历史
val searchResults by viewModel.searchResults.collectAsState() // 搜索结果
val isSearching by viewModel.isSearching.collectAsState() // 搜索加载状态
Scaffold(
topBar = {
TopAppBar(
title = {
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("搜索新闻...") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), // 键盘显示搜索按钮
keyboardActions = KeyboardActions(
onSearch = { // 点击键盘搜索按钮时触发
if (query.isNotBlank()) {
viewModel.search(query)
}
}
),
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { query = "" }) {
Icon(Icons.Default.Clear, contentDescription = "清除")
}
}
}
)
},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
}
}
)
}
) { padding ->
Box(modifier = Modifier.padding(padding)) {
// 根据搜索状态显示不同内容
when {
isSearching -> FullScreenLoading() // 搜索中显示加载动画
searchResults.isNotEmpty() -> SearchResultsList(
results = searchResults,
onNewsClick = onNewsClick
)
query.isEmpty() -> SearchHistoryPanel( // 未输入时显示搜索历史
history = searchHistory,
onHistoryClick = {
query = it
viewModel.search(it)
},
onClearHistory = { viewModel.clearHistory() },
onDeleteHistoryItem = { viewModel.deleteHistoryItem(it) }
)
else -> EmptyState(message = "未找到相关新闻")
}
}
}
}
NewsDetailScreen.kt¶
Kotlin
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewsDetailScreen(
newsUrl: String,
newsTitle: String,
viewModel: NewsDetailViewModel = hiltViewModel(),
onBackClick: () -> Unit
) {
var isLoading by remember { mutableStateOf(true) }
var webView by remember { mutableStateOf<WebView?>(null) }
val context = LocalContext.current
Scaffold(
topBar = {
TopAppBar(
title = { Text(newsTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
}
},
actions = {
IconButton(onClick = {
webView?.reload()
}) {
Icon(Icons.Default.Refresh, contentDescription = "刷新")
}
IconButton(onClick = {
shareNews(context, newsTitle, newsUrl)
}) {
Icon(Icons.Default.Share, contentDescription = "分享")
}
}
)
}
) { padding ->
Box(modifier = Modifier.padding(padding)) {
AndroidView(
factory = { context ->
// 在Compose中嵌入WebView加载新闻原文
WebView(context).apply {
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
isLoading = false // 页面加载完成后隐藏进度条
}
}
webChromeClient = WebChromeClient() // 支持JavaScript对话框等Chrome功能
settings.apply {
javaScriptEnabled = true // 启用JS(大部分新闻网站需要)
domStorageEnabled = true // 启用DOM存储
cacheMode = WebSettings.LOAD_DEFAULT // 使用默认缓存策略
}
loadUrl(newsUrl)
webView = this // 保存引用,供刷新按钮使用
}
},
modifier = Modifier.fillMaxSize()
)
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}
// 调用系统分享Sheet分享新闻链接
private fun shareNews(context: Context, title: String, url: String) {
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain" // MIME类型为纯文本
putExtra(Intent.EXTRA_SUBJECT, title)
putExtra(Intent.EXTRA_TEXT, "$title\n$url") // 分享内容:标题+链接
}
context.startActivity(Intent.createChooser(shareIntent, "分享新闻"))
}
4. ViewModel实现¶
Kotlin
@HiltViewModel // Hilt注入ViewModel,自动管理生命周期
class HomeViewModel @Inject constructor(
private val repository: NewsRepository
) : ViewModel() {
fun getNewsStream(category: String): Flow<PagingData<News>> {
return repository.getNewsStream(category)
.cachedIn(viewModelScope) // 在ViewModel作用域内缓存分页数据,避免配置变更时重新加载
}
fun toggleFavorite(newsId: String, isFavorite: Boolean) {
viewModelScope.launch { // 在协程中执行数据库操作
repository.toggleFavorite(newsId, isFavorite)
}
}
}
@HiltViewModel
class SearchViewModel @Inject constructor(
private val repository: NewsRepository
) : ViewModel() {
// MutableStateFlow用于ViewModel内部修改,StateFlow用于对外只读暴露
private val _searchResults = MutableStateFlow<List<News>>(emptyList())
val searchResults: StateFlow<List<News>> = _searchResults.asStateFlow()
private val _isSearching = MutableStateFlow(false)
val isSearching: StateFlow<Boolean> = _isSearching.asStateFlow()
// stateIn将Flow转换为StateFlow,Lazily表示首次订阅时才开始收集
val searchHistory: StateFlow<List<String>> = repository.getSearchHistory()
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
fun search(query: String) {
viewModelScope.launch {
_isSearching.value = true
try {
_searchResults.value = repository.searchNews(query)
} catch (e: Exception) {
_searchResults.value = emptyList()
} finally {
_isSearching.value = false
}
}
}
fun clearHistory() {
viewModelScope.launch {
repository.clearSearchHistory()
}
}
fun deleteHistoryItem(query: String) {
viewModelScope.launch {
repository.deleteSearchQuery(query)
}
}
}
🔧 依赖配置¶
Kotlin
// build.gradle.kts (Module: app)
dependencies {
// Paging - 分页加载库
implementation("androidx.paging:paging-runtime-ktx:3.2.1")
implementation("androidx.paging:paging-compose:3.2.1") // Compose集成
// WebView - 增强型WebView组件
implementation("androidx.webkit:webkit:1.9.0")
// Coil - Compose专用图片异步加载库
implementation("io.coil-kt:coil-compose:2.5.0")
// Retrofit - 网络请求框架
implementation("com.squareup.retrofit2:retrofit:2.11.0")
implementation("com.squareup.retrofit2:converter-gson:2.11.0") // JSON解析转换器
// Room - 本地数据库ORM框架
implementation("androidx.room:room-runtime:2.6.1")
implementation("androidx.room:room-ktx:2.6.1") // 协程支持扩展
kapt("androidx.room:room-compiler:2.6.1") // 编译期注解处理器
// Hilt - 依赖注入框架
implementation("com.google.dagger:hilt-android:2.50")
kapt("com.google.dagger:hilt-compiler:2.50") // 编译期代码生成
implementation("androidx.hilt:hilt-navigation-compose:1.1.0") // Compose导航集成
}
🎯 练习任务¶
基础任务¶
- ✅ 完成新闻列表的分页加载功能
- ✅ 实现新闻分类切换
- ✅ 添加新闻收藏功能
进阶任务¶
- 🔄 实现新闻详情页的离线阅读(保存网页内容)
- 🔄 添加新闻推送通知功能
- 🔄 实现新闻阅读历史记录
挑战任务¶
- 🎯 集成Google AdMob广告
- 🎯 实现新闻内容的文字转语音功能
- 🎯 添加新闻评论功能(需要后端支持)
📚 学习要点¶
- Paging3: 掌握分页加载的最佳实践
- WebView: 学会处理WebView的各种场景
- 搜索功能: 实现搜索历史和实时搜索
- 分享功能: 使用系统分享Sheet
- 离线支持: 实现数据的本地缓存策略