Hybrid App 离线包方案实践解析(已开源)

关注 高级前端进阶,回复“加群javascript

加入咱们一块儿学习,每天进步html

做者:杭州个推 - 唐江洪
前端

来源:https://github.com/mcuking/blog/issues/63java

背景

在 H5 + Native 的混合开发模式中,让人诟病最多的恐怕就是加载 H5 页面过程当中的白屏问题了。下面这张图描述了从 WebView 初始化到 H5 页面最终渲染的整个过程。node

image

其中目前主流的优化方式主要包括:android

  1. 针对 WebView 初始化:该过程大体需耗费 70~700ms。当客户端刚启动时,能够先提早初始化一个全局的 WebView 待用并隐藏。当用户访问了 WebView 时,直接使用这个 WebView 加载对应网页并展现。webpack

  2. 针对向后端发送接口请求:在客户端初始化 WebView 的同时,直接由 Native 开始网络请求数据,当页面初始化完成后,向 Native 获取其代理请求的数据。git

  3. 针对加载的 js 动态拼接 html(单页面应用):可采用多页面打包, 服务端渲染,以及构建时预渲染等方式。github

  4. 针对加载页面资源的大小:可采用懒加载等方式,将须要较大资源的部分分离出来,等总体页面渲染完成后再异步请求分离出来的资源,以提高总体页面加载速度。web

固然还有不少其它方面的优化,这里就再也不赘述了。本文重点讲的是,在与静态资源服务器创建链接,而后接收前端静态资源的过程。因为这个过程过于依赖用户当前所处的网络环境,所以也成了最不可控因素。当用户处于弱网时,页面加载速度可能会达到 4 到 5 s 甚至更久,严重影响用户体验。而离线包方案就是解决该问题的一个比较成熟的方案。

技术方案

首先阐述下大概思路:

咱们能够先将页面须要的静态资源打包并预先加载到客户端的安装包中,当用户安装时,再将资源解压到本地存储中,当 WebView 加载某个 H5 页面时,拦截发出的全部 http 请求,查看请求的资源是否在本地存在,若是存在则直接返回资源。

下面是总体技术方案图,其中 CI/CD 我默认使用 Jenkins,固然也能够采用其它方式。

image

前端部分

相关代码:

离线包打包插件:https://github.com/mcuking/offline-package-webpack-plugin

应用插件的前端项目:https://github.com/mcuking/mobile-web-best-practice

首先须要在前端打包的过程当中同时生成离线包,个人思路是 webpack 插件在 emit 钩子时(生成资源并输出到目录以前),经过 compilation 对象(表明了一次单一的版本构建和生成资源)遍历读取 webpack 打包生成的资源,而后将每一个资源(可经过文件类型限定遍历范围)的信息记录在一个资源映射的 json 里,具体内容以下:

资源映射 json 示例

{
  "packageId": "mwbp",
  "version": 1,
  "items": [
    {
      "packageId": "mwbp",
      "version": 1,
      "remoteUrl": "http://122.51.132.117/js/app.67073d65.js",
      "path": "js/app.67073d65.js",
      "mimeType": "application/javascript"
    },
    ...
  ]
}

其中 remoteUrl 是该资源在静态资源服务器的地址,path 则是在客户端本地的相对路径(经过拦截该资源对应的服务端请求,并根据相对路径从本地命中相关资源而后返回)。

最后将该资源映射的 json 文件和须要本地化的静态资源打包成 zip 包,以供后面的流程使用。

离线包管理平台

相关代码:

离线包管理平台先后端:https://github.com/mcuking/offline-package-admin

文件差分工具:https://github.com/Exoway/bsdiff-nodejs

从上面有关离线包的阐述中,有心者不难看出其中有个遗漏的问题,那就是当前端的静态资源更新后,客户端中的离线包资源如何更新?难不成要从新发一个安装包吗?那岂不是摒弃了 H5 动态化的特色了么?

而离线包平台就是为了解决这个问题。下面我以 mobile-web-best-practice 这个前端项目为例讲解整个过程:

mobile-web-best-practice 项目对应的离线包名为 main,第一个版本能够如上文所述先预置到客户端安装包里,同时将该离线包上传到离线包管理平台中,该平台除了保存离线包文件和相关信息以外,还会生成一个名为 packageIndex 的 json 文件,即记录全部相关离线包信息集合的文件,该文件主要是提供给客户端下载的。大体内容以下:

{
  "data": [
    {
      "module_name": "main",
      "version": 2,
      "status": 1,
      "origin_file_path": "/download/main/07eb239072934103ca64a9692fb20f83",
      "origin_file_md5": "ec624b2395a479020d02262eee36efe4",
      "patch_file_path": "/download/main/b4b8e0616e75c0cc6f34efde20fb6f36",
      "patch_file_md5": "6863cdacc8ed9550e8011d2b6fffdaba"
    }
  ],
  "errorCode": 0
}

其中 data 中就是全部相关离线包的信息集合,包括了离线包的版本、状态、以及文件的 url 地址和 md5 值等。

