【现代 Android APP 架构】06. 构建一个离线也可使用的 APP

1,161 阅读12分钟

普遍的做法是,选择一种方法,试试看;如果失败了,没关系,再试试别的方法。 不管怎么样,重要的是先去尝试。 ——《人月神话》

在什么时候需要离线优先应用

首先定义什么是 “离线优先应用(offline-first app)”。离线优先应用是指在断网状态下能够运行全部、或者至少是核心功能的软件。

尽管国内目前基站覆盖已经十分全面,但仍然存在着弱网/断网的场景,例如坐火车通过隧道、飞机航行中、电梯内部等,如果你的应用软件有预设在这些环境下仍然能正常运行的目标,那就应该围绕着离线可用进行设计。

如果应用程序的设计者认为,在这些极端环境下不存在使用的场景,那就简单设计一个网络状态异常页面即可,无需过度设计。

离线应用还有一个优点在于,它是优先加载本地数据的,意味着它的 页面首帧展现时间 要优于依赖互联网连接的在线应用。

在设计离线应用时,主要考虑哪些问题?

  • 数据层开始设计,将网络数据源本地数据源拆分
  • 在读数据的过程中,优先读取本地数据
  • 在写数据的过程中,如果用户离线,则需要将写的行为本地持久化
  • 数据仓库层负责将云端/本地数据源进行融合,并向上隐藏数据具体来源

数据层设计

一个离线优先应用,至少应当具备网络、本地两个数据源。

本地数据源

本地数据源,是更高层次模块(Domain、Business、UI 等)的唯一数据来源,这是数据一致性最基本的保证。

  • 对于结构化数据,使用 Room 数据库
  • 对于非结构化数据,使用 protobuf 进行序列化后,保存在 Datastore
  • 使用文件保存 json 配置等

网络数据源

网络数据源保存了应用在云端的状态,它可能比本地状态更新,也可能比本地状态更老。

为不同数据源区分设计 model

针对同一个业务实体,本地数据源、网络数据源可以具有不同的 model,这样做可以提供更大的灵活性,将本地/网络数据源的数据结构进行解耦。

NowInAndroid 中的“文章作者”为例,在本地使用 AuthorEntity,对应 Room 数据库中的 Entity,网络数据则使用 NetworkAuthor 类。向上层业务暴露出的是业务模型 Author

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

网络、本地数据对象设计如下,虽然目前两者的字段是一一对应的,但将它们独立出来,有利于后续更自由地进行扩展。

/**
 * Network representation of [Author]
 */
@Serializable // ===> 网络数据需要序列化后通过 HTTP 协议传输
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") // ===> 本地数据则通过 Room 进行持久化
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,
)

接下来是业务层 Author 类,它的字段与网络/本地数据源相同。区别之处在于它不需要任何注解(序列化、Room),因为不会对其进行持久化保存。因为它足够简单,所以它足够高效。

/**
 * 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,
)

定义扩展函数,进行不同数据源对象之间的转换。转换是单向的,网络数据源 -> 本地数据源 -> 业务对象

注意转换函数的命名是 asEntity() ,而非我(作为中国程序员)习惯使用的 toEntity()。因为 as(作为)可以理解成同一个对象的不同展示面,而 to(转为)则隐含有新创建一个对象的意思。

/**
 * 网络数据源 ===> 本地数据源
 */
fun NetworkAuthor.asEntity() = AuthorEntity(
    id = id,
    name = name,
    imageUrl = imageUrl,
    twitter = twitter,
    mediumPage = mediumPage,
    bio = bio,
)

/**
 * 本地数据源 ===> 业务对象
 */
fun AuthorEntity.asExternalModel() = Author(
    id = id,
    name = name,
    imageUrl = imageUrl,
    twitter = twitter,
    mediumPage = mediumPage,
    bio = bio,
)

数据读过程

对于离线优先应用而言,读数据是非常重要的环节,开发者应当保证在应用离线状态下有数据可读,并将其正常显示在 UI 上。

在下面的例子中,离线数据仓库 OfflineFirstTopicRepository 通过返回 Flows,对外暴露出读数据的接口。它集成了本地、网络数据源,在读取数据时优先返回本地数据。

class OfflineFirstTopicsRepository(
    private val topicDao: TopicDao, // ===> 作为参数提供本地、网络数据源
    private val network: NiaNetworkDataSource,
) : TopicsRepository {

    override fun getTopicsStream(): Flow<List<Topic>> =
        topicDao.getTopicEntitiesStream()
            .map { it.map(TopicEntity::asExternalModel) } // ===> 优先返回本地数据,转化为外部业务 Model
}

读数据过程中的异常处理

异常处理-本地数据源

尽管读取本地数据库时,发生异常的几率非常小,但仍然需要应对万分之一的可能,以增强应用稳定性,防止闪退。

使用 Flows 中的 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()) } // ===> 捕获到异常时,发射一个空 Author 对象
}

异常处理——网络数据源

与本地不同的是,网络异常通常伴随着重试。在重试时可以采取 指数退让 算法,避免短时间内爆发大量请求,造成服务器拥堵。

对于重试机制,还可以设计以下条件,提升重试效率,降低不必要的重试请求:

  • 根据不同的状态码决定是否重试,例如 500(Internal Server Error) 时,可能是服务器短暂宕机,此时应当进行指数退让后继续重试。但对于 401(Unauthorized),即使再多重试也是徒劳,因为发送的请求里没有包含认证信息。
  • 设定最大重试次数,避免无用功。

数据写过程

写数据属于耗时操作,应当使用异步过程,或者是 Kotlin 中的 suspend 函数,以免阻塞 UI 线程,同时做好异常捕获。

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

写数据过程中的策略选择

读数据比较简单,只要读取本地数据源就可以。相比之下,写数据要考虑的事情就多了,例如在断网情况下写入的数据,应当在联网后更新到服务器。如果多次写入,还需要将写操作维护成队列,并进行持久化。

通常有三种写策略可供选择:

  • 仅联网状态写 —— 实时性要求最高
  • 写队列 —— 先加入队列,待联网后更新至网络,并写入本地数据源
  • 懒写入 —— 先写入本地数据源,待联网后更新至网络

仅联网状态写

  1. 尝试调用云端接口进行写入网络数据源
  2. 若成功则更新本地数据源
  3. 若失败则抛异常,由上层处理

这种方式适用于实时性要求高的事务性操作,例如银行转账,如果网络请求失败就立即抛出给用户,而不是将操作保存在离线数据库,待下次联网时重放。

对于提示用户操作失败的场景,建议采用 Dialog失败页面等强提醒给用户。

写队列

将写操作插入一个先进先出的队列,离线状态下维护队列(持久化),恢复网络连接后再依次执行。WorkManager 通常用于处理此类任务。 同样是在网络数据源写成功后,更新本地数据源。

相比于“仅联网状态写”,增加了一个 写失败后加入队列 的操作。

具备如下特征的操作,适用于“写队列”,例如数据分析、日志系统

  • 即使调用网络接口上传失败也不影响主要功能
  • 操作无实时性需求
  • 用户不关心操作成功/失败

懒写入

首先写入本地数据源,然后将写入操作加入队列,以便尽快通知网络数据源。这种实现方式存在一个潜在的风险 —— 当应用恢复在线时,网络和本地数据源之间可能会发生冲突。下一节将详细介绍冲突解决。

当数据对应用至关重要时,这种方法是正确的选择。例如,在一个离线优先的 Todo-List APP 中,用户离线添加的所有任务都必须存储在本地,以避免数据丢失的风险。

数据同步与冲突处理

当应用从离线状态恢复为在线状态时,需要将本地数据源与网络数据源进行 数据同步,有两种方式。

  • 基于拉取的数据同步
  • 基于推送的数据同步

基于拉取的数据同步

适用于 用户主动触发、向服务器端拉取最新信息 的应用场景,例如抖音视频、小红书信息流页面。这种模式可采用 Jetpack Paging Library 以及 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
}

基于拉取的数据同步,优点是:

  1. 逻辑实现简单,方便复用
  2. 只拉取用户关心的数据(新视频等),对于用户不关心、不使用的数据不会进行拉取

缺点有:

  1. 网络传输任务重,所获取的信息都是未经缓存的新数据

基于推送的数据同步

在基于推送的数据同步中,应用本地维护了完整的数据备份,以便在离线状态下使用。同时,当网络服务器数据发生变化时,会 推送 变化通知给客户端,再由客户端本地数据源对新数据执行拉取操作。

这种实现类似于 Git 版本管理,在本地拥有一份远端仓库镜像,并且在此基础上,增加了远端代码变更后的主动通知功能。

class UserDataRepository(...) {

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

基于推送的数据同步,优点是:

  1. APP 可以在离线状态下使用大部分功能
  2. 数据同步量小,APP 只去拉取数据中变化的部分,对于不变的则继续使用本地数据

缺点则有:

  1. 需要小心实现处理数据冲突逻辑 —— 想想 git 吧
  2. 要处理数据同步过程中,触发写操作的场景
  3. 服务器需要支持发生数据变化时进行推送通知

混合式数据同步

一些应用会根据数据情况采用基于拉取或推送的混合同步方式。例如,由于 动态更新频率较高微博 这样的社交媒体应用可能会使用 基于拉取的同步方式按需获取用户关注的动态。同时选择使用 基于推送的同步方式来获取已登录用户的数据,包括其用户名、个人资料图片等。

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

数据同步过程中的冲突处理

对于离线可用的 APP 而言,必然会面对的一个问题是冲突处理,或者说是数据融合。这在写数据过程中有几率发生,想象一下,最初仅存在一份数据 A,本地数据源将其修改成了 A',服务器端则将其更新成 A''。对于客户端和服务器而言,都需要进行冲突处理的工作。

个人的建议是先由客户端/服务器的一方进行处理,并且将处理后的结果同步给另一方。

在处理冲突时,有多种策略可供选择,使用最广泛也最容易理解的是 “后来者优先” 的原则。

使用 WorkManager 完成数据同步任务

在离线数据、网络数据的同步过程中,依赖于两个机制:队列网络状态监听

  • 队列
    • 读取: 用于将读取操作推迟到网络连接可用为止。
    • 写入: 用于将写入操作推迟到网络连接可用为止,并重新排队写入操作以进行重试。
  • 网络状态监听
    • 读取: 在应用程序连接时用作清空读取队列的信号,并用于同步。
    • 写入: 在应用程序连接时用作清空写入队列的信号,并用于同步。

WorkManager 是很好的提供上述机制的组件。例如,在 NowInAndroid APP 里面,WorkManager 负责执行以下操作。

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

在上图中, WorkManager 负责在应用启动时拉取最新数据,并将最新数据更新到本地数据源中,以供上层业务使用。

class SyncInitializer : Initializer<Sync> {
   override fun create(context: Context): Sync {
       WorkManager.getInstance(context).apply {
           // ===> APP 启动时加入同步任务,KEEP 保证任意时刻只有一个同步任务在运行
           enqueueUniqueWork(
               SyncWorkName,
               ExistingWorkPolicy.KEEP,
               SyncWorker.startUpSyncWork()
           )
       }
       return Sync
   }
}

SyncWorker.startupSyncWork() 定义如下,运用依赖注入技术,可以跨模块绑定。

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)
    // ===> 把数据同步代理给 SyncWorker.
   .setInputData(SyncWorker::class.delegatedData())
   .build()

val SyncConstraints
   get() = Constraints.Builder()
       .setRequiredNetworkType(NetworkType.CONNECTED) // ===> 约束条件:网络有连接
       .build()

同步的业务逻辑写在 SyncWorker 类里面。

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

    override suspend fun doWork(): Result = withContext(ioDispatcher) {
        // ===> 并行同步
        val syncedSuccessfully = awaitAll(
            async { topicRepository.sync() },
            async { authorsRepository.sync() },
            async { newsRepository.sync() },
        ).all { it }

        if (syncedSuccessfully) Result.success() // ===> 3个任务全部成功
        else Result.retry() // ===> 任一任务失败则重试
    }
}

参考资料