web + Cordova 的支持离线应用 APP 的实现方案

1,940 阅读18分钟

由于我司的业务特性,需要 APP 能够支持即时在无网络的场景下,也能够正常使用 APP 的功能

那么,为了让一个用 web 前端实现的 APP 能够在无网络的场景下,也能够正常运行程序,这其中的离线方案就需要实现几个关键点:

  • 代码的离线、更新
  • 数据的下载、上传、更新

本篇就想来讲一讲,我们在离线应用方面的实现方案

代码的离线和更新

web 应用不管是网页还是 H5,通常都是在线服务,代码都需要部署到线上服务器

但是离线应用就不能只依赖于网络,在没有网络的场景下,也需要想办法让用户的客户端可以获取到程序代码,这就需要依赖原生 APP 开发的能力了

我们采用的是 web + Cordova 的跨平台 APP 方案:

图片摘自cordova官网文档

Cordova 通过在原生 APP 上运行一个 WebView 来解析、运行 web 程序,而 web 程序通过 Cordova 插件来调用一些原生能力

WebView 加载 web 代码时,支持加载本地的 web 文件,这里的本地自然指的是用户的移动设备

通过 APP 原生开发的能力,在打包 APP 时,就把 web 代码打包进 APP 的安装包(apk/ipa)中,就可以让用户安装 APP 后,在本地拿到 web 代码

就拿 Android 来说,可以让 Android 开发的同事们打包时,把我们的 web 代码打包进 assets 里,这是一个可以存放资源文件的目录,打包时,里面的文件不会被压缩、编码处理

打包结束后,Android 的 APP 是一个 apk 安装包,可以把后缀名改成 zip,然后解压就可以看到我们的 web 文件都已经在安装包内了(assets/www):

手机在安装 APP 时,会将这些 web 代码文件解压到 APP 应用的私有目录下,运行 APP 的时候,WebView 就可以加载用户手机本地的 index.html,资源文件也都通过相对路径加载,这样就能达到将 web 代码文件离线化、程序可直接不依赖于网络被加载运行

我们通过 inspect 来调试时,也可以看到加载的 web 资源文件的路径地址,比如拿 Android 来说,WebView 通过 file 协议来加载存放在 APP 私有目录里的本地 index.html 文件:

image.png

这样一来,代码的离线实现了,但也带来了一个问题:代码如何更新?也就是如何发版?

有两种方案:

  1. 发布新版本的 APP,将新 web 代码文件打包进 APP 里
  2. 热更新机制,先下载新的 web 代码包,再借助 Cordova 插件替换本地的 web 资源,最后重新加载 web 程序

两种方案都可以达到更新 APP 的 web 代码文件的效果,但有各自的优缺点,适用场景也不同:

  • 第一种方案:由于需要依赖于原生 APP 的上架、更新机制,灵活性差、及时性低,通常是只有在 Cordova 插件变动,原生开发层面需要发版时才选择这种方案

  • 第二种方案:需要前端在程序里实现热更新的检测、下载机制,获取到新的 web 代码文件后,再借助 Cordova 插件更新到本地,这种方案灵活性和及时性都很高,日常的业务迭代发版选择这种方案即可

可以简单称呼两种方案为:大版本更新和热更新

其实,更准确来讲,前端要自行实现的热更新机制包括两个方面:web 热更新和 db 热更新,前者是更新 web 代码文件,后者是更新 db 数据库表

db 的热更新可以放到下个讲数据的章节时再介绍,这里先来讲讲 web 热更新,实现这个机制的关键点有两点:检测和下载更新

既然是检测,自然是需要有比较,所以需要有一套版本管理机制,来进行用户本地版本和服务器最新版本间的比较

版本管理机制可以包含很多方面的检测,其中比较重要的是代码版本的比较

来看一个抓包请求:

image.png

常用的检测逻辑是比对版本,而版本是由 APP 大版本(如:6.5.0)拼接上 web 热更新版本(如:00)组成,前者可以通过 Cordova 插件获取,后者则需要自行存储维护,可以简单存储在 localstorage 里即可

