29、实战项目(上):项目架构搭建、首页Feed流实现、详情页、状态管理

好,终于到了实战环节。说实话,前面讲了那么多理论,我自己都觉得有点手痒。这一章我们直接上手,从零搭一个完整的项目。我会带着你一步步走,把前面学的 Jetpack Compose 知识串起来。

先说说这个项目要做什么。我们要做一个简单的资讯阅读 App,有首页 Feed 流、详情页,还要处理好状态管理。麻雀虽小,五脏俱全。你想想看,这其实就是大多数内容型 App 的核心骨架。

项目架构总览 数据层 Repository + API ViewModel层 状态管理 + 业务逻辑 UI层 Compose 组件 数据源(Mock/网络) MainViewModel FeedScreen / DetailScreen UiState 密封类 单向数据流:UI → ViewModel → Repository → 数据源

项目架构怎么搭?

我个人习惯用简洁的分层架构。不搞太复杂,够用就行。项目结构大概这样:

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", "")
        // 更多数据...
    )
}
小技巧:Mock 数据要尽量真实。我一般会写 5-8 条数据,覆盖不同长度、不同情况,这样 UI 调试时才能发现问题。

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 ?: "未知错误")
        }
    }
}
注意:StateFlow 的初始值一定要设置合理。我见过有人忘了设 Loading 状态,结果页面闪一下白屏。用户体验很差。

首页 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
            )
        }
    }
}
核心要点:Feed 流的关键在于「状态驱动 UI」。ViewModel 负责管理状态,Composable 只负责根据状态渲染。这样代码清晰,也容易测试。

详情页实现

详情页其实更简单。接收一个文章 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)
            }
        }
    }
}
避坑指南:导航参数类型一定要匹配。我曾经因为 NavType 写错了,调试了半天才发现是 Int 写成了 String。这种低级错误最浪费时间。

总结一下

这一章我们做了三件事:搭了项目架构、实现了 Feed 流和详情页、搞定了状态管理。说白了,就是让数据从 Repository 流到 ViewModel,再流到 UI。方向是单向的,逻辑是清晰的。

你可能会问:就这么简单?对,就这么简单。但简单不等于容易。把每个环节做好,代码才能经得起推敲。下一章我们会继续完善这个项目,加入更多实战功能。

本章核心:
  • 分层架构:数据层、ViewModel层、UI层各司其职
  • 状态管理:用密封类 + StateFlow 管理 UI 状态
  • Feed 流:LazyColumn + 状态驱动渲染
  • 详情页:复用已有数据,避免重复加载

公众号:蓝海资料掘金营,微信deep3321