第24章:Koin与Room:数据库模块的Koin封装、DAO与Database
说实话,在Android开发里,Room和依赖注入的组合,几乎是每个中大型项目的标配。我见过太多项目,数据库代码散落在各个Activity和Fragment里,想改个表结构都得翻遍整个项目。今天我们就聊聊,怎么用Koin把Room模块封装得干净利落。
为什么需要封装Room模块?
你想想看,Room本身已经帮我们做了很多事——自动生成DAO实现、编译期校验SQL、支持Flow和LiveData。但有个问题:Database实例的创建和管理。
RoomDatabase是个重量级对象,它的创建涉及文件IO、数据库迁移、回调注册等操作。如果每次用的时候都new一个,那性能就崩了。更别提测试的时候,你根本不想连真数据库。
我个人习惯,把Database和DAO都交给Koin管理。这样有几个好处:
- 单例管理:Database只创建一次,全局复用
- 生命周期可控:可以配合Application生命周期,避免内存泄漏
- 测试友好:替换成内存数据库只需要改一个module
- 依赖清晰:ViewModel只需要声明DAO依赖,不用关心Database怎么来的
Koin封装Room的标准姿势
我们先看一个典型的Room数据库定义。假设我们有个用户表:
@Entity(tableName = "users")
data class User(
@PrimaryKey val id: Long,
val name: String,
val email: String
)
@Dao
interface UserDao {
@Query("SELECT * FROM users")
fun getAllUsers(): Flow<List<User>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: User)
@Delete
suspend fun deleteUser(user: User)
}
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
嗯,这里要注意:Database必须是abstract类,Room会帮我们生成实现类。接下来,我们用Koin来管理它。
Koin Module的封装
我一般会单独建一个databaseModule,把Database和DAO的创建逻辑放在一起:
val databaseModule = module {
// 提供Database实例,单例
single {
Room.databaseBuilder(
get<Context>(), // 从Koin容器获取Application Context
AppDatabase::class.java,
"my_app_database"
)
.fallbackToDestructiveMigration() // 开发阶段用,生产环境要谨慎
.build()
}
// 提供DAO实例
factory { get<AppDatabase>().userDao() }
}
这里有个细节:get<Context>()从哪里来?我们需要在Application启动时,把Context注册到Koin里:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MyApplication) // 关键!注册Context
modules(databaseModule, viewModelModule)
}
}
}
我在项目中遇到过一个问题:忘记注册Context,结果运行时直接崩了,报错说找不到Context的依赖。排查了半天才发现是这步漏了。所以,androidContext()一定要在startKoin时调用。
DAO的作用域选择:single还是factory?
这个问题经常有人问。你看上面的代码,Database用的是single,DAO用的是factory。为什么?
| 作用域 | 适用场景 | 原因 |
|---|---|---|
| single | Database | 重量级对象,全局只需一个实例 |
| factory | DAO | 轻量级,每次获取都是新的,避免状态污染 |
说白了,DAO本身没有状态,它只是SQL操作的封装。用factory可以确保每次注入都是干净的实例。而且Room的DAO实现类本身也很轻,创建成本极低。
factory。但如果你确定某个DAO会被频繁注入,也可以考虑single。不过说实话,性能差异微乎其微,我更倾向于统一风格。
在ViewModel中使用DAO
封装好了,用起来就简单了。ViewModel里直接声明依赖:
class UserViewModel(
private val userDao: UserDao
) : ViewModel() {
val users: StateFlow<List<User>> = userDao.getAllUsers()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
fun addUser(name: String, email: String) {
viewModelScope.launch {
val user = User(
id = System.currentTimeMillis(),
name = name,
email = email
)
userDao.insertUser(user)
}
}
}
然后在Koin里注册ViewModel:
val viewModelModule = module {
viewModel { UserViewModel(get()) } // get()会自动注入UserDao
}
你看,ViewModel完全不知道Database的存在,它只关心DAO。这就是依赖注入的魅力——解耦。
多数据库场景的处理
有些项目会有多个数据库,比如一个用于用户数据,一个用于缓存。这时候怎么封装?
我一般会用named来区分:
val databaseModule = module {
single(named("user_db")) {
Room.databaseBuilder(get<Context>(), AppDatabase::class.java, "user_db")
.build()
}
single(named("cache_db")) {
Room.databaseBuilder(get<Context>(), CacheDatabase::class.java, "cache_db")
.build()
}
factory { get<AppDatabase>(named("user_db")).userDao() }
factory { get<CacheDatabase>(named("cache_db")).cacheDao() }
}
使用时,在ViewModel里通过named指定:
class MainViewModel(
private val userDao: UserDao,
private val cacheDao: CacheDao
) : ViewModel()
Koin会自动匹配对应的DAO实例。嗯,这里要注意命名的一致性,别写错了。
测试时的替换技巧
我曾经在测试环境里踩过一个坑:单元测试跑着跑着,突然报数据库文件已存在的错误。后来发现是测试用例之间共享了同一个Database实例。
解决方案很简单:在测试Module里替换成内存数据库。
val testDatabaseModule = module {
single {
Room.inMemoryDatabaseBuilder(
get<Context>(),
AppDatabase::class.java
).build()
}
factory { get<AppDatabase>().userDao() }
}
然后在测试启动时,用testDatabaseModule替换掉原来的databaseModule:
@Before
fun setup() {
startKoin {
androidContext(ApplicationProvider.getApplicationContext())
modules(testDatabaseModule, viewModelModule)
}
}
这样每个测试用例都会获得一个全新的内存数据库,互不干扰。而且内存数据库跑得飞快,测试效率也上去了。
数据库迁移的处理
项目迭代过程中,数据库表结构难免会变。Room的迁移机制需要你提供Migration对象。我习惯把迁移逻辑也封装到Koin Module里:
val databaseModule = module {
single {
Room.databaseBuilder(
get<Context>(),
AppDatabase::class.java,
"my_app_database"
)
.addMigrations(get<Migration>(named("migration_1_2")))
.addMigrations(get<Migration>(named("migration_2_3")))
.build()
}
single(named("migration_1_2")) {
object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("ALTER TABLE users ADD COLUMN age INTEGER NOT NULL DEFAULT 0")
}
}
}
single(named("migration_2_3")) {
object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL("CREATE TABLE posts (id INTEGER PRIMARY KEY, title TEXT)")
}
}
}
}
这样做的好处是:迁移逻辑和数据库创建解耦了。你可以单独测试每个Migration,也可以在需要时动态添加或移除。
SVG:Koin + Room 架构图
下面这张图展示了Koin管理Room模块的完整流程,从Application启动到ViewModel使用DAO:
避坑指南
最后,分享几个我踩过的坑:
- Context泄露:不要把Activity级别的Context传给Database,要用Application Context。Koin的
androidContext()已经帮我们处理好了。 - 数据库版本号:每次修改表结构,记得升级版本号并提供对应的Migration。我曾经忘了升级版本,结果App直接闪退。
- DAO的suspend函数:Room从2.1.0开始支持suspend函数,但要注意必须在协程里调用。ViewModel里用
viewModelScope.launch是最稳妥的。 - 测试隔离:每个测试类最好用
@Before重新启动Koin,避免上一个测试的数据库状态影响当前测试。
好了,关于Koin和Room的封装,核心思路就是这些。说白了,就是把Database的创建和管理交给Koin,让ViewModel只关心业务逻辑。这样代码更干净,测试也更方便。