当检测到需要进行 web 热更新了,这时候接口会下发新版本的 web 代码文件的下载地址,我们就可以借助 Cordova 插件去下载并更新到本地了

借助 Cordova 插件,我们可以调用原生的下载能力去下载 web 代码文件,下载完成后,再通过 Cordova 插件,将代码更新包解压到 APP 应用的存放 web 代码文件的目录里

因为下载是调用的原生 APP 的下载能力,这个数据包的请求在 inspect 调试里是抓不到包的,如果想抓取下载的更新包,可以走代理抓包(推荐使用 whistle)

另外,解压操作默认都是同名替换原则,这意味着不会删除历史文件,那么借助这个特性,我们甚至可以做到增量更新,只发布本次迭代有改动的几个文件即可

如果你的手机有 root 权限,那么可以自行到 APP 的私有目录里(data/data/{包名})查看下:

image.png

这就是 web 热更新的大体思路

上图的 AppCloud 平台是我司内部研发的专门用来给前端开发人员直接通过平台操作完成打包行为的项目

当然,web 热更新还有很多细节方面,比如在什么时机发起更新检测、本地开发结束后如何打包并压缩上传到专门的文件服务器等等。这些方面通常是根据不同项目、不同团队规范进行制定,就不展开了

这样一来,代码的离线化确保了 APP 运行时,代码文件早已在用户本地设备上,自然就可以支持离线运行

web 热更新机制,又确保了用户设备能够及时更新到最新版的 web 代码文件

两者结合就解决 web 代码的部署、发版问题,而这仅仅是让 web 程序能够在用户设备上离线运行的基础,程序运行后,交互以及业务功能依赖的数据处理也是应用能否支持离线运行的关键,所以下面继续来看看数据方面的处理

数据的下载、上传和更新

上一节讲了代码的离线化和热更新机制,这是确保 APP 能够实现离线应用的基础,在此基础上,各个业务组再去开发实现各自的业务离线功能

而业务功能的正常运行则是由页面数据支撑,所以离线的关键还是在于对各种数据的处理:

  • 页面数据的离线化
  • 用户本地离线数据的上传
  • 服务端新数据的下载更新

简单概括来讲的话,也就是:离线数据的下载、上传和更新机制

image.png

下面就一条条的来展开分析,首先看第一个

页面数据的离线化

所谓的离线化,其实也就是存储,页面渲染的数据也从本地取的话,那页面的交互自然就不依赖于网络了

那么,问题来了:

  • 要存哪里?
  • 存什么数据?

存自然就是要持久化存储,但对于前端来说,在持久化存储方面的能力并不是很强,localstorage 有大小限制;indexedDB 是非关系型数据库,且存在兼容性问题,所以我们采用的是通过 Cordova 插件调用原生能力来存储在 sqlite 的 db 文件中

sqlite 是原生 APP 支持的数据库存储方案,语法方面跟 mysql 基本一样,存储后就是一份 db 文件,调试时可直接通过 Cordova 插件执行 sql 查询数据,或者将 db 文件拿到 PC 电脑上借助诸如 Navicat 等数据库软件快速查看,甚至如果手机有 root 权限,可直接通过 sqlite3 命令来调试:

image.png

存哪里解决了,接下去就是思考要存的是什么数据?

正常来说,对于在线的 web 应用,前端的界面数据大多是实时来自 API 接口返回的 json 格式数据,所以如果是从接口数据缓存角度思考的话,那么也可以解决界面的呈现问题,但也仅仅只支持这种纯粹用来展示的界面场景,比如待办页、工作台等这类只展示不依赖业务数据的界面场景

对于这些接口数据缓存的场景,存储的地方也可以简单存储到 localstorage 里,也可以直接上手体验 ServiceWorker 新特性

但是还有部分界面会跟随用户操作产生的各种数据进行变化,界面并不单纯展示,也有业务交互,在这些场景下,接口数据缓存的方案是不可行的

对于这类场景,换种角度思考,既然前面选择了使用 sqlite 这种关系型数据库来存储数据,那么其实 APP 本身就有了一个类似后端的角色职责,那要存的数据,直接就类似后端数据库一样存储各种业务表即可,而表里存的自然也就是一些原始数据了

