闻过则喜,知过不讳,改过不惮。
【NowInAndroid架构拆解】系列文章
- 【NowInAndroid架构拆解】(1)分层设计与模块化
- 【NowInAndroid架构拆解】(2)数据层的设计和实现之model与database
- 【NowInAndroid架构拆解】(3)数据层的设计和实现之network
- 【NowInAndroid架构拆解】(4)数据层的设计和实现之data
- 【NowInAndroid架构拆解】(5)VM层的设计和实现之ForYouViewModel
- 【NowInAndroid架构拆解】(6)View层的设计和实现之Navigation路由
- 【NowInAndroid架构拆解】(7)UI层解析——MainActivity构建过程
- 【NowInAndroid架构拆解】(8)UI层解析——ForYou页面展示
- 【NowInAndroid架构拆解】(9)重新审视NowInAndroid架构设计
- 【NowInAndroid架构拆解】番外篇1之Jetpack Compose Navigation
- 【NowInAndroid架构拆解】番外篇2之Bottom Navigation底部导航
- 【NowInAndroid架构拆解】番外篇3之给xml布局者最佳的Jetpack Compose介绍文章
这是NIA系列文章中,数据层部分的最后一篇。在这篇博客中我将首先从宏观角度纵览数据的分层设计思路,随后分析数据仓库层:core:data
的实现。
数据的分层设计
采用自底向上顺序,将整个NIA项目的数据层拆分如下表。
层次 | 说明 | 对上层暴露接口 |
---|---|---|
数据源Source | 数据的存储方式,如服务器、数据库、文件 | 单例,NetworkDataSource(服务器)、XXXDao(数据库),以Bean作为函数返回 |
数据仓库Repository | 封装底层具体存取实现,通常会组合2~3个底层接口对象 | 单例,XXXRepository,以Flow对象作为函数返回 |
领域Domain | 拆分后的业务基本能力,类似“数据中台” | XXXUseCase,供ViewModel使用,以Flow对象作为函数返回值 |
特性Feature | 对应到页面粒度的ViewModel | 向上传递数据,向下传递控制 |
按照package拆分
- di: 通过依赖注入生成各个Repository的单例
- model: network层的数据对象,同样会将其转换为通用的Topic、NewsResource等来使用
- repository: 依照业务实体划分的各个Repository,对外(上层业务)暴露的主要接口,返回的数据格式为Flow
- util: 请求监控、数据同步等工具类
di——依赖注入绑定默认的Repository
di包内部有两个类,分别是生成UserNewsResourceRepository
的interface UserNewsResourceRepositoryModule
和生成TopicsRepository
、NewsResourceRepository
等的abstrace class DataModule
。
疑问:同样是借助Hilt注入能力自动生成单例对象,为什么使用了接口、抽象类两种不同的写法?是否可以只采用接口来实现?
是否可以只采用接口来实现?——在当前项目的场景中,这个问题的答案是肯定的。首先来学习了解下这两种方式的区别。
知识点:Hilt的两种抽象注入方法
Hilt框架提供了抽象类、接口两种注入方式,这两种注入方式的区别其实也就是抽象类、接口之间的区别,比如抽象类可以含有属性及非抽象函数等。
特性 | 抽象类写法 | 接口写法 |
---|---|---|
类类型 | 必须声明为 abstract class | 必须声明为 interface |
方法修饰符 | 需显式添加 abstract 关键字 | 方法默认抽象,无需 abstract 关键字 |
构造函数 | 可以有构造函数(但 Hilt 模块不需要) | 不能有构造函数 |
成员变量 | 可声明非静态属性 | 不能有属性(仅支持抽象方法) |
多继承 | 单继承(Kotlin 单继承限制) | 支持多实现多个接口 |
代码风格 | 更贴近传统 Java 风格 | 更符合 Kotlin 简洁风格 |
在具体代码中,这两种写法有些微的不同。
// 抽象类写法(必须显式声明 abstract)
@Module
abstract class DataModule {
@Binds
abstract fun bindsRepo(impl: Impl): Interface
}
// 接口写法(默认抽象,无需关键字)
@Module
interface UserRepoModule {
@Binds
fun bindsRepo(impl: Impl): Interface // 隐式抽象
}
如果需要结合@Binds
和@Provides
,则只能使用抽象类,因为后者依赖于完整的函数实现。
@Module
abstract class HybridModule {
@Binds
abstract fun bindInterface(impl: Impl): Interface
@Provides
fun provideUtility(): Utility { // 接口无法添加此方法
return Utility()
}
}
如果不需要@Provides
,那么使用抽象类和接口是等价的,两种写法最终都会被 Hilt 处理为相同的依赖注入逻辑,没有性能或功能上的差异。出于接口契约性和编码简洁的考虑,最佳编程实践是使用接口Interface
。
另一个知识点:通过@Provides生成供注入对象
前文中的@Binds
用于在抽象函数声明中进行注入,自动转换并提供抽象接口的具体实现。而@Provides
则用于生成目标对象,可以对生成的对象进行个性化定制。例如network模块里面用于生成Json解析器
的类。
@Provides
@Singleton
fun providesNetworkJson(): Json = Json {
ignoreUnknownKeys = true
}
Repository——封装后的数据仓库
repository包内部大部分都是以XXXRepository
命名的类,Repository中会携带部分的业务逻辑,通常是对数据库/网络查找出来的列表进行map
转换等操作,生成的对象是model模块
中的基础类型。
// NewsRepository.kt
interface NewsRepository : Syncable {
fun getNewsResources(
query: NewsResourceQuery = NewsResourceQuery(
filterTopicIds = null,
filterNewsIds = null,
),
): Flow<List<NewsResource>>
}
这里面以NewsRepository为例,它是一个抽象接口,具体实现有三种。
- FakeNewsRepository: 从assets中的JSON文件里加载模拟数据
- TestNewsRepository: 在代码里生成模拟数据
- OfflineFirstNewsRepository: 优先取数据库数据,数据库与网络进行同步
可以看到上面的代码段里,getNewsResources
函数返回的是Flow
包装后的新闻列表,这是为了与上层ViewModel结合使用,实现观察者模式。
接下来看OfflineFirstNewsRepository
的具体实现,难点已经加在注释里。
// OfflineFirstNewsRepository.kt
internal class OfflineFirstNewsRepository @Inject constructor( // 通过注入自动生成的单例对象
private val niaPreferencesDataSource: NiaPreferencesDataSource,
private val newsResourceDao: NewsResourceDao,
private val topicDao: TopicDao,
private val network: NiaNetworkDataSource,
private val notifier: Notifier,
) : NewsRepository {
override fun getNewsResources(
query: NewsResourceQuery,
): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(
useFilterTopicIds = query.filterTopicIds != null,
filterTopicIds = query.filterTopicIds ?: emptySet(),
useFilterNewsIds = query.filterNewsIds != null,
filterNewsIds = query.filterNewsIds ?: emptySet(),
) // 注意这里拿到的是Flow<List<PopulatedNewsResource>>,需要将其转换为通用类型NewsResource
.map { it.map(PopulatedNewsResource::asExternalModel) } // 双层map进行转换
override suspend fun syncWith(synchronizer: Synchronizer): Boolean {
// 略
}
}
Sync同步机制
整个data层的数据逻辑,是遵循单一数据源
的原则的,所有UI层使用到的数据均来自于本地数据库,这样做的优点很明显:
- 数据来源统一,主次分明。data层向UI层发送数据时,只关心数据库来源的数据;另有WorkManager来负责网络-本地的同步;两个流程彼此独立
- 响应速度快,本地数据库必然比网络接口响应快
- 可离线使用
这样做的缺点在于,可能无法及时获取到最新的文章数据,但是可以通过设定合理的同步策略,来降低这种不利影响。
下面我们从触发数据同步开始捋一遍源码。有两个触发同步的入口。
- 应用进程初始化
- FCM(Firebase Cloud Messaging)服务下发数据
object Sync {
// This method is initializes sync, the process that keeps the app's data current.
// It is called from the app module's Application.onCreate() and should be only done once.
fun initialize(context: Context) {
WorkManager.getInstance(context).apply {
// Run sync on app startup and ensure only one sync worker runs at any time
enqueueUniqueWork(
SYNC_WORK_NAME,
ExistingWorkPolicy.KEEP,
SyncWorker.startUpSyncWork(),
)
}
}
}
以上是单例Sync
的代码,在Application.onCreate()
中会调用Sync.initialize(applicatinoCtx)
,从而触发首次数据同步。同步的代码位于SyncWorker.startUpSyncWork()
。
同步数据的另一处触发点位于SyncNotificationsService
,它监听了FCM下发的/topics/sync
消息,在收到消息后执行数据同步。由于谷歌在国内不提供服务,所以这种由服务器触发同步的机制行不通。其实这种方式对于客户端来说是性能消耗最小的,可以减少很多不必要的数据请求。
SyncManager.requestSync()
SyncManager
是一个接口,其具体实现位于WorkManagerSyncManager
,这里使用到了WorkManager
机制,它适用于后台执行耗时任务的场景。
// SyncWorker.kt
override suspend fun doWork(): Result = withContext(ioDispatcher) {
traceAsync("Sync", 0) {
analyticsHelper.logSyncStarted()
syncSubscriber.subscribe()
// 并发执行,并且将返回结果做“AND”
val syncedSuccessfully = awaitAll(
async { topicRepository.sync() },
async { newsRepository.sync() },
).all { it } // 等价于 all {it == true}
analyticsHelper.logSyncFinished(syncedSuccessfully)
if (syncedSuccessfully) {
searchContentsRepository.populateFtsData()
Result.success()
} else {
Result.retry()
}
}
}
OfflineFirstNewsRepository.syncWith()
所有的与网络数据存储相关的Repository类,都实现了Syncable
接口,并提供syncWith()
的具体实现,这里以NewsResourceRepository
为例。
// OfflineFirstNewsRepository.kt
override suspend fun syncWith(synchronizer: Synchronizer): Boolean {
var isFirstSync = false
return synchronizer.changeListSync( // 拉取changeList,并对发生变化的对象进行更新
versionReader = ChangeListVersions::newsResourceVersion,
changeListFetcher = { currentVersion ->
isFirstSync = currentVersion <= 0
network.getNewsResourceChangeList(after = currentVersion)
},
versionUpdater = { latestVersion ->
copy(newsResourceVersion = latestVersion) // 有新版本号的处理方式,保存新版本号至SP
},
modelDeleter = newsResourceDao::deleteNewsResources,
modelUpdater = { changedIds ->
val userData = niaPreferencesDataSource.userData.first()
val hasOnboarded = userData.shouldHideOnboarding
val followedTopicIds = userData.followedTopics
val existingNewsResourceIdsThatHaveChanged = when {
hasOnboarded -> newsResourceDao.getNewsResourceIds(
useFilterTopicIds = true,
filterTopicIds = followedTopicIds,
useFilterNewsIds = true,
filterNewsIds = changedIds.toSet(),
)
.first()
.toSet()
// No need to retrieve anything if notifications won't be sent
else -> emptySet()
}
if (isFirstSync) {
// When we first retrieve news, mark everything viewed, so that we aren't
// overwhelmed with all historical news.
niaPreferencesDataSource.setNewsResourcesViewed(changedIds, true)
}
// Obtain the news resources which have changed from the network and upsert them locally
changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds -> // 分批请求新数据,防止服务器压力过大
val networkNewsResources = network.getNewsResources(ids = chunkedIds)
// Order of invocation matters to satisfy id and foreign key constraints!
topicDao.insertOrIgnoreTopics( // 请求完成后更新本地db
topicEntities = networkNewsResources
.map(NetworkNewsResource::topicEntityShells)
.flatten()
.distinctBy(TopicEntity::id),
)
newsResourceDao.upsertNewsResources(
newsResourceEntities = networkNewsResources.map(
NetworkNewsResource::asEntity,
),
)
newsResourceDao.insertOrIgnoreTopicCrossRefEntities(
newsResourceTopicCrossReferences = networkNewsResources
.map(NetworkNewsResource::topicCrossReferences)
.distinct()
.flatten(),
)
}
if (hasOnboarded) {
val addedNewsResources = newsResourceDao.getNewsResources(
useFilterTopicIds = true,
filterTopicIds = followedTopicIds,
useFilterNewsIds = true,
filterNewsIds = changedIds.toSet() - existingNewsResourceIdsThatHaveChanged,
)
.first()
.map(PopulatedNewsResource::asExternalModel)
if (addedNewsResources.isNotEmpty()) {
notifier.postNewsNotifications(
newsResources = addedNewsResources,
)
}
}
},
)
}
通过抽象接口的方式,将检测NewsResource是否有云端更新,以及云端更新后的同步操作反向委派给了NewsResourceRepository,非常巧妙。