当 mobile-web-best-practice 更新后,会经过 offline-package-webpack-plugin 插件打包出一个新的离线包。这个时候咱们就能够将这个离线包上传到管理平台,此时 packageIndex 中离线包 main 的版本就会更新成 2。

当客户端启动并请求最新的 packageIndex 文件时,发现离线包 main 的版本比本地对应离线包的版本大时,会从离线包平台下载最新的版本,并以此做为查询本地静态资源文件的资源池。

讲到这里读者可能还会有一个疑问,那就是若是前端仅仅是改动了某一处,客户端仍旧须要下载完整的新包,岂不是很浪费流量同时也延长了文件下载的时间?

针对这个问题咱们可使用一个文件差分工具 - bsdiff-nodejs,该 node 工具调用了 c 语言实现的 bsdiff 算法(基于二进制进行文件比对算出 diff/patch 包)。当上传版本为 2 的离线包到管理平台时,平台会与以前保存的版本为 1 的离线包进行 diff ,算出 1 到 2 的差分包。而客户端仅仅须要下载差分包,而后一样使用基于 bsdiff 算法的工具,和本地版本 1 的离线包进行 patch 生成版本 2 的离线包。

到此离线包管理平台大体原理就讲完了,但仍有待完善的地方,例如:

  1. 增长日志功能

  2. 增长离线包达到率的统计功能

...

客户端

相关项目:

集成离线包库的安卓项目:https://github.com/mcuking/mobile-web-best-practice-container

客户端的离线包库目前仅开发了 android 平台,该库是在webpackagekit(我的开发的安卓离线包库)基础上进行的二次开发,主要实现了一个多版本文件资源管理器,能够支持多个前端离线包预置到客户端中。其中拦截请求的源码以下:

public class OfflineWebViewClient extends WebViewClient {
    @TargetApi(Build.VERSION_CODES.LOLLIPOP)
    @Override
    public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
        final String url = request.getUrl().toString();
        WebResourceResponse resourceResponse = getWebResourceResponse(url);
        if (resourceResponse == null) {
            return super.shouldInterceptRequest(view, request);
        }
        return resourceResponse;
    }


    /**
     * 从本地命中并返回资源
     * @param url 资源地址
     */
    private WebResourceResponse getWebResourceResponse(String url) {
        try {
            WebResourceResponse resourceResponse = PackageManager.getInstance().getResource(url);
            return resourceResponse;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }
}

经过对 WebviewClient 类的 shouldInterceptRequest 方法的复写来拦截 http 请求,并从本地查找是否有相应的前端静态资源,若是有则直接返回。

部分问题解答

1. 离线包是否能够自动更新?

当前端资源经过 CI 机自动打包后部署到静态资源服务器,那么又如何上传到离线包平台呢?我曾经考虑过当前端资源打包好时,经过接口自动上传到离线包平台。但后来发现可行性不高,由于咱们的前端资源是须要通过测试阶段后,经过运维手动修改 docker 版原本更新前端资源。若是自动上传,则会出现离线包平台已经上传了了未经验证的前端资源,而静态资源服务器却没有更新的状况。所以仍须要手动上传离线包。固然读者能够根据实际状况选择合适的上传方式。

2. 多 App 状况下如何区分离线包属于哪一个 App?

在上传的离线包填写信息的时候,增长了 appName 字段。当请求离线包列表 json 文件时,在 query 中添加 appName 字段,离线包平台会只返回属于该 App 的离线包列表。

3. 必定要在 App 启动的时候下载离线包吗?

固然能够作的更丰富些,好比能够选择在客户端链接到 Wi-Fi 的时候,或者从后台切换到前台并超过 10 分钟时候。该设置项能够放在离线包平台中进行配置,能够作成全局有效的设置或者针对不一样的离线包进行个性化设置。

4. 若是客户端离线包尚未下载完成,而静态资源服务器已经部署了最新的版本,那么是否会出现客户端展现的页面仍然是旧的版本呢?若是此次改动的是接口请求的变更,那岂不是还会引发接口报错?

这个大可没必要担忧,上面的代码中若是 http 请求没有命中任何前端资源,则会放过该请求,让它去请求远端的服务器。所以即便本地离线包资源没有及时更新,仍然能够保证页面的静态资源是最新的。也就是说有一个兜底的方案,出了问题大不了回到原来的请求服务器的加载模式。

结束语

至此整个方案的大体原理已经阐述完了,更多细节问题读者能够参考文中提供的项目连接,全部端的代码都已经托管到了个人 github 上了。

这也算完成了我一个夙愿:实现一套离线包方案而且彻底开源出来。最后但愿对你们有所帮助~

- END -

若是你以为这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  1. 点个「在看」,让更多的人也能看到这篇内容(喜欢不点在看,都是耍流氓 -_-)

  2. 关注个人官网 https://muyiy.cn,让咱们成为长期关系

  3. 关注公众号「高级前端进阶」,每周重点攻克一个前端面试重难点,公众号后台回复「面试题」 送你高级前端面试题。