使用 Hilt 框架管理依赖关系,构建松耦合、易测试的 Android 应用架构
核心方法论:架构如棋局,走一步看三步
「善战者,求之于势,不责于人。软件架构亦是如此——好的依赖注入如同排兵布阵,让每个模块各司其职、互不牵制。当你需要替换一员将领时,整个阵法依然稳如磐石。今日我们便来学习如何用 Hilt 构建一套攻守兼备的架构体系。」
行军打仗,主帅需要粮草官筹备军粮、斥候营打探敌情、工匠营修筑器械。在软件世界中也是如此:一个类往往需要借助其他类才能完成使命。譬如 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()
}
此等做法,如同让前锋将领自己决定何时出击、粮草官自行决定给谁送粮——全军必乱。具体来说,有三大致命隐患:
UserRepository 的源码,牵一发而动全身。UserRepository 与同一个 ApiService 实现绑死,如同一员武将只会操练一种兵器,无法适应变局。UserViewModel 都连带生出一整棵依赖树,如同每到一处都重建营寨,既浪费资源又无法共享军资。依赖注入(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 是真正的网络客户端还是演习用的替身——它只需要一位能听令行事的将领即可。这正是依赖注入带来的战略优势:
依赖注入就是"别自己 new,让别人给你"。谁来调配?可以是手动指派(手动组装),也可以是中军帐自动分派(框架自动完成)。正所谓:善将者,不自操戈,而令三军协力。
构造函数注入的道理浅显易懂,那为何还需要框架来助阵?诸位且看——若由主帅一人亲自分配每一粒军粮、每一支箭矢,全军上下几万人的调度会变成何等景象:
// 在 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 登场的时机——它用注解自动完成这些繁琐的"军需调配",并在编译期检查依赖图是否完整,如同出征前清点军需,确保无一遗漏。
Hilt 是 Google 官方推荐的 Android 依赖注入框架,犹如朝廷颁布的统一军令制度。它基于 Dagger 构建,但大幅简化了部署配置,专为 Android 开发量身打造。若将 Dagger 比作一部精密却繁复的兵书,那么 Hilt 便是提炼其精髓而成的实战手册——威力不减,上手更快。
万事开头,粮草先行。首先在项目级 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")
}
行军布阵,必先立中军帐。用 @HiltAndroidApp 标记你的 Application 类,Hilt 便会以此为帅帐,生成管理全局依赖的基础组件:
@HiltAndroidApp
class MyApp : Application()
需要在 AndroidManifest.xml 的 <application> 标签中声明 android:name=".MyApp",否则 Hilt 无法正常初始化。此乃出征前的"点将令"——少了这一步,帅帐虽立却无人听令。
中军帐已立,接下来要指定哪些营寨需要接受统一调度。在需要注入依赖的 Activity 上添加 @AndroidEntryPoint,如同为各营挂上令旗:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
AppNavigation()
}
}
}
}
对于你亲手编写的类,只需在构造函数上添加 @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)
}
}
只要 ApiService 和 UserDao 也能被 Hilt 创建(通过 @Inject 或 Module 提供),整条依赖链便自动串联——犹如军令自帅帐而下,层层传递,直达前锋,无需主帅亲自跑腿。
如果依赖图有缺失(比如 ApiService 没有告诉 Hilt 怎么创建),编译时就会报错,不会等到运行时才崩溃。此乃"未战而庙算胜者"——在出征前便将隐患一一排除,远胜于兵临城下才发现粮草不继。
@Inject 构造函数只适用于你能修改源码的类——也就是你自己麾下的将士。然而战场上还需要借助盟军之力(第三方库),或者以接口定义的抽象角色来排兵布阵。对于以下情况,需要通过 Module 来向中军帐禀报"此部将如何调遣":
用 @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 比 @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 | @ViewModelScoped | ViewModel 存活期间 |
ActivityComponent | @ActivityScoped | Activity 存活期间 |
FragmentComponent | @FragmentScoped | Fragment 存活期间 |
ServiceComponent | @ServiceScoped | Service 存活期间 |
ActivityRetainedComponent | @ActivityRetainedScoped | 跨配置变更的 Activity |
如果只是将一位将领(接口)的职位指定给某人(实现类),用 @Binds(一纸调令,简洁高效)。如果需要亲自督造兵器(执行构建逻辑,如调用 Builder、配置参数),则用 @Provides(详细的锻造工序)。
用兵之道,在于知进退、明存亡。作用域(Scope)之于 Hilt,便如同军令中对各部驻防期限的规定——它决定了一个依赖的实例会被复用多久。选错作用域,轻则浪费军资(内存),重则阵法崩溃(状态混乱)。
如果不添加任何作用域注解,Hilt 每次注入时都会创建一个全新的实例——如同每次出征都临时招募新兵,打完仗就地解散:
// 没有作用域注解 —— 每次注入都是新实例
class AnalyticsTracker @Inject constructor() {
val id = UUID.randomUUID()
fun trackEvent(name: String) {
println("Tracker[$id] 记录事件: $name")
}
}
// 如果两个不同的类都注入 AnalyticsTracker,
// 它们拿到的是不同的实例(不同的 id)
标记 @Singleton 如同授予铁券丹书——此将从应用启动到终结,自始至终只此一人,全军共用:
@Singleton
class AnalyticsTracker @Inject constructor() {
val id = UUID.randomUUID()
fun trackEvent(name: String) {
println("Tracker[$id] 记录事件: $name")
}
}
// 现在所有注入点拿到的都是同一个实例(相同的 id)
当多个依赖需要在同一个 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]
}
| 作用域注解 | 对应组件 | 实例生命周期 | 典型用途 |
|---|---|---|---|
| 无注解 | — | 每次注入创建新实例 | 轻量级无状态工具类 |
@Singleton | SingletonComponent | 与 Application 同生命周期 | 网络客户端、数据库、Repository |
@ViewModelScoped | ViewModelComponent | 与 ViewModel 同生命周期 | 需在 ViewModel 内共享的状态 |
@ActivityScoped | ActivityComponent | 与 Activity 同生命周期 | Activity 级别的 UI 状态管理 |
@ActivityRetainedScoped | ActivityRetainedComponent | 跨配置变更(如旋转屏幕) | 需要在配置变更后保留的数据 |
将所有依赖都封为 @Singleton,是新手常犯的兵家大忌。这如同给每位士卒都授予世袭爵位,永远占据朝堂编制——结果便是内存中驻满了本不需要长存的对象,军资(内存)白白消耗。唯有真正需要全局共享的重器(如网络客户端、数据库实例)才应授此殊荣。
在 Android 的战略架构中,ViewModel 如同前敌指挥所——上承中军帐(数据层)的战略情报,下达前线(UI 层)的作战指令。它是连接后方与前线的核心枢纽,Hilt 对此自然有专门的调度机制。
@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
}
使用 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 会自动将 ApiService、UserDao、RemoteUserDataSource、LocalUserDataSource、UserRepositoryImpl 一层层串联起来。正所谓"善治者不亲细务"——你只需声明各部的依赖需求,统筹调配之事自有帅帐(Hilt)运筹。
纸上谈兵终觉浅,绝知此事要躬行。还记得第 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),既不利于分兵把守,也无法灵活换将。现在,请随我按六步之法,完成这场架构的改天换地。
立中军帐——这是整个阵法的根基:
@HiltAndroidApp
class NewsApplication : Application()
设立"通信营"——统一管理网络层的兵器装备:
@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)
}
}
设立"辎重营"——管理数据存储的粮草辎重:
@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()
}
}
Hilt 内置了 @ApplicationContext 和 @ActivityContext 两枚令牌,你可以直接在参数中使用它们来获取对应的 Context,无需自行调配。此乃帅帐预备的"通行符节"——省去了各营自行请印的繁琐。
明确"参谋部"的职能与人选——接口定义战略方针,实现类负责具体执行:
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 统揽战场态势,向前线传达作战方略:
@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)凭令牌即可获取指挥官(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,各司其职 |
善用兵者,不仅要知道如何排兵布阵,更要深谙"因地制宜、因时而变"的用兵之道。以下五条锦囊,是从无数实战中提炼出的用"Hilt"之法,望诸位牢记于心。
Hilt 也支持字段注入(@Inject lateinit var),但构造函数注入如同出征前便将军令写明——一目了然、不留后患:
// 推荐:构造函数注入
class UserRepository @Inject constructor(
private val apiService: ApiService
)
// 不推荐:字段注入(仅在无法使用构造函数注入时使用,如 Activity)
class SomeClass {
@Inject lateinit var apiService: ApiService
}
构造函数注入的优势:依赖关系如同军令状一般一目了然,对象创建后即全副武装、随时可战,测试时亦可轻松替换任何一位将领。
能一纸调令解决的事,何须大费周章地写锻造工序?
// 推荐:@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
}
治军之要,在于分工明确。按功能领域拆分 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 方法... */ }
古之善战者,先为不可胜,以待敌之可胜。在架构设计之初便预留测试的余地,方能在日后检验代码时从容不迫。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 测试——那将是对今日所学阵法的真正沙场检验。正所谓:练兵千日,用兵一时。
行军在外,难免遇到突发状况。以下是 Hilt 使用中常见的"军情急报"及其对策——可将此表裱起以备不时之需:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 编译错误:缺少绑定 | 某个依赖没有告诉 Hilt 如何创建 | 添加 @Inject constructor 或在 Module 中提供 |
| 运行时崩溃:Activity 未标记 | 忘记在 Activity 上加 @AndroidEntryPoint |
在所有需要注入的 Activity/Fragment 上添加注解 |
| ViewModel 注入失败 | 忘记添加 @HiltViewModel |
确保 ViewModel 同时有 @HiltViewModel 和 @Inject constructor |
| 作用域不匹配 | 在低层级组件中引用高层级作用域的依赖 | 检查组件层级关系,调整 @InstallIn |
| Application 未初始化 | AndroidManifest 没有声明自定义 Application | 在 manifest 中添加 android:name |
本章我们从依赖注入的基本兵法出发,审视了手动调度的困局,最终习得了以 Hilt 为帅帐统筹全军的精妙阵法。正如用兵之道在于"以正合,以奇胜",好的架构既要有章法可循(框架规范),又要灵活变通(轻松替换实现)。核心要点回顾:
@HiltAndroidApp(立帅帐)、@AndroidEntryPoint(挂令旗)、@Inject(递物资清单)是三个最基础的注解。@Module + @Provides/@Binds 用于调配第三方盟军或以接口定义的抽象角色。@Singleton、@ViewModelScoped 等)如同规定各部驻防期限,不可滥封。@HiltViewModel 配合 hiltViewModel() 在 Compose 中实现前线与指挥部的无缝衔接。以下代码中存在紧耦合的问题。请指出所有紧耦合之处,并将其重构为构造函数注入的方式。思考一下:若战场形势有变(需要切换实现),当前的阵型能否快速应变?
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) 将 DatabaseHelper、EmailSender、Logger 定义为接口,通过构造函数传入——正如兵法中"将帅任命由中军帐统一调度"。
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} 已创建")
}
}假设你的应用需要接入一个天气 API。请为以下第三方库类编写完整的 Hilt Module,提供 OkHttpClient、Retrofit 和 WeatherApi 三个依赖,并确定合适的作用域。想想看:这三者是应该每次重新锻造,还是应当作为全军共用的重器?
// 已有的接口定义
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)
}
}以下五个类分别应该使用哪种作用域注解?请为每个类选择最合理的作用域,并说明理由。记住:滥封 @Singleton 如同滥封侯爵——既浪费国库,又增加朝廷负担。
RetrofitClient —— 全局网络客户端InputValidator —— 无状态的表单验证工具CartManager —— 管理当前购物车,需在同一个 ViewModel 内共享AppDatabase —— Room 数据库实例DateFormatter —— 将日期格式化为字符串的无状态工具判断依据有三:(1) 是否有状态?无状态的工具类不需要作用域。(2) 需要全局共享吗?重量级的网络客户端和数据库应全军共用。(3) 只需在某个范围内共享?用对应的范围作用域。正如治军,"赏罚分明"方能令行禁止。
1. RetrofitClient → @Singleton:全军粮道之重器,只需一条,全局共享。
2. InputValidator → 无注解:无状态的轻骑斥候,随用随遣,无需常驻编制。
3. CartManager → @ViewModelScoped:购物车如同一支偏师的军需官,在此偏师内共享,不同偏师各有各的。
4. AppDatabase → @Singleton:全军辎重总库,自然只需一座,从开营到收兵始终存在。
5. DateFormatter → 无注解:无状态的笔墨小吏,需要时即可招来使用,不占编制。
为以下接口和实现类编写 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
}你正在设计一个电商应用的完整架构。应用包含以下功能模块:用户认证、商品浏览、购物车、订单管理。请完成以下任务:
@InstallIn 组件。NetworkModule、RepositoryModule(包含所有四个 Repository 的 @Binds 绑定),以及一个 CartViewModel。此乃统帅全局的大考——你需要像谋划一场战役一样,纵观全局、合理部署,使各营既能独立作战,又能协同配合。
思路如下:(1) 网络客户端和数据库是全军共用的基础设施,放入 SingletonComponent。(2) 四个 Repository 各管一域(认证、商品、购物车、订单),接口与实现分离,用 @Binds 绑定。(3) CartViewModel 需要注入 CartRepository 和 UserRepository(需要知道当前用户)。走一步看三步——考虑未来如果要加入"推荐系统"模块,你的架构是否能轻松扩展?
// 依赖关系图:
// ┌─────────── 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()
}
}
}
}