Hilt 進階用法、Custom Components 與 Multi-binding 模式
當問題涉及 Hilt 進階模式、scope 設計、跨模組存取、測試替換時載入。Dagger 2 → Hilt 的「遷移步驟」由 @tech_stack_migration 為唯一權威來源,本 skill 不重複(去重);本 skill 處理 Hilt 已就位後的進階使用。
@tech_stack_migration@project_bootstrapping@platform_modernization_2026@TestInstallIn 怎麼配?」@EntryPoint 而非全域 singleton;遠離 applicationContext.applicationContext as App。@TestInstallIn(replaces = [RealModule::class]) + HiltAndroidRule,可重組整個 graph。| 項目 | 推薦做法 |
|---|---|
| 編譯器 | KSP 2.x(不用 kapt) |
| Hilt 版本 | 2.52+ |
| Compose ViewModel | hiltViewModel<VM, Factory> { factory.create(...) } |
| Worker | @HiltWorker + HiltWorkerFactory |
| 第三方無法注入 | @EntryPoint from non-Hilt class |
| 測試替換 | @TestInstallIn(replaces = [...]) |
| Scope 過度設計風險 | 能用 @Singleton 解決就不要建 Custom Component |
目標: <例:登入後的服務綁 User Session>
Scope 對應:
- SingletonComponent: Network, Database, Analytics
- ActivityRetainedComponent: NavigationRouter
- ViewModelComponent: 表單 state holder
- UserSessionComponent (custom): UserPreferences, FeatureFlags
Module 結構:
- core:network/di/NetworkModule
- core:database/di/DatabaseModule
- feature:checkout/di/CheckoutBindModule
Qualifier: @IoDispatcher, @AuthenticatedClient
測試替換: @TestInstallIn 在 androidTest/
驗收: Quick Checklist
@HiltViewModel(assistedFactory = DetailViewModel.Factory::class)
class DetailViewModel @AssistedInject constructor(
@Assisted private val productId: String,
@Assisted savedStateHandle: SavedStateHandle,
private val repository: ProductRepository,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(productId: String, savedStateHandle: SavedStateHandle): DetailViewModel
}
}
@Composable
fun DetailScreen(productId: String) {
val viewModel = hiltViewModel<DetailViewModel, DetailViewModel.Factory> { factory ->
factory.create(productId, createSavedStateHandle())
}
}
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val repository: Repository,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = runCatching { repository.sync() }
.map { Result.success() }
.getOrElse { Result.retry() }
}
| 情境 | 建議 |
|---|---|
| 同 App 全程都需要的服務 | @Singleton 即可 |
| 跨多個 Activity,但 Activity 重建不重生 | @ActivityRetainedScoped |
| 單一 Activity / Fragment / ViewModel 範圍 | 內建對應 scope |
| 登入後才存在、登出時應釋放的服務 | Custom Component(推薦) |
| 多租戶 app 切換 tenant 時釋放 | Custom Component |
| 「我覺得這樣比較乾淨」但無生命週期理由 | ❌ 不要做 |
@Scope
@Retention(AnnotationRetention.RUNTIME)
annotation class UserSessionScope
@DefineComponent(parent = SingletonComponent::class)
@UserSessionScope
interface UserSessionComponent
@DefineComponent.Builder
interface UserSessionComponentBuilder {
fun setUser(@BindsInstance user: User): UserSessionComponentBuilder
fun build(): UserSessionComponent
}
@EntryPoint
@InstallIn(UserSessionComponent::class)
interface UserSessionEntryPoint {
fun userPreferences(): UserPreferences
fun featureFlagSource(): FeatureFlagSource
}
@Singleton
class UserSessionManager @Inject constructor(
private val componentBuilder: Provider<UserSessionComponentBuilder>,
) {
private var component: UserSessionComponent? = null
fun login(user: User) {
component = componentBuilder.get().setUser(user).build()
}
fun logout() {
component = null
}
inline fun <reified T> entryPoint(extract: UserSessionEntryPoint.() -> T): T {
val c = checkNotNull(component) { "Not logged in" }
return EntryPoints.get(c, UserSessionEntryPoint::class.java).extract()
}
}
interface PaymentProcessor { fun supports(type: String): Boolean; fun process(amount: Long): Result<Unit> }
@Module
@InstallIn(SingletonComponent::class)
abstract class PaymentBindingModule {
@Binds @IntoSet abstract fun creditCard(impl: CreditCardProcessor): PaymentProcessor
@Binds @IntoSet abstract fun paypal(impl: PayPalProcessor): PaymentProcessor
}
class PaymentService @Inject constructor(
private val processors: Set<@JvmSuppressWildcards PaymentProcessor>,
) {
fun process(type: String, amount: Long): Result<Unit> =
processors.first { it.supports(type) }.process(amount)
}
@MapKey annotation class PaymentTypeKey(val value: PaymentType)
@Module
@InstallIn(SingletonComponent::class)
abstract class PaymentMapModule {
@Binds @IntoMap @PaymentTypeKey(PaymentType.CREDIT_CARD)
abstract fun bindCreditCard(impl: CreditCardProcessor): PaymentProcessor
}
class PaymentService @Inject constructor(
private val processors: Map<PaymentType, @JvmSuppressWildcards Provider<PaymentProcessor>>,
) {
// Provider 延遲建構,未使用的 processor 不會被生成
fun process(type: PaymentType, amount: Long) = processors.getValue(type).get().process(amount)
}
當 feature:a 想用 feature:b 的服務,不要直接 import。改放在 core:domain 介面 + feature:b 實作 + feature:a 透過 EntryPoint 取。
@EntryPoint
@InstallIn(SingletonComponent::class)
interface AnalyticsEntryPoint {
fun analytics(): Analytics
}
// 非 Hilt class(如某 Util object 或第三方 callback)需要服務時
class LegacyCallback(private val context: Context) {
private val analytics by lazy {
EntryPointAccessors
.fromApplication(context, AnalyticsEntryPoint::class.java)
.analytics()
}
}
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class DefaultDispatcher
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class MainDispatcher
@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {
@Provides @IoDispatcher fun io(): CoroutineDispatcher = Dispatchers.IO
@Provides @DefaultDispatcher fun default(): CoroutineDispatcher = Dispatchers.Default
@Provides @MainDispatcher fun main(): CoroutineDispatcher = Dispatchers.Main.immediate
}
注入:
class Repository @Inject constructor(
@IoDispatcher private val io: CoroutineDispatcher,
private val api: Api,
) {
suspend fun load() = withContext(io) { api.fetch() }
}
// androidTest/.../FakeRepositoryModule.kt
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class],
)
abstract class FakeRepositoryModule {
@Binds abstract fun fake(impl: FakeUserRepository): UserRepository
}
@HiltAndroidTest
class LoginScreenTest {
@get:Rule val hiltRule = HiltAndroidRule(this)
@Inject lateinit var repository: UserRepository
@Before fun setup() = hiltRule.inject()
}
單一測試需更精細替換時用 @UninstallModules(RealModule::class) + @BindValue:
@UninstallModules(RepositoryModule::class)
@HiltAndroidTest
class CheckoutFlowTest {
@BindValue val userRepository: UserRepository = mockk(relaxed = true)
}
core/network/di/NetworkModule.kt # OkHttp, Retrofit base
core/network/di/AuthInterceptorModule.kt
core/database/di/DatabaseModule.kt # Room
core/data/di/RepositoryModule.kt # Binds Repository impls
core/domain/di/UseCaseModule.kt # 通常不需要,UseCase 用 constructor inject
feature/checkout/di/CheckoutModule.kt # feature-local providers
app/di/AppModule.kt # Application-level
API/Impl 分離:
core:network:api -> 介面與 DTO
core:network:impl -> Retrofit 實作 + Hilt module(@InstallIn(SingletonComponent::class))
feature:* 只 implementation core:network:api
app implementation core:network:impl
@tech_stack_migration:Dagger 2 → Hilt 遷移的唯一權威。本 skill 不重複,遇到遷移問題請載入它。@project_bootstrapping:Convention Plugin 套 Hilt + KSP;本 skill 是 Hilt 已就位後的進階。@platform_modernization_2026:KSP 版本與 Kotlin 版本的對齊。@kotlin_multiplatform:KMP 端 commonMain 的 expect/actual + 平台 DI 接合。@testing_legacy_strategies:Hilt test rule 在舊代碼覆寫的策略。@AssistedInject + hiltViewModel<VM, Factory>@EntryPoint 或 core 介面,禁止直接 import 對方 impl@IoDispatcher 等 qualifier 注入,不直接寫 Dispatchers.IO@TestInstallIn 替換整個 module;單一 case 用 @UninstallModules + @BindValueLazy<T> 或 Provider<T> 打破Provider<T> 避免不必要的初始化