构建离线优先应用

离线优先应用是指能够在没有互联网访问的情况下执行所有或其核心功能的关键子集的应用。也就是说,它可以在脱机状态下执行某些或所有业务逻辑。

构建离线优先应用的注意事项始于提供对应用程序数据和业务逻辑访问的数据层。应用可能需要不时从设备外部的来源刷新这些数据。在这样做时,它可能需要调用网络资源以保持最新状态。

网络可用性并非总是得到保证。设备通常会经历网络连接不稳定或缓慢的时期。用户可能会遇到以下情况

  • 有限的互联网带宽
  • 短暂的连接中断,例如在电梯或隧道中。
  • 偶尔的数据访问。例如,仅限 Wi-Fi 的平板电脑。

无论原因如何,应用通常都可以在这些情况下正常运行。为了确保您的应用在脱机状态下正确运行,它应该能够执行以下操作

  • 在没有可靠网络连接的情况下保持可用。
  • 立即向用户显示本地数据,而不是等待第一个网络调用完成或失败。
  • 以一种了解电池和数据状态的方式获取数据。例如,仅在最佳条件下(例如充电时或在 Wi-Fi 上)请求数据获取。

能够满足上述条件的应用通常称为离线优先应用。

设计离线优先应用

在设计离线优先应用时,您应该从数据层开始,以及您可以在应用数据上执行的两个主要操作

  • 读取:检索数据供应用的其他部分使用,例如向用户显示信息。
  • 写入:持久化用户输入以供以后检索。

存储库在数据层负责组合数据源以提供应用数据。在离线优先应用中,必须至少有一个数据源,它不需要网络访问即可执行其最关键的任务。其中一项关键任务是读取数据。

在离线优先应用中建模数据

每个使用网络资源的存储库,离线优先应用至少有两个数据源

  • 本地数据源
  • 网络数据源
An offline-first data layer is comprised of both local and network data sources
图 1:离线优先存储库

本地数据源

本地数据源是应用的规范性数据源。它应该是应用中较高层读取的任何数据的唯一来源。这确保了连接状态之间的數據一致性。本地数据源通常由持久化到磁盘的存储支持。一些将数据持久化到磁盘的常用方法如下

  • 结构化数据源,例如像Room这样的关系数据库。
  • 非结构化数据源。例如,带有Datastore的协议缓冲区。
  • 简单文件

网络数据源

网络数据源是应用程序的实际状态。本地数据源充其量与网络数据源同步。它也可能落后于网络数据源,在这种情况下,应用需要在重新联机时进行更新。反之,网络数据源可能落后于本地数据源,直到应用在恢复连接时能够更新它为止。应用的域层和UI层绝不应该直接与网络层交互。托管repository负责与其通信,并使用它来更新本地数据源。

公开资源

本地和网络数据源在应用如何读取和写入它们方面可能存在根本差异。查询本地数据源可以快速灵活,例如使用SQL查询时。相反,网络数据源可能速度缓慢且受限,例如按ID增量访问RESTful资源时。因此,每个数据源通常都需要其自身提供的数据的表示形式。因此,本地数据源和网络数据源可能具有自己的模型。

下面的目录结构直观地展示了这一概念。AuthorEntity是应用本地数据库读取的作者的表示形式,而NetworkAuthor是通过网络序列化的作者的表示形式

data/
├─ local/
│ ├─ entities/
│ │ ├─ AuthorEntity
│ ├─ dao/
│ ├─ NiADatabase
├─ network/
│ ├─ NiANetwork
│ ├─ models/
│ │ ├─ NetworkAuthor
├─ model/
│ ├─ Author
├─ repository/

AuthorEntityNetworkAuthor的详细信息如下

/**
 * Network representation of [Author]
 */
@Serializable
data class NetworkAuthor(
    val id: String,
    val name: String,
    val imageUrl: String,
    val twitter: String,
    val mediumPage: String,
    val bio: String,
)

/**
 * Defines an author for either an [EpisodeEntity] or [NewsResourceEntity].
 * It has a many-to-many relationship with both entities
 */
@Entity(tableName = "authors")
data class AuthorEntity(
    @PrimaryKey
    val id: String,
    val name: String,
    @ColumnInfo(name = "image_url")
    val imageUrl: String,
    @ColumnInfo(defaultValue = "")
    val twitter: String,
    @ColumnInfo(name = "medium_page", defaultValue = "")
    val mediumPage: String,
    @ColumnInfo(defaultValue = "")
    val bio: String,
)

最佳实践是将AuthorEntityNetworkAuthor都保留在数据层内部,并公开第三种类型供外部层使用。这可以保护外部层免受本地和网络数据源的细微更改的影响,而这些更改不会从根本上改变应用的行为。这在以下代码片段中进行了演示

/**
 * External data layer representation of a "Now in Android" Author
 */
