第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实现类本身也很轻,创建成本极低。

我的建议:如果你有多个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:

Koin + Room 数据库模块架构 Application Koin Container androidContext() + modules(databaseModule, viewModelModule) databaseModule single { Room.databaseBuilder(...) } factory { get<AppDatabase>().userDao() } AppDatabase (single) 全局唯一实例 UserDao (factory) 每次注入新实例 get() ViewModel 通过 Koin 注入 UserDao,无需关心 Database 创建细节

避坑指南

最后,分享几个我踩过的坑:

  • 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只关心业务逻辑。这样代码更干净,测试也更方便。

公众号:蓝海资料掘金营,微信deep3321