构建离线优先的 React 应用

构建离线优先的 React 应用

长久以来,在开发移动端应用(包括 Web 应用,以下简称「应用」)时,我们习惯性地将「离线」当作一种错误来对待。作为应用的创建者,我们在越来越稳定和快速的办公网络下设计和开发着这些应用,渐渐地对那些做不到时刻在线的用户们失掉了同理心。实际上这些「掉了线」的用户离我们并不远,他们也许是通勤路上不得不挤进没有信号的地铁中的上班族,也许是喜欢拿着手机跑进 Wi-Fi 鞭长莫及的厕所蹲马桶的游戏玩家。他们在我们身边,他们就是我们。

前些年移动优先(Mobile First)的概念喊得火热,而移动的网络往往是不稳定的,渐进式网页应用 (Progressive Web Apps)概念顺理成章地流行了起来,越来越多的应用提供了对离线的支持。这样的事实使得我们重新思考「离线」对于应用来讲到底是什么。

拥抱离线优先

作为一款主打文档记录的办公产品,提供离线支持对于石墨文档来说意义非常大。然而不同的用户有着不同的使用场景,每种使用场景又会产生不同的离线操作闭环需求:笔记型的用户希望能够在离线的情况下创建文档、编写内容、修改标题以及删除文档;协作型的用户则想要在离线的时候阅读文档、划词留言以及回复别人的评论。为了满足这些复杂而又琐碎的离线需求。我们最终确定了离线优先的开发模式:

离线不是一种错误,在线则是一种特性。

离线优先指的是开发应用时以离线作为目标场景,同时针对联网的情况增加额外功能。就石墨文档应用而言,我们会预设用户始终处于没有网络连接的状态,并让用户能够在这个状态下可以正常地使用大部分地功能。同时当联网后,我们会额外为用户提供离线同步和多人实时协作等功能。


SOS:离线优先的应用实践

半年前石墨文档新版应用正式立项开发,我们得以将离线优先的理念灌注到这个新生儿当中。

考虑到石墨文档的所有应用均是基于 Redux 打造(iOS 和 Android 端采用了 React Native;移动网页端则通过 React Native for Web 复用移动端的代码做桥接;桌面端自然就是 React),我们的前端工程师基于 Redux 实现了一套跨平台的离线同步解决方案(内部项目名为 SOS,Shimo Offline Solution)。下图是 SOS 的示意图:

SOS 的核心理念是「先修改后同步」,用户对数据的所有修改都是直接写入到本地数据中(即使当前是联网状态)并实时得到反馈,应用会在后台通过独立的组件定时将这些修改和服务器进行同步。这个理念也使得我们在贯彻「离线优先」的同时,也自然而然地实现了「乐观 UI」的概念,进一步提升了用户的使用体验。

图中绿线以下的部分为 SOS 的内部实现:SOS 以 Redux 中的 Action 和 Selector 作为输入输出向应用提供接口:当应用需要操作数据时,向 SOS 派发相应的 Action,SOS 内部会通过 Reducer 更新相应的 Store State。此时借助 react-redux,应用中所有的 Smart Component 都会重新调用 SOS 提供的 Selector 来实现自动视图更新。图中有两个核心的概念:


1. Mapped Data Store

即 Redux 中的 Store,用户所有的数据均存储在其中。SOS 中的 Store 可以看作全站数据库面向该用户的约束(Restriction)子集,从 API 获取数据时 SOS 会通过 Wormalize(类似 Normalizr,增加了通过 Schema 反向获取数据的功能)对返回结果进行规范化。如获取用户收藏的文件列表接口的返回结果是:

[
  { id: 'baf6', name: '文档一', content: 'xxxx', favorited: true },
  { id: 'y09a', name: '文档二', content: 'xxxx', favorited: true },
  { id: 'z8d1', name: '文档三', content: 'xxxx', favorited: true }
]

经过 Wormalize 处理后,存储到 Mapped Data Store 中的内容就会转换为:

{
  files: {
    baf6: { name: '文档一', content: 'xxxx' },
    y09a: { name: '文档二', content: 'xxxx' },
    z8d1: { name: '文档三', content: 'xxxx' }
  },
  favorites: ['baf6', 'y09a', 'z8d1']
}

数据存储规范化有两个最大的好处:一个是利于更新,当某个文件内容变化时,可以只修改一处,而不用把所有引用到该文件的地方都进行更新;另一个是节约内存占用,同一个文件内容只会存储在一处,避免了多个副本造成的空间浪费。

2. Object Diff

前面提到过用户对数据的操作都是直接写入到本地数据,然后异步地和服务器进行数据同步。完成这一同步过程的组件便是 Object Diff。

Object Diff 是一个支持追踪 State 内容改动的数据结构和算法套件。SOS 会在特定时候(State 发生变化且当前已经联网或者从离线状态变成联网状态)调用 Object Diff 来拿到变化的数据,然后为这些数据生成不同的 API 调用。API 调用成功后则回调给 Object Diff 来把相关差异信息标记为已完成;如果失败了,则通过 Object Diff 回滚相应的数据。

