网易新闻客户端 H5 秒开优化
H5 因其“天生”的跨平台、实时更新、便于传播等特性,一直是各家 APP 承载内容的重要手段之一。但由于 web 技术本身的限制,在功能、性能以及体验方面与 native 仍有一定的差距,比如受限的硬件访问能力、差强人意的离线功能等。而基于 WebView 的混合模式,借助 native 的增强是比较通用的解决方案。本文将围绕这些痛点,分享下网易新闻客户端在资源离线、JsBridge 通信、接口预请求三个方面的优化实践。
一、资源离线
大体流程如图:
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'
}]
拷贝页面依赖生成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;
│ ── 163
│ └── frontend
│ └── hot-content
│ └── js
│ └── app.10882524.js
│ └── css
│ └── app.412b0635.css
│ ── index.html
为离线工具提供打包信息及离线包信息存储
为 APP 提供离线数据
页面离线数据在线管理
为扩展其他产品使用,离线管理系统完成了多产品、多用户的设计。除工具自动更新数据外,还可以在系统里添加数据,对数据进行改删操作。离线数据保留最近5个版本,如果发现线上 Zip 包有问题,可以迅速回滚到上个版本。
离线资源更新
拦截资源返回
我们设计一个离线资源管理器做总调度来处理离线资源的更新和拦截返回逻辑。离线资源管理器根据配置的离线信息创建一个动态管理器,会部署每个 URL 对应的页面入口文件,静态资源(css,js,image)目录,要拦截的静态资源域名。
离线配置包含了所有已配置的页面离线信息,在我们获取到这些离线信息后,读取本地配置缓存进行比对,根据页面名称来确定离线文件的更新策略是什么,远端配置无本地配置有则认为当前页面离线包是被删除的,直接删除本地对应的离线页面入口文件;如果发现两个配置中同名页面 Zip 包的 MD5 值不一致则认为此页面离线包是更新了的;只有远端有配置则认为是新增;然后交给下载管理器下载并解压 Zip 包,下载解压完成通知离线资源管理器更新本地离线缓存配置。
页面文件(html)
依赖的静态资源(js、css、image)
当 APP 在 Webview 发起页面请求时,我们会先拦截当前页面请求,获取到页面的 URL 地址,根据离线管理器中配置,进行查找有无匹配的本地页面入口文件,有则直接返回入口文件,否则放行请求线上资源。
页面的加载会伴随着依赖资源的加载,获取请求 URL,如果在静态资源拦截域名内,则替换域名的 origin 为本地的静态资源目录进行查找。如果找到,获取文件扩展名,设置返回的文件类型直接返回。
获取离线配置接口网络错误 获取离线配置接口数据解析失败
Zip 包请求网络错误
Zip 包解压错误
Zip 包 MD5 值 APP 端和前端不一致
Zip 包解压手机空间不足
如出现上面任何一种错误都不会更新本地离线资源和离线配置。
从图中数据可以看出,通过使用离线方案,在各种网络环境下加载页面静态资源均不受网络情况影响,相比从远端加载整体有 75% 的提升。需要说明的是,测试页面中没有掺杂业务逻辑,仅纯粹的资源加载,所以效果会比较明显。
视图层面 - 注册、登录、认证、注销组件、视图路由...
存储层面 - 用户信息、设备信息、业务状态、缓存...
网络层面 - 请求 header、代理转发、预请求...
APP 层面 - 唤起、设置、push、跨 APP 操作...
系统层面 - 底层 API 的调用
其它辅助功能
良好的混合架构能降低设计成本,减少前后端工作量,快速发布迭代,提升稳定性和用户体验。而 JsBridge 正是负责 web 和 native 通信的核心。
主要采用注入 web 可调用的方法,或进行拦截:
iOS UIWebView - JavaScriptCore iOS WKWebView - WKScriptMessageHandler
Android - addJavascriptInterface(4.2以下有安全漏洞)
URL 拦截(URLScheme)
JS 方法拦截(alert、prompt、confirm、console.log)
iOS - stringByEvaluatingJavaScriptFromString (兼容但无法捕获错误)
iOS UIWebVIew - JSContext evaluateScript
iOS WKWebView - evaluateJavaScript
Android - loadUrl(无法获取返回结果)
Android 4.4+ - evaluateJavascript(可以获取返回结果)
foo:// - 无参数调用
bar://encodedParams - 所有参数转 JSONString 并 encode
baz://param1/param2 - 分割单个参数
window.xxx(params) - 注入方法直接调用
window.foo_success(result) / .foo_fail(error) - 不同函数代表不同状态
window.bar_done(result) - 从结果集中区分不同状态
result = window.xxx(params) - 直接返回
混乱的实现方式导致使用了大量的协议头,同时注入了大量的全局方法,还存在一些使用的时序问题。而且使用一个功能需要知道协议头、参数格式、传参方式、回调方式、如何区分成功/失败,显然过于复杂了。
借这次重构,终于有机会对两端通信的 API 做一次全盘整理,而如何设计统一规范的 JsBridge 是本次重构的主要目标。
从单一职责的角度考虑,JsBridge 只应处理一件事:把消息在正确的通道传递给对方,不关心具体业务;而业务开发只聚焦功能自身,无需考虑如何传递。
web 调用 -> native 接收调用并返回结果 -> web 接收结果
native 调用 -> web 接收调用并返回结果 -> native 接收结果
调用(invoke)- 调用另一端的方法
接收(receive)- 接收执行结果
注册(register)- 注册方法等待另一端的调用
回调(callback)- 回传执行结果
消息都是通过异步回调的方式进行传输,这样在满足更多的业务场景时,功能实现方也不用关心功能自身是同步还是异步,只需在得到结果的时候丢给 JsBridge 即可。
基于消息传递需具备的能力,我们来考虑采用哪种方式实现。目前端内的情况,iOS 采用 WKWebview,Android 仍需兼容系统 4.0+,所以最终实现:
iOS - WKScriptMessageHandler
Android - 4.2及以上 采用 addJavascriptInterface,其它使用 URLScheme
简单来说就是优先使用 native 注入的方式,更通用的 URLScheme 用来兜底。之所以没有统一使用 URLScheme,主要是考虑性能方面,native 注入的方法调用速度更快;而且在并行调用时 URLScheme 方式需要做一些 hack 处理,对效率也有一定影响。
统一由 web 暴露一个全局的接收方法供 native 调用。
综上,只需要在全局环境注入三个方法(iOS 和 Android 注入的方法限于实现方式不同未强制统一)和一个协议。
具体到消息本身,我们需明确应该包含哪些内容。每个业务或功能的 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 其实已经可以工作了。但在实际使用场景下,我们仍需要考虑以下几个方面。
Native 方法注入是由系统底层实现的,当 WebView 构建 Window 环境时会相应的挂载要注入的方法,当 web 页面运行时,注入方法已经可用;而对 URLScheme 的监听时机更早,且跟 Window 无直接关联。所以我们可以在页面的任何地方直接发起调用,而无需进行双方“握手”,perfect!
iOS 通过 WKScriptMessageHandler 注入的方法无法直接拿到返回值,改由 WKUserScript 注入 js
Android 4.2 及以上仍利用 addJavascriptInterface 注入方法,可以直接拿到返回值
Andoird 4.2 以下使用 loadUrl 在页面加载的多个阶段尝试注入 js
在 web 端的具体实现中,对消息传递部分进行了封装,抹平了 iOS、Android 中的差异,并使用 Promise 来承接设计中的双向异步、状态区分(成功、失败)等特性。
jsBridge.config({
namespace: 'common', // 设置命名空间
...
})
if (jsBridge.isAvailable('foo')) { // 判断是否可用
jsBridge.invoke('foo', params) // 只需关心方法名和参数即可
.then(data => {...}) // 成功数据
.catch(error => {...}) // 失败处理
}
jsBridge.register('bar', params => {
...
// 直接返回结果
return result
// Or 返回一个异步操作
return new Promise(...)
})
总体来讲,JsBridge 的设计并不复杂,只需要确定职责范围,针对性技术选型,再处理一部分兼容问题即可,剩下的就是具体业务功能的实现和联调了。
另外针对 URLScheme 有两个问题需要补充下:
在使用 URLScheme 时,会遇到“并发”问题,即同时发送多次调用,仅最后一个能正确传递到 native 端,前边的都被忽略了。针对这个问题我们可以使用纯 web 的解决方案,每次调用都创建一个新的 iframe,但考虑到频繁地创建销毁 DOM 元素,对整个页面会造成一定的影响,最终采用队列 + 确认的方案。
另外,URL 是有长度限制的,所以对于特别复杂的调用是有可能丢失数据的,这也是很多 JsBridge 实现采用 web 发送通知(有新消息),native 自己来取的策略。但这就增加了一个取数据的环节,考虑到这种情况极少,而且 URLScheme 只是我们的 fallback 方案,我们最终选择忽略这个问题。
基于资源离线和 JsBridge 的设计,WebView 容器的基础功能已经实现,之后依照规范对 native API 进行完善,整个重构就算完成了。除了常规的 API 外,我们还实现了 web 端请求代理功能,以解决在实际的业务场景中数据请求存在的种种问题。
利用 request 协议方法,将请求参数传递给 native 端,native 端封装好请求并发送,最终将返回数据挂载到 request 的回调中返回给 web 端。利用该方法能带来以下几个方面的提升:
在加载页面的同时,native 端根据配置参数(合并在离线配置中)提前发送请求并暂存,web 端运行时发送同样请求时,native 端通过比对将已经暂存的请求数据返回(如果请求还未完成则等待完成后返回)。通过简单的并行,缩短了用户的感知时间,特别针对首屏有数据依赖的页面,性能提升较为明显。
为了方便和统一,在 native 端封装了一套通用的请求 header,包含一些常用的设备信息、用户登录信息等,用于后端接口的数据查询和校验。而 web 端想保持一致的话,一些 header 信息需要先从 native 端取,然后自行封装发送,存在一定耗时且较为繁琐。利用 request 协议方法,web 端可以省略通用 header,统一由 native 端注入,既方便又能保持数据的一致性。
Native 端在处理 request 协议方法时,将请求和返回数据一并插入日志队列,集中存储,能更清晰的记录用户的行为和请求状态,改善了 web 端难以落日志的痛点,对于反馈问题的排查有很大帮助。
省去了跨域带来的种种麻烦。
除了资源加载,容器(WebView)的创建和初始化也存在一定的耗时,对此,我们创建了一个 WebView 的“池子”,当 APP 启动时,在主序列任务完成后,会主动创建一个 WebView 放进“池子”中,以供后续 web 页面的使用。同时,每当“池子”中的一个 WebView 被使用了便会再创建一个留作备用,依次循环。这就保证了,每次 web 页面加载时,总有一个 ready 状态的 WebView 可以立即使用。
从图中可以看出,受到业务接口的影响,平均整体耗时由 942.9ms 优化到了 391.1ms,优化幅度在 50% 左右,接口的平均耗时要高于静态资源的记载耗时。但随着业务逻辑复杂度的提升,资源加载和接口预请求的并行时间会进一步增加,收益也会相应的提升更多。
在如今各种混合框架/语言激烈竞争的大前端环境下,基于 WebView 的 H5 Hybrid 凭着简单、通用、轻量、稳定等特性,仍占据着重要的地位。而借助资源离线、数据预请求、native 方法注入等优化方案,对其在性能和硬件能力上的短板进行加强,使其能够承接更多的业务场景。