第14章:依赖注入实战

使用 Hilt 框架管理依赖关系,构建松耦合、易测试的 Android 应用架构

🪶

本章导师:诸葛亮

核心方法论:架构如棋局,走一步看三步

「善战者,求之于势,不责于人。软件架构亦是如此——好的依赖注入如同排兵布阵,让每个模块各司其职、互不牵制。当你需要替换一员将领时,整个阵法依然稳如磐石。今日我们便来学习如何用 Hilt 构建一套攻守兼备的架构体系。」

14.1 什么是依赖注入

行军打仗,主帅需要粮草官筹备军粮、斥候营打探敌情、工匠营修筑器械。在软件世界中也是如此:一个类往往需要借助其他类才能完成使命。譬如 UserViewModel 需要 UserRepository 提供数据,而 UserRepository 又需要 ApiService 发起网络请求。这些被依赖的对象,便是你麾下的各路将领。

紧耦合的问题

最鲁莽的做法,便是让每位将领自行招兵买马、各自为政——在类的内部直接创建依赖对象:

// 反面示例:紧耦合
class UserRepository {
    // 直接在内部创建依赖 —— 紧耦合!
    private val apiService = ApiService()
    private val database = AppDatabase.create()

    suspend fun getUser(id: Int): User {
        return apiService.fetchUser(id)
    }
}

class UserViewModel {
    // 又一次直接创建依赖
    private val repository = UserRepository()
}

此等做法,如同让前锋将领自己决定何时出击、粮草官自行决定给谁送粮——全军必乱。具体来说,有三大致命隐患:

构造函数注入

依赖注入(Dependency Injection, DI)的精妙之处,与兵法中"令行禁止、统一调度"的思想异曲同工:不要让各部自行筹备物资,而是由中军帐统一调配。落到代码上,最经典的手法便是通过构造函数传入依赖:

// 正面示例:构造函数注入
class UserRepository(
    private val apiService: ApiService,
    private val userDao: UserDao
) {
    suspend fun getUser(id: Int): User {
        val cached = userDao.findById(id)
        if (cached != null) return cached

        val remote = apiService.fetchUser(id)
        userDao.insert(remote)
        return remote
    }
}

class UserViewModel(
    private val repository: UserRepository
) {
    fun loadUser(id: Int) { /* ... */ }
}

如此一来,UserRepository 不再关心 ApiService 是真正的网络客户端还是演习用的替身——它只需要一位能听令行事的将领即可。这正是依赖注入带来的战略优势:

DI 一句话总结

依赖注入就是"别自己 new,让别人给你"。谁来调配?可以是手动指派(手动组装),也可以是中军帐自动分派(框架自动完成)。正所谓:善将者,不自操戈,而令三军协力。

14.2 手动依赖注入的痛点

构造函数注入的道理浅显易懂,那为何还需要框架来助阵?诸位且看——若由主帅一人亲自分配每一粒军粮、每一支箭矢,全军上下几万人的调度会变成何等景象:

// 在 Application 或某个入口处手动组装所有依赖
class AppContainer(private val context: Context) {

    // 1. 网络层
    private val okHttpClient = OkHttpClient.Builder()
        .addInterceptor(AuthInterceptor())
        .build()

    private val retrofit = Retrofit.Builder()
        .baseUrl("https://api.example.com/")
        .client(okHttpClient)
        .addConverterFactory(GsonConverterFactory.create())
        .build()

    val apiService: ApiService = retrofit.create(ApiService::class.java)

    // 2. 数据库层
    private val database = Room.databaseBuilder(
        context, AppDatabase::class.java, "app.db"
    ).build()

    val userDao: UserDao = database.userDao()
    val articleDao: ArticleDao = database.articleDao()

    // 3. Repository 层
    val userRepository = UserRepository(apiService, userDao)
    val articleRepository = ArticleRepository(apiService, articleDao)

    // 4. 更多的 Repository... 越来越多...
    // val commentRepository = CommentRepository(apiService, commentDao)
    // val settingsRepository = SettingsRepository(dataStore)
}

随着疆域扩张(应用规模增长),这种事必躬亲的手动调度暴露出愈来愈严重的弊端——犹如丞相事无巨细皆亲自过问,纵有经天纬地之才,也终有力不从心之日:

痛点表现
样板代码爆炸每增加一个类,都要在 Container 中手动添加创建代码
生命周期管理困难哪些对象该是单例?哪些该跟 Activity 生命周期绑定?全靠人工维护
依赖图不透明新成员加入项目,很难快速理解依赖关系
编译时安全性弱忘记初始化某个依赖?只有运行时才会崩溃

古语云:"善用人者为之下。"聪明的统帅不会亲手调度每一名士兵,而是建立一套完善的军令体系,让调度自动运转。这便是 Hilt 登场的时机——它用注解自动完成这些繁琐的"军需调配",并在编译期检查依赖图是否完整,如同出征前清点军需,确保无一遗漏。

14.3 Hilt 入门

Hilt 是 Google 官方推荐的 Android 依赖注入框架,犹如朝廷颁布的统一军令制度。它基于 Dagger 构建,但大幅简化了部署配置,专为 Android 开发量身打造。若将 Dagger 比作一部精密却繁复的兵书,那么 Hilt 便是提炼其精髓而成的实战手册——威力不减,上手更快。

Gradle 配置

万事开头,粮草先行。首先在项目级 build.gradle.kts 中添加 Hilt 插件,如同在全军上下颁布军令:

// 项目级 build.gradle.kts
plugins {
    id("com.google.dagger.hilt.android") version "2.51.1" apply false
}

然后在模块级 build.gradle.kts 中应用插件并添加依赖——这相当于在各营落实军令细则:

// 模块级 app/build.gradle.kts
plugins {
    id("com.google.dagger.hilt.android")
    id("com.google.devtools.ksp")
}

dependencies {
    implementation("com.google.dagger:hilt-android:2.51.1")
    ksp("com.google.dagger:hilt-android-compiler:2.51.1")

    // Hilt 与 Compose 集成
    implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
}

标记 Application 类

行军布阵,必先立中军帐。用 @HiltAndroidApp 标记你的 Application 类,Hilt 便会以此为帅帐,生成管理全局依赖的基础组件:

@HiltAndroidApp
class MyApp : Application()
别忘了 AndroidManifest.xml

需要在 AndroidManifest.xml<application> 标签中声明 android:name=".MyApp",否则 Hilt 无法正常初始化。此乃出征前的"点将令"——少了这一步,帅帐虽立却无人听令。

标记 Activity

中军帐已立,接下来要指定哪些营寨需要接受统一调度。在需要注入依赖的 Activity 上添加 @AndroidEntryPoint,如同为各营挂上令旗:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyAppTheme {
                AppNavigation()
            }
        }
    }
}

使用 @Inject 构造函数

对于你亲手编写的类,只需在构造函数上添加 @Inject 注解,便等同于向中军帐递交了一份"本部所需物资清单"——Hilt 自然知道该如何为你筹备:

// Hilt 知道如何创建这个类 —— 因为构造函数标记了 @Inject
class UserRepository @Inject constructor(
    private val apiService: ApiService,
    private val userDao: UserDao
) {
    suspend fun getUsers(): List<User> {
        return apiService.fetchUsers()
    }

    suspend fun getUserById(id: Int): User {
        return userDao.findById(id) ?: apiService.fetchUser(id)
    }
}

只要 ApiServiceUserDao 也能被 Hilt 创建(通过 @Inject 或 Module 提供),整条依赖链便自动串联——犹如军令自帅帐而下,层层传递,直达前锋,无需主帅亲自跑腿。

编译期安全

如果依赖图有缺失(比如 ApiService 没有告诉 Hilt 怎么创建),编译时就会报错,不会等到运行时才崩溃。此乃"未战而庙算胜者"——在出征前便将隐患一一排除,远胜于兵临城下才发现粮草不继。

14.4 Module 与 Provides

@Inject 构造函数只适用于你能修改源码的类——也就是你自己麾下的将士。然而战场上还需要借助盟军之力(第三方库),或者以接口定义的抽象角色来排兵布阵。对于以下情况,需要通过 Module 来向中军帐禀报"此部将如何调遣":

@Provides 提供实例

@Module 标记一个类(或 object),用 @InstallIn 指定它隶属于哪个军团(组件),用 @Provides 标记具体的"锻造工序":

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

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(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)
    }
}

注意 provideRetrofit 的参数 client: OkHttpClient —— Hilt 会自动从 provideOkHttpClient 的返回值中取用,整条军需供应链自动衔接。这便是"运筹帷幄之中"的精妙:你只需声明各部所需,调度之事自有帅帐安排。

@Binds 绑定接口

兵法云:"将在谋而不在勇。"当你以接口定义一个战略角色、以实现类担任具体人选时,用 @Binds@Provides 更为精简——如同一纸任命书,简洁明了:

// 定义接口
interface UserRepository {
    suspend fun getUsers(): List<User>
    suspend fun getUserById(id: Int): User
}

