12. Hilt与Room:数据库模块的封装、DAO与Database的注入
说实话,在Android开发里,Room和Hilt的组合,是我见过最舒服的「数据层+依赖注入」搭档。一个管数据持久化,一个管对象创建和生命周期。但舒服归舒服,封装不好照样翻车。
我记得刚接手一个老项目时,发现每个Activity里都new了一个Room.databaseBuilder(...)。你想想看,这要是换了数据库版本,得改多少个地方?更别提测试了,根本没法mock。所以今天我们就来聊聊,怎么用Hilt把Room模块封装得漂漂亮亮。
12.1 为什么需要封装数据库模块?
直接回答:为了解耦和可测试性。
Room的Database对象,说白了就是一个重量级单例。它需要Application Context,需要做Migration,还得保证整个App里只有一个实例。这些活儿,交给Hilt来干最合适。
我个人习惯把数据库相关的依赖分成三层:
- Database层:提供RoomDatabase实例
- DAO层:提供具体的数据访问对象
- Repository层:组合多个DAO,对外暴露业务数据
Hilt要做的,就是把这每一层的对象都管理起来。你不需要关心它们什么时候创建、什么时候销毁,只需要声明「我需要什么」就行。
12.2 定义Database模块
我们先写一个标准的Room Database。假设我们有一个用户表和一个订单表:
@Database(
entities = [UserEntity::class, OrderEntity::class],
version = 1,
exportSchema = true
)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
abstract fun orderDao(): OrderDao
}
嗯,这里要注意:exportSchema = true 会生成数据库Schema文件,方便你做版本升级时参考。我建议一直开着,反正不影响运行。
接下来,我们用Hilt的Module来提供这个Database实例:
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideAppDatabase(
@ApplicationContext context: Context
): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"my_app.db"
)
.fallbackToDestructiveMigration()
.build()
}
}
这里有几个关键点:
@InstallIn(SingletonComponent::class):表示这个Module里的依赖是全局单例。因为Database本身就应该只有一个实例。@Singleton:确保AppDatabase在整个App生命周期内只创建一次。@ApplicationContext:Hilt会自动注入Application级别的Context,不需要你自己传。
.fallbackToDestructiveMigration(),结果数据库升级时用户数据全丢了。这个方法是「毁灭性回退」,只适合开发阶段。生产环境一定要写Migration。
12.3 注入DAO对象
Database有了,接下来就是DAO。很多人会直接在Module里再写一个provide方法:
@Provides
@Singleton
fun provideUserDao(database: AppDatabase): UserDao {
return database.userDao()
}
这样写没问题,但有点啰嗦。如果我有10个DAO,就得写10个provide方法。我个人更推荐用@Binds的方式,或者干脆让Hilt自动注入——前提是你的DAO是接口,并且Database里已经声明了抽象方法。
其实更简洁的做法是:直接在需要DAO的地方注入Database,然后调用方法。但这样耦合度高,不推荐。
我通常的做法是:在Module里统一提供DAO,这样测试时可以轻松替换:
@Module
@InstallIn(SingletonComponent::class)
object DaoModule {
@Provides
@Singleton
fun provideUserDao(database: AppDatabase): UserDao {
return database.userDao()
}
@Provides
@Singleton
fun provideOrderDao(database: AppDatabase): OrderDao {
return database.orderDao()
}
}
你看,每个DAO的提供方法都依赖AppDatabase。Hilt会自动把之前创建的Database实例传进来。这就是依赖注入的魅力——你只管声明,框架帮你串联。
12.4 在ViewModel中使用
现在DAO已经可以注入了。我们在ViewModel里怎么用?
@HiltViewModel
class UserViewModel @Inject constructor(
private val userDao: UserDao
) : ViewModel() {
val users: LiveData<List<UserEntity>> = userDao.getAllUsers()
fun addUser(name: String, age: Int) {
viewModelScope.launch {
userDao.insert(UserEntity(name = name, age = age))
}
}
}
注意看:@Inject constructor 告诉Hilt,这个ViewModel的构造参数需要注入。Hilt会从Module里找到UserDao的实例,然后传进来。
你可能会问:那Database呢?不需要了。因为DAO已经封装了所有数据库操作,ViewModel不应该直接接触Database。这是分层设计的基本要求。
12.5 整体架构图
下面这张图展示了Hilt、Room、DAO和ViewModel之间的依赖关系。我画的时候特意把箭头方向标清楚了,方便你理解谁依赖谁。
从图里可以清楚看到:Hilt Module负责创建Database,Database负责提供DAO,ViewModel只依赖DAO。每一层职责清晰,替换起来也方便。
12.6 测试时的替换技巧
说到测试,这才是依赖注入的真正价值所在。假设你想测试UserViewModel,但不想真的操作数据库。怎么办?
很简单,在测试Module里提供一个假的UserDao:
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [DaoModule::class]
)
object TestDaoModule {
@Provides
@Singleton
fun provideUserDao(): UserDao {
return FakeUserDao() // 内存实现,不操作真实数据库
}
}
这样,测试时Hilt会自动用FakeUserDao替换真实的UserDao。你的ViewModel代码一行都不用改。这就是依赖注入的「可插拔」特性。
好了,这一章的内容就到这里。记住:好的架构不是一蹴而就的,而是在一次次重构中打磨出来的。你现在的代码,就是未来自己的「遗产」——写得好,未来感谢你;写得烂,未来骂死你。
公众号:蓝海资料掘金营,微信deep3321