Koin 依赖注入: 在 Android 模块化项目中定义 Room 数据库的最佳实践

前置

本文发布于个人小站:https://wavky.top/db-in-multi-modules/

欢迎移步至小站,关注更多技术分享,获得更佳阅读体验

(不保证所有技术文章都会同步发表到博客园)

什么是模块化架构

Android 模块化架构是一种将应用程序拆分为多个独立模块的设计方法,以提高代码复用性、可维护性和团队协作效率。

例如,一个 Android 项目以模块化的方式设计组织之后,大概会形成以下的目录结构:

复制代码
Android 模块化架构
│
├── 应用层 (App Module): App 的主入口
│   ├── 主体界面 (Activity / Fragment)
│   ├── 业务逻辑 (ViewModel / UseCase)
│   ├── 数据层 (Repository)
│   └── 依赖(组装)功能模块、共享模块
│
├── 功能模块 (Feature Modules)
│   ├── 某个具体功能 (如支付模块) 的具体实现
│   ├── 功能界面 (Activity / Fragment)
│   ├── 业务逻辑 (ViewModel / UseCase)
│   ├── 数据层 (Repository)
│   └── 依赖共享模块
│
└── 共享模块 (Common Modules)
    ├── 主题与样式 (UI Components / Theme)
    ├── 全局通用资源文件 (Strings / Colors / Drawables)
    ├── 公共库 (如通用 View 组件 / 基础 Activity / 通用数据类型 / 全局常量 / 算法库)
    ├── 工具类 (Utils / Extensions)
    ├── 依赖库
    └── 其他可复用的代码

应用模块、功能模块的模板目录结构和一键批量创建的方法: 一键创建 Android 项目模板目录

这种架构的优点:

  • 解耦:不同功能模块可以独立开发和维护,为后期重构创造便利。
  • 团队协作:不同团队可以并行开发不同模块,减少代码合并冲突。
  • 复用性:多个应用可以共享核心模块或公共库。
  • 加速构建:模块可以独立编译,加快 Gradle 构建速度。

模块间的依赖关系如下图所示

简单介绍一下 Room

Room 是 Android Jetpack 中的一个 SQLite 封装库,它提供了一个抽象层来简化 SQLite 数据库的使用,提供一组基于注解的 API 来标准化数据库操作,并提供类型安全支持。

使用方式

以实体类方式定义数据表

kotlin 复制代码
@Entity
data class User(
    @PrimaryKey val id: Long,
    val firstName: String?,
    val lastName: String?
)

定义增删改查的 Dao

kotlin 复制代码
@Dao
interface UserDao {
    @Query("SELECT * FROM user")
    fun getAll(): Flow<User>

    @Query("SELECT * FROM user WHERE uid IN (:userIds)")
    fun loadAllByIds(userIds: IntArray): List<User>

    @Query("SELECT * FROM user WHERE first_name LIKE :first AND last_name LIKE :last LIMIT 1")
    fun findByName(first: String, last: String): User

    @Insert
    fun insertAll(vararg users: User)

    @Update
    fun update(user: User)

    @Delete
    fun delete(user: User)
}

定义数据库 AppDatabase

下文使用 AppDatabase 指代自定义 Room 数据库类

kotlin 复制代码
// 定义数据库时需要访问到 App 中所有的 Entity 和 Dao 接口
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

读写数据

kotlin 复制代码
val db = Room.databaseBuilder(
        applicationContext,
        AppDatabase::class.java, "database-name"
    ).build()

// 需要通过数据库对象来获取 Dao 对象,进行数据操作
val userDao = db.userDao()
val user = User(1, "John", "Doe")
userDao.insertAll(user)
val allUsers: Flow<List<User>> = userDao.getAll()

值得一提的是,Room 支持直接返回 Flow 类型的查询结果(也支持 LiveData),为 Compose 的数据订阅提供了基础支持。

在模块化架构中的争议点

如前面所述,在 Android 模块化架构中,依赖关系是单向的:

  • 应用模块依赖:功能模块,共享模块
  • 功能模块依赖:共享模块,或者其他子功能模块
  • 共享模块依赖:不依赖任何自定义模块

在这种依赖关系中,下层模块看不到(访问不到)上层模块的实现,例如共享模块内是访问不到应用模块中的 MainActivity 的。

在实际开发中,应用模块和功能模块往往都需要使用 Room 来存取数据,在各模块内自定义独有的数据表和 Dao,甚至在共享模块中也可能会有一些基础的公共数据表和 Dao 的定义。

在 Room 中对数据的操作依赖各个 Dao 对象来执行,而 Dao 对象依赖 AppDatabase 实例来创建,因此 AppDatabase 应该放置在比较底层的位置,以便所有模块都能访问到所需的 Entity 和 Dao 实例。

但在上面的 Room 介绍一节中我们可以看到,在定义和实例化 AppDatabase 时,是需要访问到 App 中所有的 Entity 和 Dao 接口,这就导致了一个关于可见性的问题:

  • 如果我们在最底层的共享模块中定义并实例化 AppDatabase,AppDatabase 就无法访问到位于应用模块和功能模块中定义的 Entity 和 Dao 接口,继而无法实例化各个 Dao 对象;
  • 而如果我们将所有的 Entity 和 Dao 接口也都放在共享模块中,那么又会破坏模块化的设计原则,导致本应定义在应用模块和功能模块中的代码被耦合到了共享模块中,产生代码污染;
  • 为了让 AppDatabase 能访问到所有的 Entity 和 Dao 接口,我们需要将其挪到最上层的应用模块中,但这样又会导致较下层的功能模块无法访问到 Room 实例化的 Dao 对象,无法进行数据操作。

就这样,产生了一个先有鸡还是先有蛋的哲学问题:

AppDatabase 依赖各模块的 Entity 和 Dao 定义(所以 AppDatabase 应该放在顶层);

各个模块又依赖 AppDatabase 对象提供的 Dao 实例对象(所以 AppDatabase 应该放在底层)。

那么,AppDatabase、Entity 和 Dao 接口的定义应该放在哪里呢?

网上大佬的解决方案

在探索这个问题如何解决的过程中,我发现了网上大佬的解决方案:模块化架构下 Room 数据库的使用设计

简单引述一下:

方案一:在共享模块中定义整个 Room 数据库

将 App 中所有的 Entity 和 Dao 都定义在共享模块中,这种方法简单容易操作,但缺点之前也提到过,原本应该在各个模块内各自维护的代码都被统一下放到共享模块中,导致代码污染,背离模块化设计的初衷。

方案二:在应用模块和功能模块中各自实例化自己的 Room Database

这种方法将各个 Dao 和 Entity 保留在了各自的模块中,践行了模块化设计原则,避免了代码污染,但缺点是会导致多个 Room 数据库实例的创建,据文档所言会极大增加资源开销,还可能产生复杂的使用问题。

方案三:在应用模块中定义 AppDatabase,在各个模块中定义各自的 Entity 和 Dao 接口

这就是网上大佬最后得出的解决方案:

  1. 在顶层应用模块中定义 AppDatabase,实例化所有的 Dao 接口
  2. 在各个功能模块中定义各自的 Entity 和 Dao 接口
  3. 在各个功能模块中定义获取 Dao 对象的功能接口,接口中通过 callback 字段缓存 Room 创建的 Dao 实例
  4. 最后在 Application 中进行对各个模块的这种 Dao 对象获取接口进行实例化填充。

我没有尝试这个方案,是因为直觉上认为这个方案存在缺点:

  1. 应用层创建的 Dao 实例下放到各个模块的设计实现过于复杂,代码不直观,增加了维护难度
  2. 我不喜欢这种基于全局单例的缓存设计

我追求的设计是简单直观,减少引入不必要的复杂性,在尽可能少的代码实装下实现与这个方案相同的功能。

找到最终方案:Koin

Koin 是一个轻量级的依赖注入框架,与 Dagger 和 Google 推荐的 Hilt 类似,但与后面两者相比,Koin 更加轻量易用,不需要复杂的配置就能实现基本的依赖注入功能,并且 Koin 是运行时注入,而 Dagger 与 Hilt 是编译时注入。

Koin 是 100% Kotlin 编写的第三方库,基于 Kotlin DSL 语法,可以用非常 Kotlin 风格的方式简单地定义依赖关系,并在运行时按需动态注入。

Koin 是运行时动态注入的,而不是编译时注入,这意味着 Koin 不会在编译时检查依赖关系,不会由于模块间的相互依赖而导致编译失败。(在下文中会再详细说明)

简单说说 Koin 的使用方式

Koin 的使用方式非常简单,主要分为以下几个步骤:

  1. 在项目中添加 Koin 库的依赖
  2. 定义 Koin 模块 (Module)
  3. 定义依赖关系
  4. 运行应用并动态完成依赖注入

定义 Koin Module

Koin Module 简单理解是一个预定义的实例工厂池,这个工厂池是 App 全局共享的,里面存放了 App 中各处需要被注入的对象的实例化方法,Koin 默认通过对象的类型来进行依赖对象的匹配和注入。(根据需要也可以选择更复杂和精细的匹配模式)

kotlin 复制代码
val appModule = module {
  // 以工厂模式实例化自定义的 Printer 对象
  factory { Printer() }
}

定义依赖注入

在需要使用的地方通过 Koin API 注入对象实例,在 App 运行期间,Koin 会自动匹配、按需实例化并注入对应的对象实例。

