一、项目架构设计——从零搭建新闻客户端

说实话,每次开始一个新项目,我最头疼的不是写代码,而是「架构怎么搭」。搭好了后面顺风顺水,搭不好……嗯,后面全是坑。

这次我们要做一个新闻客户端。功能不算复杂:展示新闻列表、查看详情、收藏、分类浏览。但麻雀虽小五脏俱全,正好拿它来演示 Jetpack 全家桶怎么配合。

1.1 整体架构分层

我个人习惯用「分层架构」来组织代码。说白了就是各干各的,互不干扰。我把它分成三层:

  • UI 层:Activity、Fragment、Compose 页面,只管展示和交互
  • 数据层:Repository、DataSource,管数据从哪来、存哪去
  • 依赖注入层:Hilt 模块,管对象怎么创建、怎么给

你想想看,如果这三层混在一起,改个网络请求库,UI 层也得跟着改,那多痛苦。我在项目中遇到过这种「面条代码」,改一个 bug 能引出三个新 bug……

核心原则:上层依赖下层,下层不依赖上层。UI 层只知道 Repository,不知道 Room 还是 Retrofit。

1.2 模块划分

项目我拆成了这几个模块:

模块名 职责 依赖
app 主入口,Application 类 所有模块
core-data 数据层,Repository 实现 core-network, core-database
core-network 网络请求,Retrofit 封装
core-database 本地缓存,Room 封装
feature-news 新闻列表、详情 UI core-data
feature-bookmark 收藏功能 UI core-data

为什么要分这么细?我曾经在一个大项目里把所有代码塞进一个 module,结果编译一次 5 分钟……后来拆成模块,编译时间直接砍半。而且团队协作时,每个人负责一个 feature 模块,冲突少很多。

新闻客户端模块依赖图 app 主模块 feature-news feature-bookmark core-data core-network core-database

二、依赖注入——Hilt 让代码更清爽

依赖注入这东西,说白了就是「你要什么,我给你什么,你不用自己 new」。我刚开始用 Dagger 时被它的配置搞到头大,后来 Hilt 出来,嗯,舒服多了。

2.1 安装 Hilt

在项目级 build.gradle 加插件:

// 项目级 build.gradle
plugins {
    id 'com.google.dagger.hilt.android' version '2.48' apply false
}

// app/build.gradle
plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id 'com.google.dagger.hilt.android'
    id 'kotlin-kapt'
}

dependencies {
    implementation 'com.google.dagger:hilt-android:2.48'
    kapt 'com.google.dagger:hilt-compiler:2.48'
}

然后给 Application 类加个注解:

@HiltAndroidApp
class NewsApplication : Application()

就这么简单。Hilt 会自动生成 Dagger 组件,你不用写 Component 接口了。

2.2 提供依赖——Module 怎么写

我们需要告诉 Hilt:「嘿,Retrofit 怎么创建?Room 数据库怎么创建?」

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    @Provides
    @Singleton
    fun provideNewsApi(retrofit: Retrofit): NewsApi {
        return retrofit.create(NewsApi::class.java)
    }
}

小提示:用 @Singleton 注解确保整个应用只有一个 Retrofit 实例。我见过有人每次请求都 new 一个 Retrofit,那性能……嗯,你懂的。

2.3 注入到 ViewModel

ViewModel 里直接用构造函数注入:

@HiltViewModel
class NewsViewModel @Inject constructor(
    private val newsRepository: NewsRepository
) : ViewModel() {

    private val _newsList = MutableStateFlow<List<News>>(emptyList())
    val newsList: StateFlow<List<News>> = _newsList.asStateFlow()

    fun loadNews() {
        viewModelScope.launch {
            val result = newsRepository.getNews()
            _newsList.value = result
        }
    }
}

你看,ViewModel 里没有一行 new 的代码。所有依赖都是「要来的」。这样测试时也方便,直接 mock Repository 传进去就行。

三、数据层实现——Repository 模式

数据层我习惯用 Repository 模式。它就像一个「数据管家」,UI 层只管问它要数据,不用管数据是从网络来还是从缓存来。

3.1 定义数据源接口

// 网络数据源
interface NewsRemoteDataSource {
    suspend fun fetchNews(): List<News>
}

// 本地数据源
interface NewsLocalDataSource {
    suspend fun getCachedNews(): List<News>
    suspend fun saveNews(news: List<News>)
}

3.2 Repository 实现

class NewsRepositoryImpl @Inject constructor(
    private val remoteDataSource: NewsRemoteDataSource,
    private val localDataSource: NewsLocalDataSource
) : NewsRepository {

    override suspend fun getNews(): List<News> {
        // 先尝试从网络获取
        return try {
            val remoteNews = remoteDataSource.fetchNews()
            localDataSource.saveNews(remoteNews)  // 缓存到本地
            remoteNews
        } catch (e: Exception) {
            // 网络失败,从本地缓存读取
            localDataSource.getCachedNews()
        }
    }
}

注意:这里有个坑。如果网络请求失败,直接返回缓存数据,但用户可能看到的是旧数据。我曾经在项目中加了一个「缓存时间」判断:如果缓存超过 30 分钟,即使网络失败也要提示用户「数据可能不是最新的」。

四、UI 层实现——Compose + StateFlow

UI 层我用 Jetpack Compose,配合 StateFlow 做状态管理。为什么选 Compose?因为声明式 UI 和 StateFlow 简直是天生一对。

4.1 新闻列表页面

@Composable
fun NewsListScreen(
    viewModel: NewsViewModel = hiltViewModel()
) {
    val newsList by viewModel.newsList.collectAsState()

    LazyColumn {
        items(newsList) { news ->
            NewsItem(news = news) {
                // 点击跳转详情
            }
        }
    }
}

@Composable
fun NewsItem(news: News, onClick: () -> Unit) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp)
            .clickable { onClick() }
    ) {
        Row(modifier = Modifier.padding(16.dp)) {
            AsyncImage(
                model = news.imageUrl,
                contentDescription = null,
                modifier = Modifier.size(80.dp)
            )
            Spacer(modifier = Modifier.width(12.dp))
            Column {
                Text(text = news.title, fontWeight = FontWeight.Bold)
                Text(text = news.summary, maxLines = 2)
            }
        }
    }
}

4.2 状态管理——别忘了一件事

嗯,这里要注意:StateFlow 在 Compose 里用 collectAsState() 收集,但如果你在 Fragment 里用,记得用 lifecycleScope 配合 repeatOnLifecycle,防止泄漏。

// Fragment 中使用
viewLifecycleOwner.lifecycleScope.launch {
    viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        viewModel.newsList.collect { news ->
            // 更新 UI
        }
    }
}

五、实战:新闻客户端完整流程

好了,我们把所有东西串起来。用户打开 App,流程是这样的:

  1. App 启动,Hilt 初始化所有依赖
  2. NewsListFragment 创建,NewsViewModel 被注入
  3. ViewModel 调用 Repository 获取新闻列表
  4. Repository 先尝试网络请求,失败则读缓存
  5. 数据通过 StateFlow 发射到 UI 层
  6. Compose 页面自动重组,展示新闻列表

你想想看,整个过程没有一行代码是「硬耦合」的。换网络库?改 NetworkModule 就行。换本地存储?改 LocalDataSource 实现就行。UI 层完全不受影响。

总结一下:Jetpack 组件不是孤立的,Hilt + Room + Retrofit + Compose + StateFlow 组合起来,才能发挥最大威力。我做了这么多年 Android,这套组合是目前我觉得最「舒服」的架构。

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