查看原文
其他

网易新闻客户端 H5 秒开优化

毛仪桓 李贺柯 网易传媒技术团队 2020-10-29

H5 因其“天生”的跨平台、实时更新、便于传播等特性,一直是各家 APP 承载内容的重要手段之一。但由于 web 技术本身的限制,在功能、性能以及体验方面与 native 仍有一定的差距,比如受限的硬件访问能力、差强人意的离线功能等。而基于 WebView 的混合模式,借助 native 的增强是比较通用的解决方案。本文将围绕这些痛点,分享下网易新闻客户端在资源离线、JsBridge 通信、接口预请求三个方面的优化实践。


一、资源离线

1、介绍
Web 页面性能优化的重点之一就是静态资源的加载耗时,而传统的离线方案都难以解决首次加载的问题。在 APP 内,我们可以通过 native 将资源离线到本地,能够很好的弥补这个缺陷。实现的基本思路是把 web 页面的静态资源生成 Zip 包,客户端在合适的时机拉取 Zip 包到本地解压并持久化存储。当用户访问时,通过拦截 WebView 发出去的页面请求,直接返回对应的本地文件,这样就可以实现本地加载,缩短页面资源加载时间。

大体流程如图:


2、离线实现

当我们确定了采用页面 Zip 离线加载方案,我们需要制定一套完整流程来完成这个方案。实现整个离线方案分三部分内容:

  • Web 页面 Zip 包生成工具

  • 离线管理系统

  • 客户端离线实现


2.1、Web 页面 zip 包生成工具

2.1.1、工具介绍

Web 页面 Zip 包生成工具主要是将页面生成 Zip 包,同步配置离线页面信息。


考虑到 web 页面多、依赖资源多,手动打包容易出错且效率低下。因此我们开发了一款脱离业务、方便灵活、多项目可用的打包工具,方便我们将本地项目页面或者线上页面生成 Zip 包的工具。


也就是说配置一个页面的入口文件或者线上页面地址:

[{ name: 'index', // 页面名称 url: ['https://example.com/index'], src: './dist/index.html' // 本地页面入口文件}]// 或[{ name: 'index', // 页面名称 url: ['https://example.com/index'] //页面线上地址}]
最终输出客户端可用的离线页面相关信息:[{ name: 'index', url: ['//example.com/index'], zipUrl: 'https://assets.example.com/static/example.20190525_1020.zip', md5: 'md5md5md5md5md5md5md5md5md5md5md5md5'}]


2.1.2、工具实现
Web 页面生成 Zip 包工具所有环节均为自动化,每个环节通过中间件方式实现,满足不同场景,方便定制化需求。整体分为通用和定制两部分。


通用部包括:
  • 拷贝页面依赖生成Zip包

  • 判断包的完整性

  • 获取 Zip 包的 MD5 值

  • 生成 Zip 包版本号


定制部分为:
  • 确定待更新 Zip 包

  • 上传 Zip 包到 CDN

  • 更新离线数据、Zip 包版本数据


功能流程如下图:


实现(build.js):

const { del, zip, diff } = require('./index');const { upload, version, extend, equal, Compose, copy, check } = require('../utils')const config = require('../tool.config');
const common = () => { const compose = new Compose(); compose.use(async (context, next) => { // 通用逻辑(拷贝打包,包校验,生成版本,获取md5) next(); }); return compose;};
const build = list => new Promise((resolve, reject) => { if (!Array.isArray(list)) { return reject('The "list" argument must be of type array', list); } common().use(async (context, next) => { // 定制逻辑(确定需要上传的zip包,上传zip包,更新数据库) next(); }).exec({ list }).then(res => resolve(res)).catch(err => reject(err));});const compose = common();build.use = compose.use.bind(compose);build.exec = compose.exec.bind(compose);module.exports = build;


2.1.3、生成的 Zip 包
Zip 包里有和客户端同学约定好了 zip 包的具体内容和目录结构,对应页面的页面入口文件(index.html)和其他包含了页面依赖资源,页面资源目录结构和线上保持一致,这样可以方便客户端匹配查找,简化客户端处理逻辑。


Zip 包结构如下:
hot-content_20190808150211.zip

│  ── 163

│   └── frontend

│       └── hot-content

│           └── js

│               └── app.10882524.js

│           └── css

│               └── app.412b0635.css

│ ── index.html


2.2、离线管理系统
离线系统主要功能职责:
  • 为离线工具提供打包信息及离线包信息存储

  • 为 APP 提供离线数据

  • 页面离线数据在线管理


为扩展其他产品使用,离线管理系统完成了多产品、多用户的设计。除工具自动更新数据外,还可以在系统里添加数据,对数据进行改删操作。离线数据保留最近5个版本,如果发现线上 Zip 包有问题,可以迅速回滚到上个版本。


核心功能如下图:


2.3 、客户端离线实现
客户端离线实现是整个离线方案最重要的一环,主要分为两大内容:
  • 离线资源更新

  • 拦截资源返回


我们设计一个离线资源管理器做总调度来处理离线资源的更新和拦截返回逻辑。离线资源管理器根据配置的离线信息创建一个动态管理器,会部署每个 URL 对应的页面入口文件,静态资源(css,js,image)目录,要拦截的静态资源域名。


2.3.1、更新实现细节
APP 在获取离线配置分主动和被动,主动更新是在每次 APP 启动后通过接口获取离线配置信息,被动通过 push 更新。


离线配置包含了所有已配置的页面离线信息,在我们获取到这些离线信息后,读取本地配置缓存进行比对,根据页面名称来确定离线文件的更新策略是什么,远端配置无本地配置有则认为当前页面离线包是被删除的,直接删除本地对应的离线页面入口文件;如果发现两个配置中同名页面 Zip 包的 MD5 值不一致则认为此页面离线包是更新了的;只有远端有配置则认为是新增;然后交给下载管理器下载并解压 Zip 包,下载解压完成通知离线资源管理器更新本地离线缓存配置。


更新主要流程:


2.3.2、拦截资源返回细节      
我们会统一拦截所有网络请求,通过离线资源管理器来处理访问逻辑。需要处理的拦截返回分:
  • 页面文件(html)

  • 依赖的静态资源(js、css、image)


当 APP 在 Webview 发起页面请求时,我们会先拦截当前页面请求,获取到页面的 URL 地址,根据离线管理器中配置,进行查找有无匹配的本地页面入口文件,有则直接返回入口文件,否则放行请求线上资源。


页面的加载会伴随着依赖资源的加载,获取请求 URL,如果在静态资源拦截域名内,则替换域名的 origin 为本地的静态资源目录进行查找。如果找到,获取文件扩展名,设置返回的文件类型直接返回。


拦截返回的主流程如下图:


2.3.3、其它
为了确保整个 Zip 离线的高可用性,APP 端会对每个环节出现的错误进行上报,以便快速定位并修复问题。


和离线相关的错误类型有:
  • 获取离线配置接口网络错误
  • 获取离线配置接口数据解析失败

  • Zip 包请求网络错误

  • Zip 包解压错误

  • Zip 包 MD5 值 APP 端和前端不一致

  • Zip 包解压手机空间不足


如出现上面任何一种错误都不会更新本地离线资源和离线配置。


3、小结
为了加快页面展示速度,我们做了服务器渲染,合并减少 Request 请求,做 gzip 压缩,部署 CDN,做缓存,引入 Service Worker 等优化措施,但还是不能很好的解决首次请求白屏过长的问题。而通过 Zip 离线方案,在用户第一次访问时本地已经有对应的离线资源了,这样大大的缩短了资源加载时间,减少白屏时间。


这里通过一个测试页面来看下资源离线的前后数据对比,为了模拟真实的项目状态,我们在页面中挂载了 jQeury、Bootstrap.js/css、 以及 js-bridge 等静态资源。使用 iPhone 6s 机型,分别在不同网络环境下对测试页面进行访问,记录多组首次访问总耗时,最终取平均值。


从图中数据可以看出,通过使用离线方案,在各种网络环境下加载页面静态资源均不受网络情况影响,相比从远端加载整体有 75% 的提升。需要说明的是,测试页面中没有掺杂业务逻辑,仅纯粹的资源加载,所以效果会比较明显。


二、JsBridge
虽然随着 WebView 的逐步更新,赋予了 web 丰富的功能,但考虑到兼容性以及整个 APP 交互体验的统一,大部分的业务场景下我们仍需要借助 native 的功能。比如:
  • 视图层面 - 注册、登录、认证、注销组件、视图路由...

  • 存储层面 - 用户信息、设备信息、业务状态、缓存...

  • 网络层面 - 请求 header、代理转发、预请求...

  • APP 层面 - 唤起、设置、push、跨 APP 操作...

  • 系统层面 - 底层 API 的调用

  • 其它辅助功能


良好的混合架构能降低设计成本,减少前后端工作量,快速发布迭代,提升稳定性和用户体验。而 JsBridge 正是负责 web 和 native 通信的核心。


1、介绍
JsBridge 的设计和实现有各种各样的版本,这里简单梳理几个要点:


1.1、Web To Native

主要采用注入 web 可调用的方法,或进行拦截:

  • iOS UIWebView - JavaScriptCore
  • iOS WKWebView - WKScriptMessageHandler

  • Android - addJavascriptInterface(4.2以下有安全漏洞)

  • URL 拦截(URLScheme)

  • JS 方法拦截(alert、prompt、confirm、console.log)


1.2、Native To Web
直接执行 web 暴露的全局方法即可:
  • iOS - stringByEvaluatingJavaScriptFromString (兼容但无法捕获错误)

  • iOS UIWebVIew - JSContext evaluateScript

  • iOS WKWebView - evaluateJavaScript

  • Android - loadUrl(无法获取返回结果)

  • Android 4.4+ - evaluateJavascript(可以获取返回结果)


2、重构前状态
重构前,端内 web 调用 native 的方式较为混乱:
  • foo:// - 无参数调用

  • bar://encodedParams - 所有参数转 JSONString 并 encode

  • baz://param1/param2 - 分割单个参数

  • window.xxx(params) - 注入方法直接调用


对应的回调也是多种多样,web 暴露全局方法由 native 调用,也有直接通过注入方法返回:
  • window.foo_success(result) / .foo_fail(error) - 不同函数代表不同状态

  • window.bar_done(result) - 从结果集中区分不同状态

  • result = window.xxx(params) - 直接返回


混乱的实现方式导致使用了大量的协议头,同时注入了大量的全局方法,还存在一些使用的时序问题。而且使用一个功能需要知道协议头、参数格式、传参方式、回调方式、如何区分成功/失败,显然过于复杂了。


3、重构思路

借这次重构,终于有机会对两端通信的 API 做一次全盘整理,而如何设计统一规范的 JsBridge 是本次重构的主要目标。


从单一职责的角度考虑,JsBridge 只应处理一件事:把消息在正确的通道传递给对方,不关心具体业务;而业务开发只聚焦功能自身,无需考虑如何传递。


3.1、对称设计
首先需要明确的是,完整的 JsBridge 功能应该是双向互通的:
  • web 调用 -> native 接收调用并返回结果 -> web 接收结果

  • native 调用 -> web 接收调用并返回结果 -> native 接收结果


抽象之后应该具备两个通道,每个通道发送的消息都包含“调用”和“结果”两种内容。而每一端需要具备四项能力:
  • 调用(invoke)- 调用另一端的方法

  • 接收(receive)- 接收执行结果

  • 注册(register)- 注册方法等待另一端的调用

  • 回调(callback)- 回传执行结果


消息都是通过异步回调的方式进行传输,这样在满足更多的业务场景时,功能实现方也不用关心功能自身是同步还是异步,只需在得到结果的时候丢给 JsBridge 即可。


3.2、消息通道

基于消息传递需具备的能力,我们来考虑采用哪种方式实现。目前端内的情况,iOS 采用 WKWebview,Android 仍需兼容系统 4.0+,所以最终实现:


3.2.1、Web To Native
  • iOS - WKScriptMessageHandler

  • Android - 4.2及以上 采用 addJavascriptInterface,其它使用 URLScheme


简单来说就是优先使用 native 注入的方式,更通用的 URLScheme 用来兜底。之所以没有统一使用 URLScheme,主要是考虑性能方面,native 注入的方法调用速度更快;而且在并行调用时 URLScheme 方式需要做一些 hack 处理,对效率也有一定影响。


3.2.2、Native To Web

统一由 web 暴露一个全局的接收方法供 native 调用。

综上,只需要在全局环境注入三个方法(iOS 和 Android 注入的方法限于实现方式不同未强制统一)和一个协议。


3.3、数据结构

具体到消息本身,我们需明确应该包含哪些内容。每个业务或功能的 API 实现基本都可以简化成:方法名、所需参数、回调和错误信息。而由于消息通道的合并,为了区分消息是“调用”还是“结果”,需增加一个 ID 标示,同时利用该 ID 还可以确定是哪一次“调用”,以解决异步的对齐问题。最终的数据结构如下:

// Invocation{ "name": "foo", "params": {...}, "callbackId": "cb_1"}
// Result{ "responseId": "cb_1", "result": { "data": {...}, "errorMsg": "", "errorData": ..., "errorDesc": ... }}


其中:
  • callbackId - 表明该消息是一次“调用”,而 responseId 表明是“结果”,而且两者是对应的

  • name - 调用的方法名

  • params - 调用的方法所需的参数,统一使用对象格式,利于扩展

  • errorMsg 用于区分此次处理是否成功,其它附属 error* 是一些可选的扩展

  • data 是处理成功后的返回数据


考虑到兼容性,消息体采用 JSONString 类型,如果是通过 URLScheme 发送还需要对消息体做一次 encode。


在确定了消息通道(怎么发)和数据结构(发什么)之后,JsBridge 其实已经可以工作了。但在实际使用场景下,我们仍需要考虑以下几个方面。


3.4、可用性
3.4.1、JsBridge 是否可用

Native 方法注入是由系统底层实现的,当 WebView 构建 Window 环境时会相应的挂载要注入的方法,当 web 页面运行时,注入方法已经可用;而对 URLScheme 的监听时机更早,且跟 Window 无直接关联。所以我们可以在页面的任何地方直接发起调用,而无需进行双方“握手”,perfect!


3.4.2、API 是否可用
随着业务迭代,native 支持的方法在不同的版本下会产生差异,比起使用版本号来判断,统一暴露出一个检测 API 是否可用的方法则更为方便。但 JsBridge 的调用均为异步,两个异步嵌套会让使用变的繁琐。我们期望能像浏览器自带 API 一样可以直接使用,所以最终采用由 native 向 Window 环境中注入当前所支持的 APIList 方案。但两端的实现有些差异:
  • iOS 通过 WKScriptMessageHandler 注入的方法无法直接拿到返回值,改由 WKUserScript 注入 js

  • Android 4.2 及以上仍利用 addJavascriptInterface 注入方法,可以直接拿到返回值

  • Andoird 4.2 以下使用 loadUrl 在页面加载的多个阶段尝试注入 js


3.5、命名空间
通常 native 提供的功能多是偏向通用的,但某些业务场景会需要比较定制的混合功能(比如业务数据同步、UI 交互联动),显然与通用功能放在一起不太合适,而且会增加命名负担。我们可以把通用功能放在一个池子里,任何业务都可以继承自己所需的,然后再实现自身定制的部分,组合成一套新的 APIList,并利用命名空间加以区分,这样定制业务可以更加灵活而不用担心对其它业务做成影响。Web 端直接调用指定命名空间下注入的方法,或在 URLScheme 中增加一级 namespace 便可以访问到指定业务下的对应方法。


3.6、具体实现

在 web 端的具体实现中,对消息传递部分进行了封装,抹平了 iOS、Android 中的差异,并使用 Promise 来承接设计中的双向异步、状态区分(成功、失败)等特性。


Invoke

jsBridge.config({ namespace: 'common', // 设置命名空间 ...})
if (jsBridge.isAvailable('foo')) { // 判断是否可用 jsBridge.invoke('foo', params) // 只需关心方法名和参数即可 .then(data => {...}) // 成功数据 .catch(error => {...}) // 失败处理}


Register

jsBridge.register('bar', params => { ... // 直接返回结果 return result
// Or 返回一个异步操作 return new Promise(...)})


4、小结

总体来讲,JsBridge 的设计并不复杂,只需要确定职责范围,针对性技术选型,再处理一部分兼容问题即可,剩下的就是具体业务功能的实现和联调了。


另外针对 URLScheme 有两个问题需要补充下:


4.1、URLScheme 特殊处理

在使用 URLScheme 时,会遇到“并发”问题,即同时发送多次调用,仅最后一个能正确传递到 native 端,前边的都被忽略了。针对这个问题我们可以使用纯 web 的解决方案,每次调用都创建一个新的 iframe,但考虑到频繁地创建销毁 DOM 元素,对整个页面会造成一定的影响,最终采用队列 + 确认的方案。


Native 每收到一次协议消息(不管里边包含多少调用),立即发送接收成功的 confirm 到 web 端,然后 web 端检查等待 confirm 这期间是否有累积新的调用,如果有就一次性发走。


另外,URL 是有长度限制的,所以对于特别复杂的调用是有可能丢失数据的,这也是很多 JsBridge 实现采用 web 发送通知(有新消息),native 自己来取的策略。但这就增加了一个取数据的环节,考虑到这种情况极少,而且 URLScheme 只是我们的 fallback 方案,我们最终选择忽略这个问题。


三、实际应用

基于资源离线和 JsBridge 的设计,WebView 容器的基础功能已经实现,之后依照规范对 native API 进行完善,整个重构就算完成了。除了常规的 API 外,我们还实现了 web 端请求代理功能,以解决在实际的业务场景中数据请求存在的种种问题。


1、请求代理

利用 request 协议方法,将请求参数传递给 native 端,native 端封装好请求并发送,最终将返回数据挂载到 request 的回调中返回给 web 端。利用该方法能带来以下几个方面的提升:


1.1、预请求

在加载页面的同时,native 端根据配置参数(合并在离线配置中)提前发送请求并暂存,web 端运行时发送同样请求时,native 端通过比对将已经暂存的请求数据返回(如果请求还未完成则等待完成后返回)。通过简单的并行,缩短了用户的感知时间,特别针对首屏有数据依赖的页面,性能提升较为明显。


1.2、统一业务 header

为了方便和统一,在 native 端封装了一套通用的请求 header,包含一些常用的设备信息、用户登录信息等,用于后端接口的数据查询和校验。而 web 端想保持一致的话,一些 header 信息需要先从 native 端取,然后自行封装发送,存在一定耗时且较为繁琐。利用 request 协议方法,web 端可以省略通用 header,统一由 native 端注入,既方便又能保持数据的一致性。


1.3、统一日志管理

Native 端在处理 request 协议方法时,将请求和返回数据一并插入日志队列,集中存储,能更清晰的记录用户的行为和请求状态,改善了 web 端难以落日志的痛点,对于反馈问题的排查有很大帮助。


1.4、跨域

省去了跨域带来的种种麻烦。


2、WebView 预创建

除了资源加载,容器(WebView)的创建和初始化也存在一定的耗时,对此,我们创建了一个 WebView 的“池子”,当 APP 启动时,在主序列任务完成后,会主动创建一个 WebView 放进“池子”中,以供后续 web 页面的使用。同时,每当“池子”中的一个 WebView 被使用了便会再创建一个留作备用,依次循环。这就保证了,每次 web  页面加载时,总有一个 ready 状态的 WebView 可以立即使用。


3、性能对比
配合以上种种特性,我们最后来看下,通过部署离线和预请求来提升性能的前后对比数据。基于章节二中的测试页面,我们在静态资源加载完毕后追加了一次数据请求,这也更接近真实的业务场景,同样在多种网络环境访问多次取均值:


从图中可以看出,受到业务接口的影响,平均整体耗时由 942.9ms 优化到了 391.1ms,优化幅度在 50% 左右,接口的平均耗时要高于静态资源的记载耗时。但随着业务逻辑复杂度的提升,资源加载和接口预请求的并行时间会进一步增加,收益也会相应的提升更多。


4、线上实例
通过线上实例来对比,可以更直观的感受到用户体验的变化。视频中在同样环境下访问同一个 H5 页面(首次打开和二次打开),其中左侧为普通加载,右侧开启离线和预请求,以 WebView 所在原生视图推进的瞬间对齐时间线。
可以看出,经过离线和预请求的优化,明显的缩短了首次打开的页面白屏时间。


四、结语

在如今各种混合框架/语言激烈竞争的大前端环境下,基于 WebView 的 H5 Hybrid 凭着简单、通用、轻量、稳定等特性,仍占据着重要的地位。而借助资源离线、数据预请求、native 方法注入等优化方案,对其在性能和硬件能力上的短板进行加强,使其能够承接更多的业务场景。


    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存