/ Android  Jetpack Compose  MVVM  Clean Architecture  Hilt  Room  Kotlin  Android开发 

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。这样可以在不影响线上稳定性的前提下完成架构升级。

发布评论

热门评论区: