乐趣区

关于前端:Hybrid-App-离线包方案实践

文章首发于我的博客 https://github.com/mcuking/bl…

背景

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

其中目前支流的优化形式次要包含:

  1. 针对 WebView 初始化:该过程大抵需消耗 70~700ms。当客户端刚启动时,能够先提前初始化一个全局的 WebView 待用并暗藏。当用户拜访了 WebView 时,间接应用这个 WebView 加载对应网页并展现。
  2. 针对向后端发送接口申请:在客户端初始化 WebView 的同时,间接由 Native 开始网络申请数据,当页面初始化实现后,向 Native 获取其代理申请的数据。
  3. 针对加载的 js 动静拼接 html(单页面利用):可采纳多页面打包,服务端渲染,以及构建时预渲染等形式。
  4. 针对加载页面资源的大小:可采纳懒加载等形式,将须要较大资源的局部分离出来,等整体页面渲染实现后再异步申请分离出来的资源,以晋升整体页面加载速度。

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

技术计划

首先论述下大略思路:

咱们能够先将页面须要的动态资源打包并事后加载到客户端的安装包中,当用户装置时,再将资源解压到本地存储中,当 WebView 加载某个 H5 页面时,拦挡收回的所有 http 申请,查看申请的资源是否在本地存在,如果存在则间接返回资源。

上面是整体技术计划图,其中 CI/CD 我默认应用 Jenkins,当然也能够采纳其它形式。

前端局部

相干代码:

离线包打包插件 :https://github.com/mcuking/of…

利用插件的前端我的项目 :https://github.com/mcuking/mo…

首先须要在前端打包的过程中同时生成离线包,我的思路是 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/of…

文件差分工具 :https://github.com/Exoway/bsd…

从下面无关离线包的论述中,有心者不难看出其中有个脱漏的问题,那就是以后端的动态资源更新后,客户端中的离线包资源如何更新?难不成要从新发一个安装包吗?那岂不是摒弃了 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/mo…

客户端的离线包库目前仅开发了 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 申请没有命中任何前端资源,则会放过该申请,让它去申请远端的服务器。因而即便本地离线包资源没有及时更新,依然能够保障页面的动态资源是最新的。也就是说有一个兜底的计划,出了问题大不了回到原来的申请服务器的加载模式。

5. 如果客户端离线包版本为 1,而离线包平台的对应的离线包最新版本为 4,即版本相差大于 1 时,也是通过下载差分包而后本地进行 patch 合并吗?

笔者开发的离线包平台目前仅对相邻版本进行了差分,所以如果客户端本地离线包版本和离线包平台最新版本不相邻,会下载最新版本的全量包。当然,各位能够依据须要,能够将上传的离线包和过来 3 个版本或者更多版本进行差分,这样客户端能够抉择下载对应版本的差分包即可,例如下载 1->3 的差分包。

6. 如果离线包除了离线 js、css 等资源,还离线 html,会有什么问题么?

这里笔者举个例子不便论述,假如客户端申请线上离线包版本的机会是在 app 启动时和定时每两个小时申请一次。当 app 刚刚申请线上离线包版本完没多久,线上的前端页面资源更新了,同时线上离线包也会更新。这个时候用户拜访页面时,客户端并不知道线上资源曾经更新,所以仍旧会拦挡 html 资源申请,并从本地离线包中查找。因为 html 文件名中没有 hash,即便页面更新内容变动,文件名称依然不变,所以还是能够从本地离线包中找到对应的 html 文件并返回,尽管这个 html 文件绝对于线上曾经是较旧的文件了。而旧的 html 中援用的 js/css 等资源也会是旧的资源,由此便导致用户看到的页面始终是旧的。只有等到 app 重新启动或者期待将近两个小时后,客户端从新申请线上离线包版本后,能力更新到最新的页面。

对此次要问题本源在于,客户端并不知道线上资源的更新机会,只能通过定时轮询。如果服务端被动告诉客户端,例如通过推送形式,当线上离线包一更新,便告诉客户端申请最新版本离线包,就能够保障尽量的及时更新。(当然下载离线包也会须要一些工夫)

讲到这里读者能够思考一个问题,前端的页面更新是否及时真的是十分重要的事件么?这里波及到用户关上页面的体验和页面及时更新两者的取舍问题,能够类比下原生 app,原生 app 个别只有用户批准更新之后才会下载更新,很多用户应用的版本可能并不是最新的。所以笔者认为,只有可能做好后端接口的兼容性,不至于呈现页面不更新的话,申请的线上接口参数变更甚者被破除,导致页面报错这种状况,页面的无奈及时更新还是能够容忍的。

况且个别用户应用 app 的工夫不会太长,当下一次再关上的时候客户端就会下载最新的离线包了。笔者所在公司也有这样的问题,但并没有影响到用户的理论应用。所以还是倡议离线 html 文件,以彻底晋升页面的关上速度。

7. iOS 端 wkWebview 没有 API 反对间接拦挡网页的申请,该如何实现离线包计划呢?

笔者询问了下云音乐的 iOS 端离线包计划,是通过公有 API — registerSchemeForCustomProtocol 注册了 http(s) scheme,进而能够获取到所有的 http(s) 申请,更多信息可参考上面这篇文章:

http://nanhuacoder.top/2019/0…

文中提到因为 WKWebView 在独立于主过程 NSURLProtocol 过程 Network Process 里执行网络申请,失常状况 NSURLProtocol 过程是无奈拦挡到 webview 中网页发动的申请的。(注:UIWebView 收回的 request,NSURLProtocol 是能够拦挡到的)

如果通过 registerSchemeForCustomProtocol 注册了 http(s) scheme, 那么由 WKWebView 发动的所有 http(s) 申请都会通过 IPC 从 网络过程 Network Process 传给主过程 NSURLProtocol 解决,就能够拦挡所有的网络申请了。

然而过程之间的通信应用了 MessageQueue,网络过程 Network Process 会将申请 encode 成一个 Message,而后通过 IPC(过程间通信)发送给 主过程 NSURLProtocol。出于性能的起因,encode 的时候 将 HTTPBody 和 HTTPBodyStream 这两个字段抛弃掉。

文中提到里一个解决办法,如下所示:

然而还是会遇到一个问题,那就是 http 的 header 自身的大小会有限度,导致例如上传图片等场景会失败。笔者这里提一个能够走通的形式:

在初始化 wkWebview 的时候,注入并执行一段 js,这段 js 次要逻辑是复写挂载在全局上的 XMLHttpRequest 原型上的 open 和 send 办法。

在 open 办法里基于工夫戳生成一串字符串 identifier,挂载到 XMLHttpRequest 的实例对象上,同时增加到第二个参数 Url 上,而后再执行原有的 open 办法。

至于 send 办法,次要是拿到 http 申请的 body,以及 open 办法中挂载到实例对象的 identifier 属性,组合成一个对象并调用原生办法保留到客户端的存储中。

当在主过程 NSURLProtocol 中拦挡到 XHR 申请时,先从申请的 Url 获取到 identifier,而后依据 identifier 从客户端的存储中找到之前保留的 body。这样就解决了 body 失落的问题。

当然如果我的项目中用到了浏览器原生提供的 fetch 办法的话,记得也要将 fetch 办法复写下哦。

结束语

至此整个计划的大抵原理曾经论述完了,更多细节问题读者能够参考文中提供的我的项目链接,所有端的代码都曾经托管到了我的 github 上了。

这也算实现了我一个夙愿:实现一套离线包计划并且齐全开源进去。最初心愿对大家有所帮忙~

退出移动版