【Android架构底层逻辑拆解】Google官方项目NowInAndroid研究(2)数据层的设计和实现之model与database

丈夫当死中图生,祸中求福; 古人有困儿修德,穷而著书。------《曾国藩传》

对于NIA这个项目,我将采用自底向上的路径进行拆解。因此,第一部分会从Data(数据)层开始。从图中可见,所有的依赖关系均为单向依赖,APP层依赖Feature层各个模块,Feature层各个模块彼此独立,它们只依赖更底层的Core模块Core模块之间允许互相依赖,但不允许循环。

:core:model------定义基础Bean,供各模块使用

model是位于最底层的数据模块,定义了应用之中流转的最基础数据类型,其中包含的类如下,与应用核心逻辑相关的主要是NewsRsource、Topic、UserData

  • DarkThemeConfig: 枚举类,定义3种浅色/深色UI状态
  • FollowableTopic: 可被关注的主题,由主题Topic和一个布尔类型的isFollowed组成
  • NewsResource: 一条新闻,可以关联到多个Topic
  • SearchResult: 表示一次搜索结果,由多个Topic和NewsResource组成
  • ThemeBrand: 枚举类,无需关注
  • Topic: 一个主题
  • UserData: 一名用户的配置信息,包含他收藏的NewsResource列表、已经浏览过的NewsResource、关注的Topic列表、显示效果等个性化设置
  • UserNewsResource: 同样是一条新闻,在此基础上关联了用户信息,如是否被阅读过、其Topic列表是否被用户关注等
  • UserSearchResult: 将SearchResult中的Topic和NewsResource替换为包含User维度信息的FollowableTopic和UserNewsResource

Topic主题对象

一个主题基本的属性,不包含NewsResource信息。

kotlin 复制代码
data class Topic(
    val id: String,
    val name: String,
    val shortDescription: String,
    val longDescription: String,
    val url: String,
    val imageUrl: String,
)

NewsResource新闻

新闻NewsResource,注意其中时间戳使用的是Instant类型,它与Long对比如下:

Instant Long
- 代码更安全、更易读 - 避免时区陷阱和精度丢失 - 适合大多数业务场景 - 仅在老代码,或对性能或存储有极端要求时使用。
kotlin 复制代码
data class NewsResource(
    val id: String,
    val title: String,
    val content: String,
    val url: String,
    val headerImageUrl: String?,
    val publishDate: Instant, // 更精确的时间统计类,优于直接使用Long时间戳
    val type: String,
    val topics: List<Topic>,
)

用户数据UserData

由两部分配置组成:业务相关的主题、文章收藏与阅读记录 ,以及 UI相关的深色模式、主题颜色等个性化配置

kotlin 复制代码
data class UserData(  
    val bookmarkedNewsResources: Set<String>, // 收藏的新闻列表
    val viewedNewsResources: Set<String>, // 看过的新闻列表
    val followedTopics: Set<String>,  // 关注的主题列表
    val themeBrand: ThemeBrand,  // 个性化设置:主题风格
    val darkThemeConfig: DarkThemeConfig,  // 个性化设置-主题(亮、暗、跟随系统)
    val useDynamicColor: Boolean,  
    val shouldHideOnboarding: Boolean,  
)

model总结

整体来说model模块是非常简单的,这也决定了它不需要依赖其它任何模块。相反,上层各模块虽然都在内部定义了各自数据类,但它们之间相互调用的时候,更多地是使用model中定义的基础类型(Bean)。

:core:database------数据库作为数据源

可以看到:core:databse一共分为4个package,每个package的职责描述如下:

  • dao: 使用Room,提供SQL中的CRUD操作
  • di: 利用Hilt依赖反转,自动注入并生成Database和Dao实例
  • model: 数据层数据对象,对应数据表Entity,并提供FTS全文搜索、CrossRef交叉引用
  • util: Instant与Long之间互转的工具类,可以被Dao自动识别

model包------数据在数据层的体现

"数据在数据层的体现"这句话有点拗口,model包里面的XXXEntity是Room数据库表在Kotlin代码里的呈现,每一个XXXEntity都对应一张SQL数据表

整个model目录下的class可以分为3类:

  • 基础Bean类型: 他们就是:core:model模块下的Bean在数据库层的映射,这种类型的class有NewsResourceEntity、RecentSearchQueryEntity、TopicEntity
  • 全文检索类型: 通过room.Fts4提供全文检索的能力,例如输入关键字可以匹配到标题、正文中包含关键字的NewsResource,这种类型的class有NewsResourceFtsEntity、TopicFtsEntity,用来对新闻、主题进行全文检索
  • 交叉引用类型: 用来建立一对多、多对多关系,例如一个NewsResource可能关联到多个Topic,一个Topic下面也必然有多篇NewsResource。这种类型的class有NewsResourceTopicCrossRef、PopulatedNewsResource

TopicEntity表

先以TopicEntity为例,看一下主题表的结构:

kotlin 复制代码
@Entity(
    tableName = "topics", // 注解,表名
)
data class TopicEntity( // 使用data类,不含方法  
    @PrimaryKey // 主键声明  
    val id: String,  
    val name: String,  
    val shortDescription: String,  
    @ColumnInfo(defaultValue = "") // 指明在表中的默认值  
    val longDescription: String,  
    @ColumnInfo(defaultValue = "")  
    val url: String,  
    @ColumnInfo(defaultValue = "")  
    val imageUrl: String,  
)

上述代码在首次运行时,会自动创建一张名为topics的数据表。

请留意,shortDescriptionlongDescription这两个字段都是非空String类型,而后者增加了@ColumnInfo(defaultValue = "")注解,表示在写数据时如果该字段是UNDEFINED,则自动赋予其""作为初始值。然而,由于它们在Kotlin代码中都被声明为非空String类型,所以在插入/更新数据时,压根不会出现longDescriptionUNDEFINED的情况。

在同一个文件中,提供了扩展函数asExternalModel(),用于将TopicEntity转换为model模块的Topic对象。虽然可以将该函数作为成员函数写在TopicEntity类内部,但以扩展函数提供,其用意在于尽可能保证Entity数据类的精简。

kotlin 复制代码
fun TopicEntity.asExternalModel() = Topic(
    id = id,
    name = name,
    shortDescription = shortDescription,
    longDescription = longDescription,
    url = url,
    imageUrl = imageUrl,
)

NewsResourceFtsEntity------全文检索

以NewsResourceEntity的全文检索为例,其中包含能够进行检索的title和content。

kotlin 复制代码
@Entity(tableName = "newsResourcesFts")
@Fts4 // 使用注解自动赋予全文检索能力,需要检索的是title和content,再利用唯一主键newsResourceId进行关联
data class NewsResourceFtsEntity(

    @ColumnInfo(name = "newsResourceId")
    val newsResourceId: String,

    @ColumnInfo(name = "title")
    val title: String,

    @ColumnInfo(name = "content")
    val content: String,
)

NewsResourceTopicCrossRef------两表之间的交叉引用

相比前两种,这种类型要复杂一些,需要一定的数据库知识。以NewsResource和Topics的关联表为例。

  • 一个NewsResources可以关联多个Topic
  • 一个Topic可以包含多个NewsResource

在这种情况下,对于两者的联合表,需要声明一个全局唯一的关联主键,通常是两张表的主键相联合。关联主键的作用是,在进行查找时定位到子表相应的记录,并且在子表记录发生变化(例如删除)时,能够自动投射该动作到关联表。

kotlin 复制代码
@Entity(
    tableName = "news_resources_topics",
    primaryKeys = ["news_resource_id", "topic_id"], // 关联主键
    foreignKeys = [
        ForeignKey(
            entity = NewsResourceEntity::class,
            parentColumns = ["id"], // 关联到NewsResourceEntity#id
            childColumns = ["news_resource_id"],
            onDelete = ForeignKey.CASCADE, // 级联删除
        ),
        ForeignKey(
            entity = TopicEntity::class,
            parentColumns = ["id"], // 关联到TopicEntity#id
            childColumns = ["topic_id"],
            onDelete = ForeignKey.CASCADE,
        ),
    ],
    indices = [ // 建立索引,提升查询速度
        Index(value = ["news_resource_id"]),
        Index(value = ["topic_id"]),
    ],
)
data class NewsResourceTopicCrossRef( // 交叉引用表只含有2列数据,分别是NewsResource和Topic的id
    @ColumnInfo(name = "news_resource_id")
    val newsResourceId: String,
    @ColumnInfo(name = "topic_id")
    val topicId: String,
)

有了上面的交叉引用,就可以在一次查询中,获取到完整的NewsResource对象,它其中包含了相关联的Topic列表。这样的完整NewsResource对象就是PopulatedNewsResource。

kotlin 复制代码
data class PopulatedNewsResource(
    @Embedded // 摊平NewsResourceEntity的属性,并将其嵌入PopulatedNewsResource类当中
    val entity: NewsResourceEntity,
    @Relation(
        parentColumn = "id",           // 父表(NewsResourceEntity)的主键列
        entityColumn = "id",           // 子表(TopicEntity)的主键列(逻辑关联点)
        associateBy = Junction(
            value = NewsResourceTopicCrossRef::class,  // 中间表
            parentColumn = "news_resource_id",        // 中间表中指向父表的外键列
            entityColumn = "topic_id"                  // 中间表中指向子表的外键列
        )
    )
    val topics: List<TopicEntity>,
)

fun PopulatedNewsResource.asExternalModel() = NewsResource( // 转换为外部(model层)的NewsResource
    id = entity.id,
    title = entity.title,
    content = entity.content,
    url = entity.url,
    headerImageUrl = entity.headerImageUrl,
    publishDate = entity.publishDate,
    type = entity.type,
    topics = topics.map(TopicEntity::asExternalModel),
)

fun PopulatedNewsResource.asFtsEntity() = NewsResourceFtsEntity(
    newsResourceId = entity.id,
    title = entity.title,
    content = entity.content,
)

util包------类型转换工具类

util目录下的InstantConverter用于在Long和Instant类型之间进行转换:

kotlin 复制代码
internal class InstantConverter { // 只在模块内部生效,对外隐藏
    @TypeConverter
    fun longToInstant(value: Long?): Instant? =
        value?.let(Instant::fromEpochMilliseconds)

    @TypeConverter
    fun instantToLong(instant: Instant?): Long? =
        instant?.toEpochMilliseconds()
}

在同模块的NiaDatabas类中,借用@TypeConverters注解调用转换能力。

kotlin 复制代码
// NiaDatabase.kt
@TypeConverters(
    InstantConverter::class,
)

dao包------CRUD操作

以Topic主题类的数据库操作为例,TopicDao中提供了增删改查等操作:

kotlin 复制代码
@Dao // ROOM注解,声明Dao对象
interface TopicDao {
    @Query( // 绑定SQL语句,注意占位符是:topicId
        value = """
        SELECT * FROM topics
        WHERE id = :topicId
    """, // 三引号原始字符串表示法,支持换行
    )
    fun getTopicEntity(topicId: String): Flow<TopicEntity> // 使用Flow包装返回值,且返回的是Entity对象

    @Query(value = "SELECT * FROM topics")
    fun getTopicEntities(): Flow<List<TopicEntity>>

    @Query(value = "SELECT * FROM topics")
    suspend fun getOneOffTopicEntities(): List<TopicEntity>

    @Query(
        value = """
        SELECT * FROM topics
        WHERE id IN (:ids)
    """,
    )
    fun getTopicEntities(ids: Set<String>): Flow<List<TopicEntity>>

    /**
     * Inserts [topicEntities] into the db if they don't exist, and ignores those that do
     */
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun insertOrIgnoreTopics(topicEntities: List<TopicEntity>): List<Long>

    /**
     * Inserts or updates [entities] in the db under the specified primary keys
     */
    @Upsert // 插入或更新
    suspend fun upsertTopics(entities: List<TopicEntity>)

    /**
     * Deletes rows in the db matching the specified [ids]
     */
    @Query(
        value = """
            DELETE FROM topics
            WHERE id in (:ids)
        """,
    )
    suspend fun deleteTopics(ids: List<String>)
}

di包--生成Database和Dao对象

借助于Hilt的依赖注入框架,在应用进程启动时,生成相应的单例object。

kotlin 复制代码
@Module // 告诉Hilt这是一个提供依赖的模块
@InstallIn(SingletonComponent::class) // 单例,作用域为全应用生命周期
internal object DatabaseModule {
    @Provides // 提供NiaDatabase实例
    @Singleton // 只创建一个实例
    fun providesNiaDatabase(
        @ApplicationContext context: Context, // Hilt自动提供的ApplicationContext
    ): NiaDatabase = Room.databaseBuilder(
        context,
        NiaDatabase::class.java,
        "nia-database",
    ).build()
}

@InstallIn@Singleton两个注解共同作用,实现单例的效果

- @InstallIn(SingletonComponent::class) 是模块的"安装位置",决定依赖项的可见性作用域层级

  • @Singleton 是依赖项的"生命周期约束",确保实例在作用域内唯一

二者必须配合使用,才能实现全局单例的数据库实例。

DaosModule.kt声明了各个数据表向上层暴露的Dao对象,注意这里面不含交叉引用表,因为交叉引用属于底层实现,对于feature层而言它是不可见的。

NiaDatabase------创建及更新数据库对象

kotlin 复制代码
// NiaDatabase.kt
@Database(
    entities = [ // 数据库中包含的表
        NewsResourceEntity::class,
        NewsResourceTopicCrossRef::class,
        NewsResourceFtsEntity::class,
        TopicEntity::class,
        TopicFtsEntity::class,
        RecentSearchQueryEntity::class,
    ],
    version = 14,
    autoMigrations = [ // 版本迁移
        AutoMigration(from = 1, to = 2),
        AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class),
        AutoMigration(from = 3, to = 4),
        AutoMigration(from = 4, to = 5),
        AutoMigration(from = 5, to = 6),
        AutoMigration(from = 6, to = 7),
        AutoMigration(from = 7, to = 8),
        AutoMigration(from = 8, to = 9),
        AutoMigration(from = 9, to = 10),
        AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class),
        AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class),
        AutoMigration(from = 12, to = 13),
        AutoMigration(from = 13, to = 14),
    ],
    exportSchema = true,
)
@TypeConverters(
    InstantConverter::class,
)
internal abstract class NiaDatabase : RoomDatabase() { // 声明成抽象类,由Room自动实现
    abstract fun topicDao(): TopicDao
    abstract fun newsResourceDao(): NewsResourceDao
    abstract fun topicFtsDao(): TopicFtsDao
    abstract fun newsResourceFtsDao(): NewsResourceFtsDao
    abstract fun recentSearchQueryDao(): RecentSearchQueryDao
}

:core:database模块总结

  • 遵循 关注点分离 原则,负责以数据库形式提供数据层的一种实现,将Entity与更底层的model进行隔离
  • 利用Hilt框架,通过注解自动创建对象,并绑定到对应的生命周期(这里是Application全生命周期)
  • 利用Room框架,按照Entity-Dao-Database粒度组织数据库
  • 通过交叉引用简化查找操作,提升查询效率,并且将Topic与NewsResource进行解耦
相关推荐
-代号95275 小时前
【RocketMQ】二、架构与核心概念
架构·rocketmq·java-rocketmq
ChinaRainbowSea6 小时前
Linux: Centos7 Cannot find a valid baseurl for repo: base/7/x86_64 解决方案
java·linux·运维·服务器·docker·架构
技术流Garen7 小时前
MCU与SFU:实时音视频通信架构的对比
架构·实时音视频
小华同学ai9 小时前
2K star!三分钟搭建企业级后台系统,这款开源Java框架绝了!
后端·架构·github
福鸦10 小时前
C++ STL深度解析:现代编程的瑞士军刀
开发语言·c++·算法·安全·架构
DemonAvenger10 小时前
深入Go并发编程:Goroutine性能调优与实战技巧全解析
设计模式·架构·go
*星星之火*11 小时前
【Flink银行反欺诈系统设计方案】3.欺诈的7种场景和架构方案、核心表设计
大数据·架构·flink
洛北辰南11 小时前
系统架构设计师—系统架构设计篇—轻量级架构
架构·系统架构·ssh·ssm·轻量级架构
道法自然,人法天11 小时前
微服务的认识与拆分
微服务·云原生·架构