第30章:综合实战——构建一个完整的MVVM + Hilt/Koin + Jetpack项目
终于到了最后一章。说实话,前面讲了那么多理论、注解、作用域、测试,最终都要落到一个完整的项目里。我记得自己刚学依赖注入那会儿,最头疼的就是「每个模块都会用,但一整合就崩」。
这一章,我们就来亲手搭一个完整的项目。它包含网络请求、本地缓存、UI展示、状态管理——说白了,就是一个能跑起来的真实App骨架。
30.1 项目结构总览
我个人习惯按功能模块分包,而不是按技术层分包。这样后期维护时,找代码更快。
app/
├── data/
│ ├── local/ // Room数据库
│ ├── remote/ // Retrofit接口
│ └── repository/ // 仓库实现
├── di/ // Hilt/Koin 模块
├── domain/
│ ├── model/ // 业务模型
│ └── repository/ // 仓库接口
├── ui/
│ ├── home/ // 首页
│ ├── detail/ // 详情页
│ └── common/ // 公共组件
└── MainActivity.kt
为什么这么分?我在项目中踩过坑——按技术层分包(比如把所有ViewModel放一起),项目一大了,改一个功能要翻四五个包。按功能分包,一个功能的所有代码都在一个包里,清爽多了。
30.2 依赖注入模块设计
我们先看Hilt版本。Koin的版本我会在后面给出对比。
30.2.1 Hilt 模块
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
}
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
}
嗯,这里要注意:@Singleton 注解保证了整个App生命周期内只有一个实例。我曾经见过有人把网络请求相关的对象写成局部作用域,结果每次打开页面都重新创建OkHttpClient,内存直接炸了。
30.2.2 Koin 模块
val appModule = module {
single {
OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
}
single {
Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(get())
.addConverterFactory(GsonConverterFactory.create())
.build()
}
single {
get<Retrofit>().create(ApiService::class.java)
}
}
你看,Koin的写法更简洁,没有注解,全是DSL。但代价是编译期不检查依赖——我有个同事就因为拼错了模块名,运行时才报错,排查了半天。
30.3 核心架构流程图
下面这张图,是我自己画的项目架构。你仔细看,箭头方向就是数据流动的方向。
数据流向是单向的:UI -> ViewModel -> Repository -> DataSource。反过来,数据通过回调或Flow向上传递。DI容器负责把每一层的对象「喂」给上一层。
30.4 ViewModel 中的依赖注入
这是Hilt的写法,用注解就能拿到Repository:
@HiltViewModel
class HomeViewModel @Inject constructor(
private val repository: HomeRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
fun loadData() {
viewModelScope.launch {
repository.getHomeData()
.onSuccess { data ->
_uiState.value = HomeUiState.Success(data)
}
.onFailure { error ->
_uiState.value = HomeUiState.Error(error.message ?: "未知错误")
}
}
}
}
Koin的写法稍微不同,但核心思想一样:
class HomeViewModel(
private val repository: HomeRepository
) : ViewModel() {
// ... 同上
}
// 在Koin模块中注册
val viewModelModule = module {
viewModel { HomeViewModel(get()) }
}
我个人更偏爱Hilt的注解方式,因为IDE能直接跳转到提供依赖的地方。但Koin的DSL写起来确实快,适合小团队快速迭代。
30.5 Repository 的实现
Repository是数据层的门面。它决定什么时候从网络拿数据,什么时候用缓存。
class HomeRepositoryImpl @Inject constructor(
private val remoteDataSource: HomeRemoteDataSource,
private val localDataSource: HomeLocalDataSource
) : HomeRepository {
override suspend fun getHomeData(): Result<HomeData> {
// 先尝试从本地拿
val cached = localDataSource.getCachedData()
if (cached != null) {
return Result.success(cached)
}
// 本地没有,走网络
return try {
val remote = remoteDataSource.fetchHomeData()
localDataSource.saveData(remote) // 写入缓存
Result.success(remote)
} catch (e: Exception) {
Result.failure(e)
}
}
}
这里有个坑:我曾经直接把网络返回的数据塞给UI,没做缓存。结果用户一断网,页面就白屏了。后来才加上本地缓存——其实Room做缓存特别稳,配合Flow还能自动刷新UI。
30.6 数据层的完整实现
Remote DataSource 用 Retrofit:
interface HomeApiService {
@GET("home/data")
suspend fun getHomeData(): Response<HomeDataResponse>
}
class HomeRemoteDataSource @Inject constructor(
private val api: HomeApiService
) {
suspend fun fetchHomeData(): HomeData {
val response = api.getHomeData()
if (response.isSuccessful) {
return response.body()!!.toDomain()
}
throw Exception("网络请求失败: ${response.code()}")
}
}
Local DataSource 用 Room:
@Dao
interface HomeDao {
@Query("SELECT * FROM home_data LIMIT 1")
fun getCachedData(): Flow<HomeDataEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveData(data: HomeDataEntity)
}
class HomeLocalDataSource @Inject constructor(
private val dao: HomeDao
) {
fun getCachedData(): Flow<HomeData?> {
return dao.getCachedData().map { it?.toDomain() }
}
suspend fun saveData(data: HomeData) {
dao.saveData(data.toEntity())
}
}
30.7 避坑指南
我曾经犯过的错:
- 把整个Retrofit实例注入到ViewModel里——导致ViewModel变得臃肿,而且难以测试。应该只注入具体的Repository。
- 在Koin中使用
factory { }创建ViewModel,结果每次旋转屏幕都重新创建,状态全丢了。应该用viewModel { }。 - Hilt中忘记给模块加
@InstallIn,编译不报错,运行时才崩溃。排查了整整一下午。
我的小技巧:
- 用
@Binds代替@Provides来绑定接口和实现,代码更简洁。 - Koin中可以用
qualifier区分同一个接口的不同实现,比如qualifier<Named>("mock")。 - 测试时,直接手动构造依赖对象,不用启动DI容器,跑得飞快。
30.8 测试怎么写
依赖注入最大的好处就是方便测试。你看这个单元测试,所有依赖都是假的:
class HomeViewModelTest {
private val mockRepository = mock<HomeRepository>()
private lateinit var viewModel: HomeViewModel
@Before
fun setup() {
// 直接手动注入,不用Hilt
viewModel = HomeViewModel(mockRepository)
}
@Test
fun `加载数据成功时状态更新为Success`() = runTest {
// 模拟成功
`when`(mockRepository.getHomeData()).thenReturn(
Result.success(HomeData("测试"))
)
viewModel.loadData()
// 验证状态
val state = viewModel.uiState.first()
assert(state is HomeUiState.Success)
}
}
你看,没有Hilt、没有Koin,测试照样跑。依赖注入的本质就是「把对象的创建权交出去」,测试时我们手动接管这个权力,反而更灵活。
30.9 总结
这一章我们完整走了一遍MVVM + DI + Jetpack的项目搭建。从模块划分、依赖注入配置、数据流向,到测试——每一步我都尽量把实际项目中踩过的坑讲清楚。
说实话,依赖注入本身并不复杂。复杂的是怎么把它用好,怎么让项目结构清晰、可维护、可测试。你想想看,一个项目里几十个类,如果没有DI,你得手动new来new去,改一个依赖就要改一堆代码。有了DI,改一行配置就行。
嗯,课程到这里就结束了。希望这些内容能帮你少走弯路。如果你在实际项目中遇到什么问题,欢迎来交流。
核心要点回顾:
- 按功能分包,别按技术层分包
- Hilt用注解,Koin用DSL,选你团队习惯的
- Repository做数据源切换,ViewModel管状态
- 测试时手动注入,别依赖DI框架