需要注意的是,在这类离线应用里的一些 API 用途跟正常的 web 在线应用就有些不大一样了,这些 API 可以看成纯粹是用来同步后端数据库表和 APP 本地表数据的角色,接口返回的数据大多也都是 sql 语句,真实数据是需要执行完 sql 才会插入到 db

所以,对于这类离线应用 APP,其本身架构上就会比较复杂一些,毕竟除了视图层外,还需要实现从 db 里取出原始数据,然后经过一系列业务逻辑处理,将数据进行加工、转换成界面所需的数据结构。因为这中间基本没有 API 的参与了,那么原先一些后端角色需要处理的职责就需要 APP 本身自行承担了

image.png

简单来小结下,页面数据的离线化需要根据不同场景来进行不同的处理:

  • 对于一些只展示的页面,比如待办页、工作台,可直接存储接口数据到 localstorage 里,有网时走接口,无网时走缓存即可
  • 对于支持用户进行交互的页面,需要提前下载好各个业务表数据到 sqlite 的 db 中,不管有网无网,都只从 db 里取数和存数,再自行处理业务逻辑后丢给界面

用户本地离线数据的上传

上面说到,可交互产生数据的界面,不管有网无网,都只走本地 db 的取数和存数处理,这也就意味着,随着用户的不断操作,本地 db 里的数据就会发生变化

那么,用户操作产生的这些离线数据,就必需得想办法上传给服务端,对于这一点,也有几个方面需要考虑清楚:

  • 什么时候上传?
  • 上传什么数据?

上传的时机通常有自动上传和手动上传两种,自动上传需要考虑的场景比较多,比如有网无网、数据有效无效等等,所以实现上更多的还是倾向于交给用户手动上传

而手动上传就需要有一个用户交互的时机来触发上传,这需要依赖不同业务特性来进行设计

那么,上传的时候,该上传的是什么数据呢?

用户操作产生的数据肯定会插入或更新到本地的数据表中,这样就跟原生数据掺杂在一起了,而上报的数据肯定不会是全量数据,所以本地得有一种机制来记录用户操作过程中产生的增量数据

所以数据库中,除了一些业务表之外,还会有一张增量表:

表结构主要有 type、operation、releation_id 几个字段,当然还有其他字段,但这些字段基本是必须的,覆盖了大多数场景的使用

type 和 operation 字段用来标志这次操作产生了哪种类型数据的变化,比如 type=addoperation=问题,表示新登记了一个问题,而跟问题相关的业务表比如 xxx_problem 表, xxx_problem_images 等表里就会新增问题相关数据,releation_id 就会关联着这些业务表里新增的数据 id,这样就可以根据增量表到各个业务表里取出增量的数据

当增量数据上传完毕后,本地则需要将这些增量数据从增量表中移除,这样才不会重复上传

这是基本的离线数据的上传处理,当然,还有很多细节方面的处理,比如性能方面:如何进行分批上传,如何进行后台队列上传等等

再比如数据处理方面:图片文件数据得先上传文件服务器,再回填 url,最后再 sql 取数组装成接口需要的格式上传

再比如上传过程中,在各个步骤失败时的场景处理和提示等

这些都依赖于产品业务特性来进行设计实现即可

服务端新数据的下载更新

数据有上传自然也有下载,下载分两种场景,一是本地无数据时的初始化下载场景,二是同步更新服务端数据的场景

针对这一点,同样也需要考虑清楚:

  • 更新什么数据?

  • 什么时候更新数据?

首先需要清楚,都有什么数据是需要下载更新的,如果从离线化存储的数据库 db 角度来看的话,其实可以分成两类:表结构数据和表数据

db 热更新

因为页面数据基本都存在本地的数据库表中了,既然是数据库,那么就会有建库、库版本管理的场景存在

所以,需要有一套数据库版本管理的机制,也俗称 db 热更新

建库时,最好根据用户来建数据库的,不同用户有不同的数据库,这样就能确保在 APP 内切换用户使用时,相互间互不影响

当在本地没有找到用户的数据库时,自然就是先建数据库。程序里会维护一份 init.sql 文件,里面是各个表最新结构的建表 sql,建库时,就会执行这份 init.sql 来建立数据库。当本地已经有数据库存在时,就需要检测是否需要 db 热更新

