【NowInAndroid架构拆解】(4)数据层的设计和实现之data

333 阅读8分钟

闻过则喜,知过不讳,改过不惮。


【NowInAndroid架构拆解】系列文章


这是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包内部有两个类,分别是生成UserNewsResourceRepositoryinterface UserNewsResourceRepositoryModule和生成TopicsRepositoryNewsResourceRepository等的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来负责网络-本地的同步;两个流程彼此独立
  • 响应速度快,本地数据库必然比网络接口响应快
  • 可离线使用

这样做的缺点在于,可能无法及时获取到最新的文章数据,但是可以通过设定合理的同步策略,来降低这种不利影响。

下面我们从触发数据同步开始捋一遍源码。有两个触发同步的入口。

  1. 应用进程初始化
  2. 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,非常巧妙。