data class Author(
    val id: String,
    val name: String,
    val imageUrl: String,
    val twitter: String,
    val mediumPage: String,
    val bio: String,
)

然后,网络模型可以定义一个扩展方法将其转换为本地模型,类似地,本地模型也具有一个将其转换为外部表示的方法,如下所示

/**
 * Converts the network model to the local model for persisting
 * by the local data source
 */
fun NetworkAuthor.asEntity() = AuthorEntity(
    id = id,
    name = name,
    imageUrl = imageUrl,
    twitter = twitter,
    mediumPage = mediumPage,
    bio = bio,
)

/**
 * Converts the local model to the external model for use
 * by layers external to the data layer
 */
fun AuthorEntity.asExternalModel() = Author(
    id = id,
    name = name,
    imageUrl = imageUrl,
    twitter = twitter,
    mediumPage = mediumPage,
    bio = bio,
)

读取

读取是在脱机优先应用中对应用数据进行的基本操作。因此,您必须确保您的应用能够读取数据,并且一旦有新数据可用,应用就能显示它。能够执行此操作的应用是反应式应用,因为它们使用可观察类型公开读取API。

在下面的代码片段中,OfflineFirstTopicRepository为其所有读取API返回Flows。这使它能够在从网络数据源接收更新时更新其读取器。换句话说,它允许OfflineFirstTopicRepository在本地数据源失效时推送更改。因此,OfflineFirstTopicRepository的每个读取器都必须准备好处理在恢复与应用的网络连接时可能触发的數據更改。此外,OfflineFirstTopicRepository直接从本地数据源读取数据。它只能通过首先更新其本地数据源来通知其读取器数据更改。

class OfflineFirstTopicsRepository(
    private val topicDao: TopicDao,
    private val network: NiaNetworkDataSource,
) : TopicsRepository {

    override fun getTopicsStream(): Flow<List<Topic>> =
        topicDao.getTopicEntitiesStream()
            .map { it.map(TopicEntity::asExternalModel) }
}

错误处理策略

在脱机优先应用中处理错误的方式是独一无二的,具体取决于可能发生错误的数据源。以下小节概述了这些策略。

本地数据源

从本地数据源读取时发生的错误应该很少见。为了保护读取器免受错误的影响,请在读取器从中收集数据的Flows上使用catch运算符。

ViewModel中使用catch运算符的方式如下

class AuthorViewModel(
    authorsRepository: AuthorsRepository,
    ...
) : ViewModel() {
   private val authorId: String = ...

   // Observe author information
    private val authorStream: Flow<Author> =
        authorsRepository.getAuthorStream(
            id = authorId
        )
        .catch { emit(Author.empty()) }
}

网络数据源

如果在从网络数据源读取数据时发生错误,应用将需要采用启发式方法来重试获取数据。常见的启发式方法包括

指数退避

指数退避中,应用会以越来越长的时间间隔不断尝试从网络数据源读取数据,直到成功,或其他条件指示它应该停止。

Reading data with exponential backoff
图2:使用指数退避读取数据

评估应用是否应该继续退避的标准包括

  • 网络数据源指示的错误类型。例如,您应该重试返回指示连接丢失的错误的网络调用。相反,在提供正确的凭据之前,您不应该重试未经授权的HTTP请求。
  • 最大允许重试次数。
网络连接监控

在这种方法中,读取请求会被排队,直到应用确定它可以连接到网络数据源。一旦建立连接,读取请求就会出队,读取数据并更新本地数据源。在Android上,此队列可以使用Room数据库维护,并使用WorkManager作为持久性工作进行清空。

Reading data with network monitors and queues
图3:带有网络监控的读取队列

写入

虽然在脱机优先应用中读取数据的推荐方法是使用可观察类型,但写入API的等效方法是异步API,例如挂起函数。这避免了阻塞UI线程,并有助于错误处理,因为脱机优先应用中的写入在跨越网络边界时可能会失败。

interface UserDataRepository {
    /**
     * Updates the bookmarked status for a news resource
     */
    suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean)
}

在上面的代码片段中,选择的异步API是协程,因为上面的方法会挂起。

写入策略

在脱机优先应用中写入数据时,需要考虑三种策略。您选择哪种策略取决于正在写入的数据类型和应用的要求

仅在线写入

尝试跨网络边界写入数据。如果成功,则更新本地数据源,否则抛出异常并将其留给调用方进行适当的响应。

Online only writes
图4:仅在线写入

此策略通常用于必须在线实时发生的写入事务。例如,银行转账。由于写入可能会失败,因此通常需要向用户传达写入失败的信息,或者阻止用户尝试写入数据。您可以在这些场景中采用的某些策略可能包括

  • 如果应用需要互联网访问才能写入数据,它可以选择不向用户呈现允许用户写入数据的UI,或者至少禁用它。
  • 您可以使用用户无法关闭的弹出消息或临时提示来通知用户他们处于脱机状态。

排队写入

当您有一个要写入的对象时,将其插入队列中。当应用重新联机时,使用指数退避清空队列。在Android上,清空脱机队列是持久性工作,通常委托给WorkManager

Write queues with retries
图5:带有重试的写入队列

如果以下情况适用,则此方法是一个不错的选择

  • 数据是否写入网络并不重要。
  • 事务对时间不敏感。
  • 是否通知用户操作失败并不重要。

此方法的用例包括分析事件和日志记录。

延迟写入

首先写入本地数据源,然后将写入排队以在最早的方便时间通知网络。这并非易事,因为当应用重新联机时,网络和本地数据源之间可能存在冲突。下一节关于冲突解决的内容将提供更多详细信息。

Lazy writes with network monitoring
图6:延迟写入

当数据对应用至关重要时,此方法是正确选择。例如,在脱机优先待办事项应用中,必须将用户离线添加的任何任务存储在本地,以避免数据丢失的风险。

同步和冲突解决

当脱机优先应用恢复连接时,它需要将其本地数据源中的数据与网络数据源中的数据进行协调。此过程称为同步。应用可以有两种主要方式与其网络数据源同步

  • 拉取式同步
  • 推送式同步

拉取式同步

在拉取式同步中,应用按需联系网络以读取最新的应用程序数据。此方法的常见启发式方法是基于导航的,即应用仅在将数据呈现给用户之前获取数据。

当应用预计网络连接断开的时间较短至中等时,此方法最有效。这是因为数据刷新是机会主义的,长时间断开连接会增加用户尝试访问缓存过时或为空的应用目的地的可能性。

Pull based synchronization
图7:拉取式同步:设备A仅访问屏幕A和B的资源,而设备B仅访问屏幕B、C和D的资源

考虑一个应用,其中页面标记用于在特定屏幕的无限滚动列表中获取项目。实现可能延迟联系网络,将数据持久化到本地数据源,然后从本地数据源读取数据以将信息呈现回用户。在没有网络连接的情况下,存储库可能仅请求本地数据源中的数据。这是Jetpack Paging库及其RemoteMediator API使用的模式。

class FeedRepository(...) {

    fun feedPagingSource(): PagingSource<FeedItem> { ... }
}

class FeedViewModel(
    private val repository: FeedRepository
) : ViewModel() {
    private val pager = Pager(
        config = PagingConfig(
            pageSize = NETWORK_PAGE_SIZE,
            enablePlaceholders = false
        ),
        remoteMediator = FeedRemoteMediator(...),
        pagingSourceFactory = feedRepository::feedPagingSource
    )

    val feedPagingData = pager.flow
}

下表总结了拉取式同步的优缺点

优点 缺点
相对易于实现。

容易产生大量数据使用。这是因为重复访问导航目标会触发不必要地重新获取未更改的信息。您可以通过适当的缓存来缓解这个问题。这可以通过使用UI层中的cachedIn操作符,或在网络层中使用HTTP缓存来实现。
不需要的数据永远不会被获取。 由于需要拉取的模型必须自包含,因此在处理关系数据时扩展性不佳。如果要同步的模型依赖于其他模型来填充自身,则前面提到的数据使用量大的问题会变得更加严重。此外,它可能会导致父模型的存储库与嵌套模型的存储库之间产生依赖关系。

推送式同步

在基于推送的同步中,本地数据源会尽其所能地模仿网络数据源的副本集。它在首次启动时会主动获取适当数量的数据以设置基线,之后它依赖于来自服务器的通知来提醒它何时数据已过期。

Push-based synchronization
图 8:基于推送的同步:网络在数据更改时通知应用,应用通过获取更改的数据来响应

收到过期通知后,应用会联系网络以仅更新标记为过期的数据。这项工作被委托给Repository,它会联系网络数据源,并将获取的数据持久化到本地数据源。由于存储库使用可观察类型公开其数据,因此读取器将收到任何更改的通知。

class UserDataRepository(...) {

    suspend fun synchronize() {
        val userData = networkDataSource.fetchUserData()
        localDataSource.saveUserData(userData)
    }
}

在这种方法中,应用对网络数据源的依赖性要小得多,并且可以在没有网络数据源的情况下工作较长时间。它提供离线读取和写入访问权限,因为它假设本地拥有来自网络数据源的最新信息。

下表总结了基于推送的同步的优缺点

优点 缺点
应用可以无限期地保持离线状态。 为冲突解决版本化数据并非易事。
最小的数据使用。应用仅获取已更改的数据。 在同步期间,您需要考虑写入问题。
适用于关系数据。每个存储库只负责获取其支持的模型的数据。 网络数据源需要支持同步。

混合同步