// 实现类 —— 注意 @Inject constructor
class UserRepositoryImpl @Inject constructor(
    private val apiService: ApiService,
    private val userDao: UserDao
) : UserRepository {

    override suspend fun getUsers(): List<User> {
        return apiService.fetchUsers()
    }

    override suspend fun getUserById(id: Int): User {
        return userDao.findById(id) ?: apiService.fetchUser(id)
    }
}

// 用 @Binds 告诉 Hilt:当需要 UserRepository 时,给 UserRepositoryImpl
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindUserRepository(
        impl: UserRepositoryImpl
    ): UserRepository
}

组件层级

@InstallIn 决定了模块安装在哪个组件中。组件如同军中的层级体系——从天子到将军到校尉,每一层级的令旗管辖范围不同,依赖的可见范围与生命周期也随之而定:

组件作用域注解生命周期
SingletonComponent@Singleton整个应用
ViewModelComponent@ViewModelScopedViewModel 存活期间
ActivityComponent@ActivityScopedActivity 存活期间
FragmentComponent@FragmentScopedFragment 存活期间
ServiceComponent@ServiceScopedService 存活期间
ActivityRetainedComponent@ActivityRetainedScoped跨配置变更的 Activity
@Binds vs @Provides 选择指南

如果只是将一位将领(接口)的职位指定给某人(实现类),用 @Binds(一纸调令,简洁高效)。如果需要亲自督造兵器(执行构建逻辑,如调用 Builder、配置参数),则用 @Provides(详细的锻造工序)。

14.5 作用域与生命周期

用兵之道,在于知进退、明存亡。作用域(Scope)之于 Hilt,便如同军令中对各部驻防期限的规定——它决定了一个依赖的实例会被复用多久。选错作用域,轻则浪费军资(内存),重则阵法崩溃(状态混乱)。

无作用域 = 每次新建

如果不添加任何作用域注解,Hilt 每次注入时都会创建一个全新的实例——如同每次出征都临时招募新兵,打完仗就地解散:

// 没有作用域注解 —— 每次注入都是新实例
class AnalyticsTracker @Inject constructor() {
    val id = UUID.randomUUID()

    fun trackEvent(name: String) {
        println("Tracker[$id] 记录事件: $name")
    }
}

// 如果两个不同的类都注入 AnalyticsTracker,
// 它们拿到的是不同的实例(不同的 id)

@Singleton — 全局唯一

标记 @Singleton 如同授予铁券丹书——此将从应用启动到终结,自始至终只此一人,全军共用:

@Singleton
class AnalyticsTracker @Inject constructor() {
    val id = UUID.randomUUID()

    fun trackEvent(name: String) {
        println("Tracker[$id] 记录事件: $name")
    }
}

// 现在所有注入点拿到的都是同一个实例(相同的 id)

@ViewModelScoped — 绑定 ViewModel

当多个依赖需要在同一个 ViewModel 中共享状态,但不同 ViewModel 之间需要独立编制时——如同一支偏师内部共享情报,但各偏师之间互不干涉:

@ViewModelScoped
class SavedStateHandler @Inject constructor() {
    private val stateMap = mutableMapOf<String, Any?>()

    fun save(key: String, value: Any?) {
        stateMap[key] = value
    }

    fun get(key: String): Any? = stateMap[key]
}

作用域对照表

作用域注解对应组件实例生命周期典型用途
无注解每次注入创建新实例轻量级无状态工具类
@SingletonSingletonComponent与 Application 同生命周期网络客户端、数据库、Repository
@ViewModelScopedViewModelComponent与 ViewModel 同生命周期需在 ViewModel 内共享的状态
@ActivityScopedActivityComponent与 Activity 同生命周期Activity 级别的 UI 状态管理
@ActivityRetainedScopedActivityRetainedComponent跨配置变更(如旋转屏幕)需要在配置变更后保留的数据
常见错误:作用域选择不当

将所有依赖都封为 @Singleton,是新手常犯的兵家大忌。这如同给每位士卒都授予世袭爵位,永远占据朝堂编制——结果便是内存中驻满了本不需要长存的对象,军资(内存)白白消耗。唯有真正需要全局共享的重器(如网络客户端、数据库实例)才应授此殊荣。

14.6 ViewModel 注入

在 Android 的战略架构中,ViewModel 如同前敌指挥所——上承中军帐(数据层)的战略情报,下达前线(UI 层)的作战指令。它是连接后方与前线的核心枢纽,Hilt 对此自然有专门的调度机制。

@HiltViewModel 基本用法