举个例子,假设当前本地的 State 内容为:

{
  files: {
    baf6: { name: '文档一' }
  },
  favorites: ['baf6']
}

里面包含一个 ID 为 baf6、标题为「文档一」的文档,同时被加入了收藏。当用户进行一系列操作后,State 的内容变为:

{
  files: {
    baf6: { name: '第一个文档' },
    cw12: { name: '第二个文档' }
  },
  favorites: ['cw12']
}

可以看到用户将 baf6 这个文档的标题改成了「第一个文档」,同时将取消了对这个文档的收藏。另外又创建了一个新文档「第二个文档」并将其加入收藏。在这个例子中,如果我们调用 Object Diff 的获取两个 State 之间的差异,那么会拿到如下结果:

{
  diff: {
    files: [
      { action: 'update', path: 'baf6.name', from: '文档一', to: '第一个文档' },
      { action: 'insert', path: 'cw12' }
    ],
    favorites: [
      { action: 'remove', value: 'baf6' },
      { action: 'insert', value: 'cw12' }
    ]
  }
}

接下来,SOS 会将这个结果转换成若干个对石墨文档 RESTful API 的调用:

1. PATCH /files/baf6 { name: '第一个文档' }
2. POST /files { name: '第二个文档' }
3. DELETE /favorites/baf6
4. PUT /favorites/{第二个调用返回的文件ID}

值得注意的是第四个调用依赖第二个调用返回的文件 ID 而不是直接用 State 中的 cw12,这是因为客户端在离线状态下创建文件时,需要为这个文件生成一个本地 ID 来使所有依赖文件 ID 的 Action 也能处理未同步的文件,这里的 cw12 就是一个本地 ID。但是把新创建的文件同步给服务器会造成客户端与服务端生成的 ID 不一致。一些框架和实践采用了不同的方法来解决这个问题,如 UUID + Per-User Token 和 Meteor 的共享随机 seed 方式,然而这些方式都对服务端的具体实现与 ID 的格式有所要求,无法成为一个非侵入的通用解决方案。

既然客户端难以生成与服务端一致的 ID,我们转而将方向调整为解决同步成功后新旧 ID 兼容的问题:每次文件(或者其他对象,如评论)同步成功时,我们都会为新旧 ID 生成一个映射。下次应用通过 Selector 取数据时,即使提供的是旧 ID,Selector 也会将其映射成新 ID 从而正确地取回内容,整个过程对应用是透明的。

懒加载模式

因为和用户有关的数据都是存储在 Store 中的,所以很容易导致 Store 的内存占用过多。拿我们的场景举例,一个用户可能会有几百甚至几千个文件,每个文件的内容一般为几 KB,这样计算下来仅文件的内容就要占用十几兆的内存。这些内存如果放到桌面端当然显得无足挂齿,但是一个跨平台的离线解决方案也需要考虑到一些低性能设备(如低配置的 Android 设备对每个应用可以使用的内存空间有相对苛刻的限制)的使用。

考虑到虽然用户的数据总量很多,但是每次访问网页(或启动 App)后用户使用的数据相对较少(往往只会用到有限的几个文件),我们采用了一套数据唤醒的逻辑实现了懒加载模式,保证即使磁盘上预加载了很多数据,也只有用户需要用到的部分才会被加载到内存中。这一步借助了 Wormalize 的 dewormalize 功能,使得 SOS 可以直接按照 Schema 来从持久化层中载入嵌套的对象。如下代码定义了一个文件 Schema:

import { Schema } from 'wormalize'
export const userSchema = new Schema('users')
export const fileSchema = new Schema('files')

fileSchema.define({
  author: userSchema, // 文件作者
  lastUpdatedBy: userSchema  // 文件最后更新者
})

SOS 内部需要从持久化层加载数据时只需要执行:

import { restoreSchema } from './actions'
import { fileSchema } from './schemas'
import { selectFile } from './reducers/files'

store.dispatch(restoreSchema(
  { file1: 'b8f1', file2: 'c890' }, // 实际要加载的 id 列表
  { file1: fileSchema, file2: fileSchema } // 与列表相对应 Schame
)).then(() => {
  const file1 = selectFile(store.getState(), 'b8f1')
  console.log('Author of file1 is', file1.author.name)
})

这样就会把文件、文件作者和文件最后更新者的数据从持久化层加载到 State 上了。

总结与扩展阅读

上个月我们刚刚在应用商店上架了基于 SOS 的 iOS 应用,收到了非常多积极的反馈。未来我们会继续对这个话题进行深度地探索,同时也会逐渐将 SOS 中核心的组件开源出来,并希望能够与开源社区以及相关厂商一起不断地提升用户的应用使用体验。

这里推荐一些文章,有兴趣的同学可以对相关的话题进行更深入的研究:

Offline First 概述

Introducing Redux Offline - 另一个借助 Redux 实现 Offline First 的实践

Google Groups 上关于 normalizr 在的讨论

True Lies Of Optimistic User Interfaces - 非常全面地介绍了乐观 UI

编辑于 2017-06-15 15:24