29、实战项目(上):项目架构搭建、首页Feed流实现、详情页、状态管理
好,终于到了实战环节。说实话,前面讲了那么多理论,我自己都觉得有点手痒。这一章我们直接上手,从零搭一个完整的项目。我会带着你一步步走,把前面学的 Jetpack Compose 知识串起来。
先说说这个项目要做什么。我们要做一个简单的资讯阅读 App,有首页 Feed 流、详情页,还要处理好状态管理。麻雀虽小,五脏俱全。你想想看,这其实就是大多数内容型 App 的核心骨架。
项目架构怎么搭?
我个人习惯用简洁的分层架构。不搞太复杂,够用就行。项目结构大概这样:
com.example.newsapp/
├── data/
│ ├── model/ // 数据模型
│ ├── repository/ // 数据仓库
│ └── source/ // 数据源(这里用Mock)
├── ui/
│ ├── feed/ // 首页Feed
│ ├── detail/ // 详情页
│ └── common/ // 公共组件
└── viewmodel/
└── MainViewModel.kt
嗯,这里要注意:不要把 ViewModel 和 UI 混在一起。我在项目中见过太多人把网络请求直接写在 Composable 里,结果代码乱成一锅粥。
先搭数据层
数据层是整个 App 的基石。我们先定义一个数据模型:
data class Article(
val id: Int,
val title: String,
val summary: String,
val content: String,
val author: String,
val publishTime: String,
val imageUrl: String
)
然后写个 Mock 数据源。为什么用 Mock?因为我们要先跑通流程,等后端接口好了再替换。我在项目中经常这么干,前端不用等后端。
object MockDataSource {
fun getArticles(): List<Article> = listOf(
Article(1, "Jetpack Compose 入门指南", "Compose 是 Android 的新一代 UI 框架...",
"详细内容...", "张三", "2024-01-15", ""),
Article(2, "Kotlin 协程实战", "协程让异步编程变得简单...",
"详细内容...", "李四", "2024-01-14", "")
// 更多数据...
)
}
Repository 层
Repository 是数据层的门面。ViewModel 只跟 Repository 打交道,不关心数据从哪来。
class ArticleRepository {
fun fetchArticles(): List<Article> {
return MockDataSource.getArticles()
}
fun getArticleById(id: Int): Article? {
return MockDataSource.getArticles().find { it.id == id }
}
}
你想想看,如果以后要接入网络,只需要改 Repository 内部实现,ViewModel 完全不用动。这就是分层的好处。
状态管理——核心中的核心
状态管理是 Compose 的灵魂。我习惯用密封类来定义 UI 状态:
sealed class FeedUiState {
object Loading : FeedUiState()
data class Success(val articles: List<Article>) : FeedUiState()
data class Error(val message: String) : FeedUiState()
}
为什么用密封类?因为它能清晰表达所有可能的状态。Loading、成功、失败,一目了然。我曾经见过有人用布尔值控制加载状态,结果越写越乱。
ViewModel 里这样管理状态:
class MainViewModel : ViewModel() {
private val repository = ArticleRepository()
private val _feedState = MutableStateFlow<FeedUiState>(FeedUiState.Loading)
val feedState: StateFlow<FeedUiState> = _feedState.asStateFlow()
init {
loadArticles()
}
fun loadArticles() {
_feedState.value = FeedUiState.Loading
try {
val articles = repository.fetchArticles()
_feedState.value = FeedUiState.Success(articles)
} catch (e: Exception) {
_feedState.value = FeedUiState.Error(e.message ?: "未知错误")
}
}
}
首页 Feed 流实现
Feed 流说白了就是一个 LazyColumn。但怎么把状态管理好,才是关键。
@Composable
fun FeedScreen(
viewModel: MainViewModel = viewModel()
) {
val state by viewModel.feedState.collectAsState()
when (state) {
is FeedUiState.Loading -> LoadingIndicator()
is FeedUiState.Success -> FeedList((state as FeedUiState.Success).articles)
is FeedUiState.Error -> ErrorView((state as FeedUiState.Error).message)
}
}
@Composable
fun FeedList(articles: List<Article>) {
LazyColumn {
items(articles) { article ->
ArticleCard(article = article)
}
}
}
这里有个细节:collectAsState() 会把 Flow 转成 Compose 可观察的状态。当 ViewModel 里的状态变化时,UI 会自动重组。这就是响应式编程的魅力。
每个卡片组件长这样:
@Composable
fun ArticleCard(
article: Article,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable { onClick() },
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = article.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 2
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = article.summary,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
color = Color.Gray
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "${article.author} · ${article.publishTime}",
style = MaterialTheme.typography.bodySmall,
color = Color.LightGray
)
}
}
}
详情页实现
详情页其实更简单。接收一个文章 ID,展示完整内容。但要注意:不要重复加载数据。
@Composable
fun DetailScreen(
articleId: Int,
viewModel: MainViewModel = viewModel()
) {
val state by viewModel.feedState.collectAsState()
// 从已有数据中查找
val article = when (state) {
is FeedUiState.Success -> {
(state as FeedUiState.Success).articles.find { it.id == articleId }
}
else -> null
}
article?.let {
Scaffold(
topBar = {
TopAppBar(title = { Text("详情") })
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState())
) {
Text(
text = it.title,
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "${it.author} · ${it.publishTime}",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = it.content,
style = MaterialTheme.typography.bodyLarge,
lineHeight = 1.6f
)
}
}
}
}
为什么从已有数据里找?因为首页已经加载过了。如果每次进详情都重新请求,那就是浪费。当然,如果数据量很大,可以按需加载。但这里我们保持简单。
导航怎么处理?
导航用 Compose Navigation。在 MainActivity 里配置:
setContent {
NewsAppTheme {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "feed") {
composable("feed") {
FeedScreen(
onArticleClick = { articleId ->
navController.navigate("detail/$articleId")
}
)
}
composable(
route = "detail/{articleId}",
arguments = listOf(navArgument("articleId") { type = NavType.IntType })
) { backStackEntry ->
val articleId = backStackEntry.arguments?.getInt("articleId") ?: return@composable
DetailScreen(articleId = articleId)
}
}
}
}
总结一下
这一章我们做了三件事:搭了项目架构、实现了 Feed 流和详情页、搞定了状态管理。说白了,就是让数据从 Repository 流到 ViewModel,再流到 UI。方向是单向的,逻辑是清晰的。
你可能会问:就这么简单?对,就这么简单。但简单不等于容易。把每个环节做好,代码才能经得起推敲。下一章我们会继续完善这个项目,加入更多实战功能。
- 分层架构:数据层、ViewModel层、UI层各司其职
- 状态管理:用密封类 + StateFlow 管理 UI 状态
- Feed 流:LazyColumn + 状态驱动渲染
- 详情页:复用已有数据,避免重复加载
公众号:蓝海资料掘金营,微信deep3321