丈夫当死中图生,祸中求福; 古人有困儿修德,穷而著书。------《曾国藩传》
对于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
的数据表。
请留意,
shortDescription
、longDescription
这两个字段都是非空String
类型,而后者增加了@ColumnInfo(defaultValue = "")
注解,表示在写数据时如果该字段是UNDEFINED
,则自动赋予其""
作为初始值。然而,由于它们在Kotlin代码中都被声明为非空String
类型,所以在插入/更新数据时,压根不会出现longDescription
为UNDEFINED
的情况。
在同一个文件中,提供了扩展函数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进行解耦