Jetpack Compose + MVVM + Clean Architecture 实战:从零搭建 Android 新闻 App

随着 Android 应用越来越复杂,传统的 MVC/MVP 架构已经难以满足大型项目的维护需求。Jetpack Compose + MVVM + Clean Architecture 的组合正成为 2024 年 Android 开发的主流选择。本文将通过一个完整的"新闻阅读 App"实战项目,带你从零搭建这套架构,包含真实代码、踩坑经验和性能优化技巧。
一、为什么选择 Clean Architecture?
很多开发者初学 Android 时习惯把所有逻辑塞进 Activity 或 Fragment,随着业务增长,维护成本呈指数上升。Clean Architecture 通过清晰的分层解决这个问题:
Presentation 层:Compose UI + ViewModel,只负责展示和用户交互
Domain 层:UseCase + Entity,纯 Kotlin,零 Android 依赖,可独立测试
Data 层:Repository + DataSource,负责数据获取与缓存策略
依赖方向只能向内,外层可以依赖内层,反之不行。Domain 层不知道 Retrofit 是什么,也不知道 Room 是什么,这让单元测试极其简单。
NewsApp/ ├── presentation/ │ ├── ui/ │ ├── viewmodel/ │ └── state/ ├── domain/ │ ├── entity/ │ ├── usecase/ │ └── repository/ └── data/ ├── remote/ ├── local/ └── repository/
二、依赖注入:用 Hilt 连接各层
Hilt 是 Google 官方推荐的 DI 框架,比手动 Koin 配置更安全,编译时检查依赖错误。在 build.gradle 添加依赖:
plugins {
id("com.google.dagger.hilt.android")
id("kotlin-kapt")
}
dependencies {
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")
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("androidx.room:room-runtime:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
}定义 NetworkModule:
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl("https://newsapi.org/v2/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides @Singleton
fun provideNewsApi(retrofit: Retrofit): NewsApi = retrofit.create(NewsApi::class.java)
}注意:Application 类必须加 @HiltAndroidApp 注解,否则编译报错:
@HiltAndroidApp class NewsApplication : Application()
三、Domain 层:UseCase 设计实战
每个 UseCase 只做一件事,通过 operator fun invoke 可以像函数一样调用:
data class Article(
val id: String, val title: String,
val description: String, val imageUrl: String?,
val publishedAt: Long, val source: String, val url: String
)
interface NewsRepository {
fun getTopHeadlines(category: String): Flow<Result<List<Article>>>
suspend fun refreshNews(category: String)
suspend fun toggleBookmark(articleId: String)
}
class GetTopHeadlinesUseCase @Inject constructor(
private val newsRepository: NewsRepository
) {
operator fun invoke(category: String) =
newsRepository.getTopHeadlines(category).map { result ->
result.map { articles ->
articles.filter { it.imageUrl != null }
.sortedByDescending { it.publishedAt }
}
}
}四、Data 层:离线优先的 Repository 实现
离线优先是现代 App 的标配——先展示本地缓存,后台刷新数据。Room + Flow 天然支持:
@Dao
interface NewsDao {
@Query("SELECT * FROM articles WHERE category = :category ORDER BY publishedAt DESC")
fun getArticlesByCategory(category: String): Flow<List<ArticleEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertArticles(articles: List<ArticleEntity>)
}
class NewsRepositoryImpl @Inject constructor(
private val newsApi: NewsApi,
private val newsDao: NewsDao,
private val mapper: ArticleMapper
) : NewsRepository {
override fun getTopHeadlines(category: String) =
newsDao.getArticlesByCategory(category)
.map { Result.success(it.map(mapper::entityToDomain)) }
.catch { emit(Result.failure(it)) }
override suspend fun refreshNews(category: String) {
try {
val resp = newsApi.getTopHeadlines(category, BuildConfig.NEWS_API_KEY, 50)
newsDao.insertArticles(resp.articles.map(mapper::dtoToEntity))
} catch (e: Exception) { Log.w("Repo", e) }
}
}踩坑提示:Room 的 Flow 在数据库任何变化时都会重新 emit,会造成 UI 闪烁。解决方案是在 ViewModel 层加 distinctUntilChanged()。
五、ViewModel:状态管理最佳实践
用 Sealed Class 定义 UI State,比多个 Boolean 标志位更清晰:
sealed class NewsUiState {
object Loading : NewsUiState()
data class Success(val articles: List<Article>) : NewsUiState()
data class Error(val message: String) : NewsUiState()
}
@HiltViewModel
class NewsViewModel @Inject constructor(
private val getTopHeadlinesUseCase: GetTopHeadlinesUseCase,
private val refreshNewsUseCase: RefreshNewsUseCase
) : ViewModel() {
private val selectedCategory = MutableStateFlow("technology")
val uiState: StateFlow<NewsUiState> = selectedCategory
.flatMapLatest { cat ->
getTopHeadlinesUseCase(cat).map { result ->
result.fold(
onSuccess = { if (it.isEmpty()) NewsUiState.Loading else NewsUiState.Success(it) },
onFailure = { NewsUiState.Error(it.message ?: "未知错误") }
)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NewsUiState.Loading)
fun selectCategory(category: String) {
selectedCategory.value = category
viewModelScope.launch { refreshNewsUseCase(category) }
}
}六、Compose UI:构建高性能新闻列表
LazyColumn 中的 key 参数至关重要,告诉 Compose 如何追踪列表项,避免不必要的重组:
@Composable
fun NewsScreen(viewModel: NewsViewModel = hiltViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
when (val state = uiState) {
is NewsUiState.Loading -> CircularProgressIndicator()
is NewsUiState.Success -> {
LazyColumn {
items(
items = state.articles,
key = { it.id } // 必须设置!
) { article ->
ArticleCard(
article = article,
onBookmarkClick = { viewModel.toggleBookmark(article.id) },
modifier = Modifier.animateItemPlacement()
)
}
}
}
is NewsUiState.Error -> ErrorView(state.message)
}
}性能踩坑:不要用 collectAsState() 收集 SharedFlow,会导致每次重组都重新订阅。改用 collectAsStateWithLifecycle() 自动感知生命周期,避免内存泄漏。
七、单元测试:验证 UseCase 的正确性
Domain 层的 UseCase 不依赖任何 Android 框架,用纯 Kotlin + JUnit 即可测试:
class GetTopHeadlinesUseCaseTest {
private val mockRepo: NewsRepository = mockk()
private val useCase = GetTopHeadlinesUseCase(mockRepo)
@Test
fun shouldFilterArticlesWithoutImages() = runTest {
val articles = listOf(
Article("1","T1","",null,1000L,"S","u"),
Article("2","T2","","http://img/1.jpg",2000L,"S","u"),
Article("3","T3","","http://img/2.jpg",3000L,"S","u")
)
every { mockRepo.getTopHeadlines("tech") } returns flowOf(Result.success(articles))
val result = useCase("tech").first()
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrNull()).hasSize(2)
assertThat(result.getOrNull()!![0].id).isEqualTo("3") // 按时间倒序
}
}八、总结与迁移建议
整套架构落地后具备以下特性:
可测试:Domain 层 UseCase 零依赖,单测覆盖率可达 90%+
可维护:新增功能只需添加 UseCase + ViewModel,不影响其他层
离线优先:Room + Flow 自动同步缓存与网络数据
性能优化:LazyColumn key + WhileSubscribed(5000) 减少不必要计算
如果正在使用 MVP + XML,迁移建议是渐进式:新功能用 Compose 写,旧页面逐步用 ComposeView 嵌入替换,数据层从 Repository 开始重构,业务逻辑提取到 UseCase,最后再换 ViewModel。这样可以在不影响线上稳定性的前提下完成架构升级。
发布评论
热门评论区: