构建离线优先应用

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

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

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

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

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

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

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

设计离线优先应用

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

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

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

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

对于每个利用网络资源的存储库,离线优先应用至少有 2 个数据源

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

本地数据源

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

  • 结构化数据源,例如关系数据库,如Room
  • 非结构化数据源。例如,使用 Datastore 的协议缓冲区。
  • 简单文件

网络数据源

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

公开资源

本地和网络数据源在应用如何读取和写入它们方面可能存在根本差异。查询本地数据源可以快速且灵活,例如使用 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:仅在线写入

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

  • 如果应用需要 Internet 访问才能写入数据,它可以选择不向用户显示允许用户写入数据的 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 示例演示了离线优先应用。请浏览它们以了解实践中的指导