第7章:Hilt与ViewModel:@HiltViewModel、ViewModel注入的最佳实践
ViewModel 的依赖注入,可以说是 Hilt 在日常开发中最常用的场景之一。我记得刚接触 DI 那会儿,最头疼的就是 ViewModel 里要拿个 Repository,还得自己写个 ViewModelFactory,代码又臭又长。后来 Hilt 出了 @HiltViewModel,这问题才算真正解决。
说白了,Hilt 帮我们把 ViewModel 的创建过程接管了。你不需要再手动写 Factory,也不需要操心 ViewModel 的生命周期。但这里头有几个坑,我踩过,今天一并说清楚。
7.1 @HiltViewModel 注解:到底解决了什么问题?
先看一个最直观的对比。没有 Hilt 的时候,你要这样写:
// 传统方式:自己写 Factory
class MyViewModel(
private val repo: MyRepository,
private val analytics: AnalyticsService
) : ViewModel() {
class Factory(
private val repo: MyRepository,
private val analytics: AnalyticsService
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return MyViewModel(repo, analytics) as T
}
}
}
// 在 Activity 里:
val factory = MyViewModel.Factory(repo, analytics)
val vm = ViewModelProvider(this, factory).get(MyViewModel::class.java)
嗯,你想想看,每加一个依赖,Factory 就得改一次。项目大了以后,光维护这些 Factory 就能把人逼疯。
用 @HiltViewModel 之后:
@HiltViewModel
class MyViewModel @Inject constructor(
private val repo: MyRepository,
private val analytics: AnalyticsService
) : ViewModel() {
// 直接写业务逻辑,不用管 Factory
}
在 Activity 或 Fragment 里,直接用 by viewModels() 或者 hiltViewModel() 就能拿到实例。Hilt 在背后自动生成了 Factory,并且把依赖链都处理好了。
核心要点: @HiltViewModel 注解 + @Inject constructor,这两个缺一不可。少了任何一个,Hilt 都不会帮你自动创建 ViewModel。
7.2 ViewModel 注入的最佳实践
光会用注解还不够,实际项目里怎么组织代码,怎么避免踩坑,这才是关键。我把自己这几年总结的经验列出来。
7.2.1 依赖粒度:别把整个 UseCase 塞进去
我在项目中遇到过这样的代码:
// ❌ 反面案例
@HiltViewModel
class OrderViewModel @Inject constructor(
private val orderUseCase: OrderUseCase // 这个 UseCase 里包含了十几个方法
) : ViewModel()
这样做的问题在于:ViewModel 的依赖变得很重。你只是要查一个订单详情,却把整个订单模块的 UseCase 都拉进来了。单元测试的时候,mock 起来也麻烦。
我建议的做法是:
// ✅ 推荐做法:按功能拆分
@HiltViewModel
class OrderDetailViewModel @Inject constructor(
private val getOrderDetailUseCase: GetOrderDetailUseCase,
private val trackOrderUseCase: TrackOrderUseCase
) : ViewModel()
说白了,每个 ViewModel 只依赖它真正需要的东西。这样测试好写,代码也清晰。
7.2.2 SavedStateHandle:状态恢复的正确姿势
进程被杀后恢复状态,这是 Android 的老大难问题。Hilt 对 SavedStateHandle 的支持很到位,你直接在构造函数里声明就行:
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val userRepo: UserRepository
) : ViewModel() {
private val userId: String = savedStateHandle.get("userId") ?: ""
fun loadProfile() {
viewModelScope.launch {
val profile = userRepo.getProfile(userId)
savedStateHandle.set("profile", profile)
}
}
}
这里有个细节要注意:SavedStateHandle 不需要你手动提供,Hilt 会自动注入。但前提是——你的 ViewModel 必须是通过 @HiltViewModel 创建的。如果你自己 new 了一个 ViewModel,那 SavedStateHandle 就是空的。
我曾经踩过的坑: 在 Fragment 里用 ViewModelProvider(this).get(MyViewModel::class.java) 而不是 hiltViewModel(),结果 SavedStateHandle 一直拿不到数据。排查了半天才发现是获取方式不对。
7.2.3 作用域:ViewModel 里的依赖该不该用 @Singleton?
这个问题经常有人问。我的建议是:ViewModel 里的依赖,尽量用 @ViewModelScoped 或者不加作用域。
为什么?
- @Singleton:全局单例,整个应用生命周期内只创建一次。如果 ViewModel 里依赖了一个很大的数据缓存,用 @Singleton 没问题。但如果是跟用户会话相关的数据,用 @Singleton 就错了——用户切换后数据不会刷新。
- @ViewModelScoped:这是 Hilt 1.0 之后引入的作用域。它保证依赖跟 ViewModel 同生命周期。ViewModel 销毁时,这个依赖也跟着销毁。我个人习惯,大部分场景用这个就够了。
- 不加作用域:每次注入都创建新实例。适合无状态的工具类,比如 DateFormatter。
看个例子:
@Module
@InstallIn(ViewModelComponent::class) // 注意这里
object ProfileModule {
@Provides
@ViewModelScoped
fun provideProfileCache(): ProfileCache {
return ProfileCache()
}
}
嗯,这里 @InstallIn(ViewModelComponent::class) 也很关键。它告诉 Hilt:这个 Module 里的依赖只在 ViewModel 层级可用。如果你在 Activity 里想注入 ProfileCache,编译器会报错。这其实是好事——把依赖的作用域限制死了,避免滥用。
7.3 知识体系总览
下面这张图,把 Hilt 和 ViewModel 的关系梳理清楚了。你可以看到从注解声明到最终注入的完整链路。
7.4 常见问题与避坑指南
| 问题 | 原因 | 解决方案 |
|---|---|---|
| ViewModel 无法创建,报错 "cannot be created" | 忘记加 @HiltViewModel 注解,或者构造函数没有 @Inject | 检查注解是否齐全,确保 ViewModel 类上有 @HiltViewModel,构造函数有 @Inject |
| hiltViewModel() 编译报错 | Fragment 没有用 @AndroidEntryPoint 注解 | 给 Fragment 加上 @AndroidEntryPoint,并确保依赖的 Activity 也有该注解 |
| SavedStateHandle 拿不到数据 | ViewModel 不是通过 Hilt 创建的,或者获取方式不对 | 使用 hiltViewModel() 而不是 ViewModelProvider(this) |
| 依赖在 ViewModel 销毁后仍然存活 | 依赖使用了 @Singleton 作用域,但本应使用 @ViewModelScoped | 评估依赖的生命周期需求,改用合适的作用域 |
一个小技巧: 如果你不确定某个依赖该用什么作用域,先不加。等发现它被频繁创建造成性能问题时,再考虑加 @ViewModelScoped 或 @Singleton。过早优化是万恶之源。
7.5 实战:一个完整的例子
最后,我把上面讲的东西串起来,给一个完整的实战代码。这是一个用户详情页的 ViewModel:
// 1. 定义 Repository(不加作用域,每次都是新实例)
class UserRepository @Inject constructor(
private val api: UserApi
) {
suspend fun getUser(id: String): User = api.fetchUser(id)
}
// 2. 定义 ViewModel
@HiltViewModel
class UserDetailViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle,
private val userRepo: UserRepository
) : ViewModel() {
private val userId: String = savedStateHandle.get("userId") ?: ""
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user.asStateFlow()
fun loadUser() {
viewModelScope.launch {
val result = userRepo.getUser(userId)
_user.value = result
savedStateHandle.set("user", result)
}
}
}
// 3. 在 Fragment 中使用
@AndroidEntryPoint
class UserDetailFragment : Fragment() {
private val viewModel: UserDetailViewModel by hiltViewModel()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel.loadUser()
// 观察数据...
}
}
这段代码里,UserRepository 没有加任何作用域注解,所以每次 ViewModel 创建时都会生成一个新的实例。如果你的 Repository 内部有缓存或者数据库连接,可以考虑加上 @Singleton。但记住我前面说的——先不加,有需要再加。
嗯,关于 Hilt 和 ViewModel 的配合,核心就是这些。说白了,Hilt 帮我们省掉了 Factory 的模板代码,但依赖的粒度、作用域的选择,这些设计上的事情还是得我们自己拿捏。多写几个项目,你自然就找到感觉了。