kotlin 复制代码
class MainActivity : AppCompatActivity() {
  // 声明一个 Printer 类型变量,指定通过 Koin 注入来赋值
  val printer: Printer by inject()

  override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      setContentView(R.layout.activity_main)
  
      // 使用注入的 Printer 对象
      printer.print("Hello, Koin!")
  }
}

使用 Koin 实现模块化架构中 Room 数据库的最佳实践

我们将沿用上面方案三的思路:

在应用模块中定义 AppDatabase,在各个功能模块中定义各自的 Entity 和 Dao 接口。

但是我们不需要设计和定义将应用层中实例化的 Dao 对象下放到各个模块的方法,只需要在各个模块中需要使用 Dao 对象的地方指定依赖注入,交由 Koin 动态注入来实现即可。

应用模块定义数据库,并创建数据库 Module

kotlin 复制代码
val appModule = module {
  // 单例方式实例化数据库对象
  single<AppDatabase> {
    Room.databaseBuilder(
      androidContext(),
      AppDatabase::class.java,
      AppDatabase.DATABASE_NAME
    ).build()
  }

  // 单例方式实例化 UserDao 对象
  // 这里的 UserDao 是在功能模块中定义的
  single { get<AppDatabase>().userDao() }
}

在 Application 中初始化 Module

kotlin 复制代码
class MyApplication : Application() {
  override fun onCreate() {
    super.onCreate()

    startKoin {
      androidLogger()
      androidContext(this@MyApplication)
      modules(appModule)
    }
  }
}

在功能模块的 Repository 中注入 Dao 对象

kotlin 复制代码
// 在构造函数参数中声明对 Dao 的依赖
class UserRepository(private val userDao: UserDao) {
  fun getAllUsers(): Flow<List<User>> {
    return userDao.getAll()
  }
}

在功能模块中定义 Repository 的 Module

kotlin 复制代码
val userModule = module {
  // 通过 get() 匹配并注入与参数相同类型的实例
  factory { UserRepository(get()) }
}

在功能模块的 UI 层中注入 Repository 对象

kotlin 复制代码
// 在 ViewModel 中注入 Repository
class UserViewModel(
  private val userRepository: UserRepository
) : ViewModel() {
  val allUsers: Flow<List<User>> = userRepository.getAllUsers()
}

// 在 Activity 中注入 ViewModel
class UserActivity : AppCompatActivity() {
  private val userViewModel: UserViewModel by viewModel()
  
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_user)

    userViewModel.allUsers.collect { users ->
      // 更新 UI
    }
  }
}

⚠️ 上面的代码并非完整代码,仅为说明而摘取的核心部分

纵观整个实现的依赖关系

  • 应用模块中定义了 AppDatabase
  • 功能模块中定义了 Entity、Dao,上层的应用模块基于此进行实例化
  • 应用模块在 Koin Module 中实例化 AppDatabase 和 App 内所有的 Dao 对象
  • 功能模块根据需要,在 Repository 或其他类中注入 Dao 对象,利用 Dao 对象进行数据操作

在这里,我们似乎遇到了一个循环依赖问题:

  • 应用模块依赖功能模块提供 Entity 和 Dao 接口的实现
  • 功能模块依赖应用模块提供 Dao 对象的实例化

但实际上,Dao 对象的实例化和注入是由 Koin 在 App 运行时动态完成的,Koin 会在运行时自动解析依赖关系并实例化对象,因此不会出现编译期的循环依赖的问题。

Koin 是运行时动态注入的,而不是编译时注入,不会产生模块间的循环依赖问题导致编译失败。

通过这种方式,我们在模块化架构中实现了 Room 数据库的定义和使用,避免了代码污染和复杂的对象传递设计,同时也保持了模块化设计的纯净。

举一反三,除了 Room 数据库,对于 Retrofit 等其他常用库的使用也可以采用类似的方式进行依赖注入,Koin 提供了非常灵活和强大的依赖注入功能,可以帮助我们更好地实现模块化架构。

相关推荐
ta叫我小白3 个月前
Android Room 报错:too many SQL variables (code 1 SQLITE_ERROR) 原因及解决方法
android·sql·sqlite·room
Billy_Zuo4 个月前
Android Room 数据库使用详解
android·数据库·room
编码熊(Coding-Bear)1 年前
Android JetPack Compose+Room----实现搜索记录功能
sqlite·android jetpack·room·compose·搜索记录·搜索界面
xyzso1z1 年前
room数据库升级
room·数据库升级·销毁重建·大量数据迁移
袁震1 年前
Android--Jetpack--数据库Room详解二
android·android jetpack·mvvm·room·livedata
命运之手2 年前
【Android】Room新手快速入门
android·orm·room