第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层 (Activity/Fragment) ViewModel Repository Remote DataSource (Retrofit) Local DataSource (Room) DI容器 (Hilt/Koin) 观察LiveData/Flow 调用方法 网络请求 缓存读写 注入依赖 注入依赖 注入依赖

数据流向是单向的: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框架
公众号:蓝海资料掘金营,微信deep3321