@HiltViewModel
class UserListViewModel @Inject constructor(
    private val userRepository: UserRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<UserListUiState>(
        UserListUiState.Loading
    )
    val uiState: StateFlow<UserListUiState> = _uiState.asStateFlow()

    init {
        loadUsers()
    }

    private fun loadUsers() {
        viewModelScope.launch {
            try {
                val users = userRepository.getUsers()
                _uiState.value = UserListUiState.Success(users)
            } catch (e: Exception) {
                _uiState.value = UserListUiState.Error(e.message ?: "未知错误")
            }
        }
    }

    fun refresh() {
        _uiState.value = UserListUiState.Loading
        loadUsers()
    }
}

sealed interface UserListUiState {
    data object Loading : UserListUiState
    data class Success(val users: List<User>) : UserListUiState
    data class Error(val message: String) : UserListUiState
}

在 Compose 中获取 ViewModel

使用 hiltViewModel() 在 Composable 函数中获取 Hilt 管理的 ViewModel——如同前线将领凭令牌即可调用后方资源,无需知晓调度的繁复细节:

@Composable
fun UserListScreen(
    viewModel: UserListViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    when (val state = uiState) {
        is UserListUiState.Loading -> {
            Box(
                modifier = Modifier.fillMaxSize(),
                contentAlignment = Alignment.Center
            ) {
                CircularProgressIndicator()
            }
        }
        is UserListUiState.Success -> {
            LazyColumn {
                items(state.users) { user ->
                    UserCard(user = user)
                }
            }
        }
        is UserListUiState.Error -> {
            ErrorContent(
                message = state.message,
                onRetry = viewModel::refresh
            )
        }
    }
}

完整的三层架构示例

下面展示从 DataSource 到 Repository 到 ViewModel 的完整依赖注入链——犹如从后方粮仓到中转站到前线营帐,军需补给一气呵成:

// 1. 数据源
class RemoteUserDataSource @Inject constructor(
    private val apiService: ApiService
) {
    suspend fun fetchUsers(): List<User> = apiService.getUsers()
    suspend fun fetchUser(id: Int): User = apiService.getUser(id)
}

class LocalUserDataSource @Inject constructor(
    private val userDao: UserDao
) {
    suspend fun getAll(): List<User> = userDao.getAll()
    suspend fun save(users: List<User>) = userDao.insertAll(users)
}

// 2. Repository —— 协调远程与本地数据源
class UserRepositoryImpl @Inject constructor(
    private val remote: RemoteUserDataSource,
    private val local: LocalUserDataSource
) : UserRepository {

    override suspend fun getUsers(): List<User> {
        return try {
            val users = remote.fetchUsers()
            local.save(users) // 缓存到本地
            users
        } catch (e: Exception) {
            local.getAll() // 网络失败时使用缓存
        }
    }

    override suspend fun getUserById(id: Int): User {
        return remote.fetchUser(id)
    }
}

// 3. ViewModel
@HiltViewModel
class UserListViewModel @Inject constructor(
    private val repository: UserRepository
) : ViewModel() {
    // ... 同上
}

Hilt 会自动将 ApiServiceUserDaoRemoteUserDataSourceLocalUserDataSourceUserRepositoryImpl 一层层串联起来。正所谓"善治者不亲细务"——你只需声明各部的依赖需求,统筹调配之事自有帅帐(Hilt)运筹。

14.7 实战:重构新闻应用

纸上谈兵终觉浅,绝知此事要躬行。还记得第 9 章中构建的新闻应用吗?彼时我们在 Activity 中手动创建一切依赖,如同行军打仗却没有参谋部,主帅一人包揽所有调度。今日便让我们以 Hilt 为帅帐,将这座旧营寨改造为一座攻守兼备的新城池。

重构前:手动创建依赖

// 重构前 —— 在 Activity 中手动组装一切
class NewsActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 手动创建网络层
        val client = OkHttpClient()
        val retrofit = Retrofit.Builder()
            .baseUrl("https://newsapi.example.com/")
            .client(client)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
        val newsApi = retrofit.create(NewsApi::class.java)

        // 手动创建数据库层
        val db = Room.databaseBuilder(
            this, NewsDatabase::class.java, "news.db"
        ).build()
        val newsDao = db.newsDao()

        // 手动创建 Repository
        val repository = NewsRepository(newsApi, newsDao)

        // 手动创建 ViewModel(还需要 ViewModelFactory...)
        // 这里省略了工厂类的样板代码
    }
}

审视此代码,便能看出旧阵的弊病:所有军需调度集中于一人(Activity),既不利于分兵把守,也无法灵活换将。现在,请随我按六步之法,完成这场架构的改天换地。

步骤一:创建 Application 类

立中军帐——这是整个阵法的根基:

@HiltAndroidApp
class NewsApplication : Application()

步骤二:创建 NetworkModule