db 热更新有两种思路:一种是走后端维护的数据库升级 sql,一种是纯前端维护的数据库升级 sql

如果是走后端维护的数据库升级 sql 的话,就需要有检测机制,跟 web 热更新类似,需要有版本的比较。数据库中会创建一张 _version 表,这张表里会记录上次数据库升级时的 web 版本,然后在每次 web 热更新结束后会去比对,如果版本不一致时,就向后端获取两个版本之间的 db 升级 sql:

image.png

如果是走纯前端维护数据库升级 sql 的话,那么前端里除了维护一份最新的数据库升级 sql 之外,还需要自行判断是否需要进行数据库升级检测,可以是简单的判断是否有该字段或该表存在的思路,也可以自己搞个版本检测机制

数据下载和更新

表结构数据的下载和更新解决了,那么就能来服务表数据的下载和更新了,对于表数据,我们也需要清楚它的一些分类

从我司业务来看的话,表数据大概可以分为两类:基础数据和业务数据

基础数据可以理解成:所有用户都一样,下载到本地基本就不会发生变化的数据

业务数据则就是:随着不同用户的操作,会在原有数据基础上不断新增、变化的数据

不同类型的数据下载、更新的时机和方式也不同

对于用户首次安装使用 APP,是需要先进行数据的下载才能正常使用 APP 功能,因为页面数据都是依赖于本地的 db 数据库

那么,对于业务无关的基础数据自然需要先进行下载,通常是打开 APP 后进入首页就会检测下载,然后再让用户手动选择指定业务数据下载即可

这是数据下载的处理,不管是基础数据还是业务数据,准则都是初次使用本地无此数据时就需要下载,区别在于基础数据是程序自动检测进行下载,而业务数据通常是由用户手动触发进行

这么处理是因为,业务数据量通常都是非常大,而用户当前所开展的业务可以需要的仅仅是某部分业务,那么交由用户自行决定下载那部分业务数据,可以避免下载大量无关数据而造成用户体验不好

数据的下载场景比较简单,需要特别注意处理的是数据的更新,既然要更新,就需要有检测更新的机制

检测机制则是通过时间戳和版本号实现,时间戳机制可以让服务端知道用户本地当前数据是否需要更新,而版本号则是让服务端进行数据兼容的处理,因为有可能存在不同用户使用不同版本的 APP,表结构也有可能是不同的,所以还需要根据版本进行控制

当有数据需要更新时,服务端就可以将新数据进行下发,这时候也分两种场景,是全量更新还是增量更新,对于业务数据来说,通常都是增量更新,因为量可能会很大;基础数据可以看场景选择使用全量更新,一来实现简单,二来有的数据量并不大

但有一点需要注意,对于业务数据来说,如果用户本地有离线数据,那么只能是等用户上传结束才能触发数据的更新,这是因为,业务数据会存在大家同时修改同一份数据的场景,这就会造成数据冲突,而数据冲突一旦没处理好,很可能就会导致用户本地的离线数据丢失,因为可能被覆盖,也可能键值冲突导致程序异常

对于这种场景,最好办法就是让用户先进行数据的上传,由服务端来根据业务场景解决冲突并备份,这才能尽可能降低用户数据丢失的问题出现

总结

APP 的离线方案其实就是要把 APP 运行期间所有数据都离线化,只是这数据涉及到了页面代码和页面数据,页面代码的离线化和更新机制可借助 Cordova 插件实现,原理其实就是将 web 代码文件下载存储到用户本地设备,然后 WebView 加载本地资源,再结合 web 热更新机制即可

页面数据的离线化则需要各个业务组根据各自的业务场景来决定离线化的数据有哪些,下载数据的时机是什么、存储的位置是什么,存储的是什么数据,离线数据如何进行上传,上传时机是什么,上传的是什么数据,数据的更新时机,如何更新,更新什么数据等等

不同业务需求场景,数据的下载、上传和更新在具体实现上会有些差异,但涉及到的知识点无外乎就是:sqlite、增量表、时机戳等等