一些应用使用混合方法,根据数据采用基于拉取或推送的方式。例如,社交媒体应用可能会使用基于拉取的同步按需获取用户的关注订阅源,因为订阅源更新频率很高。同一应用可能会选择使用基于推送的同步来处理有关已登录用户的数据,包括其用户名、个人资料图片等。

最终,离线优先同步的选择取决于产品需求和可用的技术基础设施。

冲突解决

如果应用在离线时本地写入的数据与网络数据源不一致,则发生了冲突,您必须在同步之前解决此冲突。

冲突解决通常需要版本控制。应用需要进行一些簿记工作来跟踪更改发生的时间。这使其能够将元数据传递给网络数据源。然后,网络数据源负责提供绝对的事实来源。根据应用的需求,有各种各样的策略可以考虑用于冲突解决。对于移动应用,常见的方法是“最后写入者获胜”。

最后写入者获胜

在这种方法中,设备将时间戳元数据附加到它们写入网络的数据中。当网络数据源收到它们时,它会丢弃任何早于其当前状态的数据,同时接受那些晚于其当前状态的数据。

Last write wins conflict resolution
图 9:“最后写入者获胜” 数据的事实来源由最后写入数据的实体确定

在上面,两个设备都处于离线状态,并且最初与网络数据源同步。在离线时,它们都本地写入数据并跟踪写入数据的时间。当它们都重新上线并与网络数据源同步时,网络通过持久化来自设备 B 的数据来解决冲突,因为它是稍后写入数据的。

离线优先应用中的WorkManager

在上面介绍的读取和写入策略中,有两个常见的实用程序

  • 队列
    • 读取:用于在网络连接可用之前延迟读取。
    • 写入:用于在网络连接可用之前延迟写入,以及重新排队写入以进行重试。
  • 网络连接监视器
    • 读取:用作信号,在应用连接并进行同步时清空读取队列
    • 写入:用作信号,在应用连接并进行同步时清空写入队列

这两种情况都是持久性工作的示例,WorkManager擅长处理此类工作。例如,在Now in Android示例应用中,WorkManager用作读取队列和网络监视器,用于同步本地数据源。在启动时,应用执行以下操作

  1. 将读取同步工作入队,以确保本地数据源和网络数据源之间存在一致性。
  2. 清空读取同步队列,并在应用在线时开始同步。
  3. 使用指数退避从网络数据源执行读取。
  4. 将读取结果持久化到本地数据源,并解决可能发生的任何冲突。
  5. 公开来自本地数据源的数据,供应用的其他层使用。

以上内容在下图中进行了说明

Data synchronization in the Now in Android app
图 10:Now in Android 应用中的数据同步

使用WorkManager入队同步工作,并将其指定为唯一工作,使用KEEP ExistingWorkPolicy

class SyncInitializer : Initializer<Sync> {
   override fun create(context: Context): Sync {
       WorkManager.getInstance(context).apply {
           // Queue sync on app startup and ensure only one
           // sync worker runs at any time
           enqueueUniqueWork(
               SyncWorkName,
               ExistingWorkPolicy.KEEP,
               SyncWorker.startUpSyncWork()
           )
       }
       return Sync
   }
}

其中SyncWorker.startupSyncWork()定义如下


/**
 Create a WorkRequest to call the SyncWorker using a DelegatingWorker.
 This allows for dependency injection into the SyncWorker in a different
 module than the app module without having to create a custom WorkManager
 configuration.
*/
fun startUpSyncWork() = OneTimeWorkRequestBuilder<DelegatingWorker>()
    // Run sync as expedited work if the app is able to.
    // If not, it runs as regular work.
   .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
   .setConstraints(SyncConstraints)
    // Delegate to the SyncWorker.
   .setInputData(SyncWorker::class.delegatedData())
   .build()

val SyncConstraints
   get() = Constraints.Builder()
       .setRequiredNetworkType(NetworkType.CONNECTED)
       .build()

具体来说,由SyncConstraints定义的Constraints要求NetworkTypeNetworkType.CONNECTED。也就是说,它会在网络可用之前等待。

网络可用后,Worker会通过委托给相应的Repository实例来清空由SyncWorkName指定的唯一工作队列。如果同步失败,则doWork()方法将返回Result.retry()。WorkManager 将自动使用指数退避重试同步。否则,它将返回Result.success(),完成同步。

class SyncWorker(...) : CoroutineWorker(appContext, workerParams), Synchronizer {

    override suspend fun doWork(): Result = withContext(ioDispatcher) {
        // First sync the repositories in parallel
        val syncedSuccessfully = awaitAll(
            async { topicRepository.sync() },
            async { authorsRepository.sync() },
            async { newsRepository.sync() },
        ).all { it }

        if (syncedSuccessfully) Result.success()
        else Result.retry()
    }
}

示例

以下 Google 示例演示了离线优先应用。探索它们以在实践中了解这些指南