设立"通信营"——统一管理网络层的兵器装备:

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

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .connectTimeout(15, TimeUnit.SECONDS)
            .readTimeout(15, TimeUnit.SECONDS)
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = HttpLoggingInterceptor.Level.BODY
            })
            .build()
    }

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

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

步骤三:创建 DatabaseModule

设立"辎重营"——管理数据存储的粮草辎重:

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

    @Provides
    @Singleton
    fun provideDatabase(
        @ApplicationContext context: Context
    ): NewsDatabase {
        return Room.databaseBuilder(
            context,
            NewsDatabase::class.java,
            "news.db"
        ).fallbackToDestructiveMigration()
         .build()
    }

    @Provides
    fun provideNewsDao(database: NewsDatabase): NewsDao {
        return database.newsDao()
    }
}
@ApplicationContext

Hilt 内置了 @ApplicationContext@ActivityContext 两枚令牌,你可以直接在参数中使用它们来获取对应的 Context,无需自行调配。此乃帅帐预备的"通行符节"——省去了各营自行请印的繁琐。

步骤四:定义 Repository 接口与实现

明确"参谋部"的职能与人选——接口定义战略方针,实现类负责具体执行:

interface NewsRepository {
    fun getNewsStream(): Flow<List<NewsArticle>>
    suspend fun refreshNews()
    suspend fun getArticleById(id: Long): NewsArticle?
}

class NewsRepositoryImpl @Inject constructor(
    private val newsApi: NewsApi,
    private val newsDao: NewsDao
) : NewsRepository {

    override fun getNewsStream(): Flow<List<NewsArticle>> {
        return newsDao.observeAll()
    }

    override suspend fun refreshNews() {
        val articles = newsApi.getTopHeadlines()
        newsDao.clearAndInsert(articles)
    }

    override suspend fun getArticleById(id: Long): NewsArticle? {
        return newsDao.findById(id)
    }
}

// 绑定接口到实现
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindNewsRepository(
        impl: NewsRepositoryImpl
    ): NewsRepository
}

步骤五:创建 ViewModel

任命"前敌指挥"——ViewModel 统揽战场态势,向前线传达作战方略:

@HiltViewModel
class NewsListViewModel @Inject constructor(
    private val repository: NewsRepository
) : ViewModel() {

    val newsArticles: StateFlow<NewsUiState> = repository
        .getNewsStream()
        .map { articles ->
            NewsUiState.Success(articles) as NewsUiState
        }
        .catch { e ->
            emit(NewsUiState.Error(e.message ?: "加载失败"))
        }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5000),
            initialValue = NewsUiState.Loading
        )

    private val _isRefreshing = MutableStateFlow(false)
    val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()

    init {
        refresh()
    }

    fun refresh() {
        viewModelScope.launch {
            _isRefreshing.value = true
            try {
                repository.refreshNews()
            } catch (e: Exception) {
                // 刷新失败时仍显示缓存数据
            } finally {
                _isRefreshing.value = false
            }
        }
    }
}

sealed interface NewsUiState {
    data object Loading : NewsUiState
    data class Success(val articles: List<NewsArticle>) : NewsUiState
    data class Error(val message: String) : NewsUiState
}

步骤六:连接 UI

前线将士(UI)凭令牌即可获取指挥官(ViewModel)的作战指令——一切调度浑然天成:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            NewsTheme {
                NewsListScreen()
            }
        }
    }
}

@Composable
fun NewsListScreen(
    viewModel: NewsListViewModel = hiltViewModel()
) {
    val uiState by viewModel.newsArticles.collectAsStateWithLifecycle()
    val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()

    PullToRefreshBox(
        isRefreshing = isRefreshing,
        onRefresh = viewModel::refresh
    ) {
        when (val state = uiState) {
            is NewsUiState.Loading -> {
                Box(
                    Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    CircularProgressIndicator()
                }
            }
            is NewsUiState.Success -> {
                LazyColumn(Modifier.fillMaxSize()) {
                    items(state.articles) { article ->
                        NewsCard(article = article)
                    }
                }
            }
            is NewsUiState.Error -> {
                Text(
                    text = state.message,
                    color = MaterialTheme.colorScheme.error,
                    modifier = Modifier.padding(16.dp)
                )
            }
        }
    }
}

重构后的依赖关系图

重构完成后,整个阵法层次分明、指挥通畅——依赖关系清晰透明,如同一幅精心绘制的行军地图:

/*
 * 依赖关系图(Hilt 自动管理)
 *
 * ┌──────────────────────────────────┐
 * │        SingletonComponent         │
 * │                                   │
 * │  OkHttpClient ──► Retrofit        │
 * │                      │            │
 * │                   NewsApi         │
 * │                      │            │
 * │  Context ──► NewsDatabase         │
 * │                  │                │
 * │               NewsDao             │
 * │                  │                │
 * │  NewsApi + NewsDao                │
 * │        │                          │
 * │   NewsRepositoryImpl              │
 * │    (binds to NewsRepository)      │
 * └──────────┬────────────────────────┘
 *            │
 * ┌──────────▼────────────────────────┐
 * │       ViewModelComponent          │
 * │                                   │
 * │  NewsRepository ──► NewsListVM    │
 * └───────────────────────────────────┘
 */

对比重构前后,高下立判——犹如散兵游勇与精锐之师之差:

方面重构前重构后(Hilt)
依赖创建Activity 中手动 new声明式注解自动完成
生命周期自己管理,容易出错Hilt 按作用域自动管理
可测试性难以替换依赖接口注入,轻松替换
编译安全运行时才发现问题缺少依赖编译就会报错
代码量大量样板代码集中在 Activity分散到各个 Module,各司其职

14.8 Hilt 最佳实践

善用兵者,不仅要知道如何排兵布阵,更要深谙"因地制宜、因时而变"的用兵之道。以下五条锦囊,是从无数实战中提炼出的用"Hilt"之法,望诸位牢记于心。

1. 优先使用构造函数注入

Hilt 也支持字段注入(@Inject lateinit var),但构造函数注入如同出征前便将军令写明——一目了然、不留后患:

// 推荐:构造函数注入
class UserRepository @Inject constructor(
    private val apiService: ApiService
)

// 不推荐:字段注入(仅在无法使用构造函数注入时使用,如 Activity)
class SomeClass {
    @Inject lateinit var apiService: ApiService
}

构造函数注入的优势:依赖关系如同军令状一般一目了然,对象创建后即全副武装、随时可战,测试时亦可轻松替换任何一位将领。

2. 能用 @Binds 就不用 @Provides

能一纸调令解决的事,何须大费周章地写锻造工序?

// 推荐:@Binds 更简洁、效率更高
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds
    abstract fun bindRepo(impl: NewsRepositoryImpl): NewsRepository
}

// 不推荐:@Provides 用于简单绑定显得冗余
@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
    @Provides
    fun provideRepo(impl: NewsRepositoryImpl): NewsRepository = impl
}

3. 保持 Module 职责单一

治军之要,在于分工明确。按功能领域拆分 Module,犹如行军设立前锋营、辎重营、斥候营,各司其职:

// 推荐:按职责拆分
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule { /* OkHttp, Retrofit, ApiService */ }

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule { /* Room, Dao */ }

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule { /* @Binds 绑定 */ }

// 不推荐:一个大杂烩 Module
// object AppModule { /* 几十个 @Provides 方法... */ }

4. 为测试做准备

古之善战者,先为不可胜,以待敌之可胜。在架构设计之初便预留测试的余地,方能在日后检验代码时从容不迫。Hilt 提供了 @TestInstallIn,可在"沙盘推演"(测试)中替换任何一位将领:

// 测试中替换 Repository 实现
@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [RepositoryModule::class]
)
abstract class FakeRepositoryModule {

    @Binds
    @Singleton
    abstract fun bindNewsRepository(
        fake: FakeNewsRepository
    ): NewsRepository
}

// 假的 Repository 实现,用于测试
class FakeNewsRepository @Inject constructor() : NewsRepository {

    private val fakeArticles = listOf(
        NewsArticle(
            id = 1,
            title = "测试新闻标题",
            content = "测试内容",
            publishedAt = "2024-01-01"
        )
    )

    override fun getNewsStream(): Flow<List<NewsArticle>> {
        return flowOf(fakeArticles)
    }

    override suspend fun refreshNews() { /* 测试中不做网络请求 */ }

    override suspend fun getArticleById(id: Long): NewsArticle? {
        return fakeArticles.find { it.id == id }
    }
}
下一章预告

第 15 章将深入讲解 Android 测试之道。届时我们会详细展示如何用 Hilt 的测试利器编写单元测试与 UI 测试——那将是对今日所学阵法的真正沙场检验。正所谓:练兵千日,用兵一时。

5. 常见问题速查

行军在外,难免遇到突发状况。以下是 Hilt 使用中常见的"军情急报"及其对策——可将此表裱起以备不时之需:

问题原因解决方案
编译错误:缺少绑定 某个依赖没有告诉 Hilt 如何创建 添加 @Inject constructor 或在 Module 中提供
运行时崩溃:Activity 未标记 忘记在 Activity 上加 @AndroidEntryPoint 在所有需要注入的 Activity/Fragment 上添加注解
ViewModel 注入失败 忘记添加 @HiltViewModel 确保 ViewModel 同时有 @HiltViewModel@Inject constructor
作用域不匹配 在低层级组件中引用高层级作用域的依赖 检查组件层级关系,调整 @InstallIn
Application 未初始化 AndroidManifest 没有声明自定义 Application 在 manifest 中添加 android:name

本章小结

本章我们从依赖注入的基本兵法出发,审视了手动调度的困局,最终习得了以 Hilt 为帅帐统筹全军的精妙阵法。正如用兵之道在于"以正合,以奇胜",好的架构既要有章法可循(框架规范),又要灵活变通(轻松替换实现)。核心要点回顾:

本章练习

练习 1:识别紧耦合 入门

以下代码中存在紧耦合的问题。请指出所有紧耦合之处,并将其重构为构造函数注入的方式。思考一下:若战场形势有变(需要切换实现),当前的阵型能否快速应变?

class OrderService {
    private val db = DatabaseHelper()
    private val emailSender = SmtpEmailSender()
    private val logger = FileLogger("/var/log/orders.log")

    fun placeOrder(order: Order) {
        db.save(order)
        emailSender.send(order.userEmail, "订单确认")
        logger.log("订单 ${order.id} 已创建")
    }
}
提示

思考三个问题:(1) 如果要在测试中避免真正发送邮件,该怎么办?(2) 如果要将日志从文件切换到远程服务,需要改多少代码?(3) 将 DatabaseHelperEmailSenderLogger 定义为接口,通过构造函数传入——正如兵法中"将帅任命由中军帐统一调度"。

参考答案
interface Database { fun save(order: Order) }
interface EmailSender { fun send(to: String, subject: String) }
interface Logger { fun log(message: String) }

class OrderService @Inject constructor(
    private val db: Database,
    private val emailSender: EmailSender,
    private val logger: Logger
) {
    fun placeOrder(order: Order) {
        db.save(order)
        emailSender.send(order.userEmail, "订单确认")
        logger.log("订单 ${order.id} 已创建")
    }
}

练习 2:编写 Hilt Module 进阶

假设你的应用需要接入一个天气 API。请为以下第三方库类编写完整的 Hilt Module,提供 OkHttpClientRetrofitWeatherApi 三个依赖,并确定合适的作用域。想想看:这三者是应该每次重新锻造,还是应当作为全军共用的重器?

// 已有的接口定义
interface WeatherApi {
    @GET("weather")
    suspend fun getWeather(
        @Query("city") city: String
    ): WeatherResponse
}
提示

需要使用 @Module + @InstallIn(SingletonComponent::class)。网络客户端和 Retrofit 实例都是重量级对象,应当标记 @Singleton——如同铸造城门铁锁,只需一把即可守护全城。注意 provideRetrofit 的参数中可以引用 provideOkHttpClient 的返回值,Hilt 会自动衔接。

参考答案
@Module
@InstallIn(SingletonComponent::class)
object WeatherNetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .connectTimeout(15, TimeUnit.SECONDS)
            .readTimeout(15, TimeUnit.SECONDS)
            .build()
    }

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

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

练习 3:选择正确的作用域 进阶

以下五个类分别应该使用哪种作用域注解?请为每个类选择最合理的作用域,并说明理由。记住:滥封 @Singleton 如同滥封侯爵——既浪费国库,又增加朝廷负担。

  1. RetrofitClient —— 全局网络客户端
  2. InputValidator —— 无状态的表单验证工具
  3. CartManager —— 管理当前购物车,需在同一个 ViewModel 内共享
  4. AppDatabase —— Room 数据库实例
  5. DateFormatter —— 将日期格式化为字符串的无状态工具
提示

判断依据有三:(1) 是否有状态?无状态的工具类不需要作用域。(2) 需要全局共享吗?重量级的网络客户端和数据库应全军共用。(3) 只需在某个范围内共享?用对应的范围作用域。正如治军,"赏罚分明"方能令行禁止。

参考答案

1. RetrofitClient@Singleton:全军粮道之重器,只需一条,全局共享。

2. InputValidator → 无注解:无状态的轻骑斥候,随用随遣,无需常驻编制。

3. CartManager@ViewModelScoped:购物车如同一支偏师的军需官,在此偏师内共享,不同偏师各有各的。

4. AppDatabase@Singleton:全军辎重总库,自然只需一座,从开营到收兵始终存在。

5. DateFormatter → 无注解:无状态的笔墨小吏,需要时即可招来使用,不占编制。

练习 4:@Binds 实战 进阶

为以下接口和实现类编写 Hilt 的 @Binds 模块。同时编写一个 FakeAuthRepository 用于测试,以及对应的 @TestInstallIn 模块。想想看:为何在架构设计之初就要为"沙盘推演"(测试)预留通道?

interface AuthRepository {
    suspend fun login(email: String, password: String): AuthResult
    suspend fun logout()
    fun isLoggedIn(): Boolean
}

class AuthRepositoryImpl @Inject constructor(
    private val apiService: AuthApi,
    private val tokenStore: TokenStore
) : AuthRepository {
    // ... 省略实现
}
提示

正式模块用 @Module + @InstallIn + @Binds。测试替换模块用 @TestInstallIn,需指定 replaces 参数。FakeAuthRepository 无需真实的网络请求,内部用简单的状态变量模拟即可——沙盘上的木偶,不需要真会打仗。

参考答案
// 正式绑定模块
@Module
@InstallIn(SingletonComponent::class)
abstract class AuthModule {
    @Binds
    @Singleton
    abstract fun bindAuthRepository(
        impl: AuthRepositoryImpl
    ): AuthRepository
}

// 测试用的假实现
class FakeAuthRepository @Inject constructor() : AuthRepository {
    private var loggedIn = false

    override suspend fun login(
        email: String, password: String
    ): AuthResult {
        loggedIn = true
        return AuthResult.Success("fake-token")
    }

    override suspend fun logout() { loggedIn = false }
    override fun isLoggedIn(): Boolean = loggedIn
}

// 测试替换模块
@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [AuthModule::class]
)
abstract class FakeAuthModule {
    @Binds
    @Singleton
    abstract fun bindAuthRepository(
        fake: FakeAuthRepository
    ): AuthRepository
}

练习 5:全局架构设计 挑战

你正在设计一个电商应用的完整架构。应用包含以下功能模块:用户认证、商品浏览、购物车、订单管理。请完成以下任务:

  1. 画出完整的依赖关系图(使用文本 ASCII 格式),标明各组件所属的 Hilt 组件层级。
  2. 为每个 Module 选择合适的 @InstallIn 组件。
  3. 编写 NetworkModuleRepositoryModule(包含所有四个 Repository 的 @Binds 绑定),以及一个 CartViewModel

此乃统帅全局的大考——你需要像谋划一场战役一样,纵观全局、合理部署,使各营既能独立作战,又能协同配合。

提示

思路如下:(1) 网络客户端和数据库是全军共用的基础设施,放入 SingletonComponent。(2) 四个 Repository 各管一域(认证、商品、购物车、订单),接口与实现分离,用 @Binds 绑定。(3) CartViewModel 需要注入 CartRepositoryUserRepository(需要知道当前用户)。走一步看三步——考虑未来如果要加入"推荐系统"模块,你的架构是否能轻松扩展?

参考答案
// 依赖关系图:
// ┌─────────── SingletonComponent ──────────────┐
// │  OkHttpClient → Retrofit → ShopApi          │
// │  Context → AppDatabase → [各 Dao]            │
// │  AuthRepositoryImpl → (binds) AuthRepository │
// │  ProductRepoImpl → (binds) ProductRepository │
// │  CartRepoImpl → (binds) CartRepository       │
// │  OrderRepoImpl → (binds) OrderRepository     │
// └──────────────────┬──────────────────────────┘
//                    │
// ┌──────────────────▼──────────────────────────┐
// │          ViewModelComponent                  │
// │  CartRepository + UserRepo → CartViewModel   │
// │  ProductRepository → ProductListViewModel    │
// │  OrderRepository → OrderViewModel            │
// └──────────────────────────────────────────────┘

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://shop.example.com/api/")
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

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

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds @Singleton
    abstract fun bindAuth(impl: AuthRepositoryImpl): AuthRepository

    @Binds @Singleton
    abstract fun bindProduct(impl: ProductRepositoryImpl): ProductRepository

    @Binds @Singleton
    abstract fun bindCart(impl: CartRepositoryImpl): CartRepository

    @Binds @Singleton
    abstract fun bindOrder(impl: OrderRepositoryImpl): OrderRepository
}

@HiltViewModel
class CartViewModel @Inject constructor(
    private val cartRepository: CartRepository,
    private val authRepository: AuthRepository
) : ViewModel() {

    val cartItems = cartRepository.getCartStream()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())

    fun checkout() {
        viewModelScope.launch {
            if (authRepository.isLoggedIn()) {
                cartRepository.submitOrder()
            }
        }
    }
}
« 上一章:Compose 动画与副作用 目录 下一章:测试入门 »