混合开发
混合开发
Hybrid App:混合了 Native(原生)技术和 web 技术进行开发的移动应用。主要有以三种方式:
- 基于
WebView UI(JSBridge)
: 通过JSBridge
完成Web端
和Native端
的通讯,从而赋予Web端
原生能力 - 基于
Native UI(ReactNative、Weex)
: 在赋予Web端
原生能力的基础之上,通过JSBridge
将 js 解析成虚拟节点树(Virtual Dom)传递到Native端
并使用原生渲染 - 小程序方案(微信、支付宝小程序等): 通过更加
定制化的JSBridge
,并使用双 WebView
、双线程
的模式隔离了JS逻辑
与UI渲染
,形成了特殊的开发模式,加强了 Web 端与 Native 端混合程度,提高了页面性能及开发体验。
Hybrid App 的本质
在原生(Native) APP 中,使用WebView
作为容器,来承载一个 Web 页面。
原生(Native)和 Web 端的双向通讯层(跨语言解决方案) —— JSBridge
- 定义: 用 JavaScript 搭建起来的桥, 一端是 Web 端, 一端是 Native。
- 目的: 让 Native 端可以调用 Web 的 JavaScript 代码, 让 Web 端可以调用 Native 的原生代码
Web端
通知Native端
Web 端通知 Native 端三种常见方案:
1. API 注入
通过向Window注入对象
的方式来提供可被 Web 端调用的方法。Android 端可直接获取注入对象,调用方法
;IOS 端则为相对固定写法(window.webkit.messageHandlers.方法名.postMessage(入参对象)
)
相同点
- 通过
WebView
来完成网页的加载 - 通过
向Window注入对象
的方式来提供可被 Web 端调用的方法 - 可以直接调用 Web 端
挂载到Window对象
下的方法
- 通过
不同点
Android IOS 注入对象 可提供 注入对象名
固定为 webkit
JS 调用 Native 方式 可直接 获取注入对象,调用方法
相对固定写法( window.webkit.messageHandlers.方法名.postMessage(入参对象)
)传递数据格式 只能接受 基本类型数据
可以接受 任意类型数据
返回值 可直接接受返回值 无法直接获取返回值
prompt/console/alert
拦截
2. WebView 中通常拦截prompt
,因为在前端中使用频率低。(prompt()方法用于显示可提示用户进行输入的对话框)
3. WebView URL Scheme 跳转拦截方式
第二种、第三种原理类似,通过对 WebView 信息冒泡传递的拦截,从而达到通讯。
解析: 原理 --> 定制协议 --> 拦截协议 --> 参数传递 --> 回调机制
原理: 在 WebView 中发出的网络请求,客户端都能进行监听和捕获
定制协议
制定一套 URL Scheme 规则,可将协议类型的请求订制为:
xxcommand://xxxx?param1=1¶m2=2
。(通常请求会带有对应的协议开头,例如https://xxx.com
或者file://1.jpg
。)xxcommand://
只是一种规则,可以根据业务进行制定,使其具有含义。不同的协议头代表着不同的含义,这样便能清楚知道每个协议的适用范围。(xxcommand://
为通用工具协议,xxapp://
为每个 App 单独的业务协议。)- 不要使用
location.href
发送,因为其自身机制存在问题,同时并发多次请求会被合并成为一次,导致协议被忽略,而并发协议其实是非常常见的功能。我们会使用创建 iframe 发送请求
的方式。 - 在客户端中设置域名白名单或者限制,避免公司内部业务协议被第三方直接调用。
拦截协议: 客户端可以通过 API 对 WebView 发出的请求进行拦截
- Android:
shouldOverrideUrlLoading
- IOS:
shouldStartLoadWithRequest
当解析到请求 URL 头为制定的协议时,便不发起对应的资源请求,而是解析参数,并进行相关功能或者方法的调用,完成协议功能的映射。
- Android:
协议回调
由于协议的本质其实是发送请求,这属于异步的过程,因此需要处理对应的回调机制。采用的方式是
JS的事件系统
,会用到两个基础 APIwindow.addEventListener
和window.dispatchEvent
发送协议时,通过协议的唯一标识注册自定义事件,并将回调绑定到对应的事件上。
客户端完成对应的功能后,调用
Bridge
的dispatch API
,直接携带 data 触发该协议的自定义事件。Bridge.getNetwork(data => {}) // 业务调用API // Bridge层功能:生成唯一标识 handler;注册自定义事件;拼接参数并发送协议 const handler = 1 window.addEventListener(`getNetwork_${handler}`, callback, false) Bridge.send(`xxcommand://getNetwork?handler=${handler}`) // Native层获取网络状态后通过bridge再次传回,将网络状态通过事件直接出发自定义事件并传递参数 event.data = network window.dispatchEvent(event)
应该避免事件的多次重复绑定,因此当唯一标识重置时,需要
removeEventListener
对应的事件。
参数传递方式
由于 WebView 对 URL 会有长度的限制,当需要传递的参数过长时,可能会导致被截断。所以使用
函数调用
的方式。这里的原理主要是基于:Native 可以直接调用 JS 方法并直接获取函数的返回值
。只需要对每条协议标记一个唯一标识,并把参数存入参数池中,客户端再通过该唯一标识从参数池中获取对应的参数即可。
Native端
通知Web端
Native 可以通过WebView API
直接执行 JS 代码
Android
loadUrl
(版本 4.4-)// 调用js中的JSBridge.trigger方法, 该方法的弊端是无法获取函数返回值 webView.loadUrl("javascript:JSBridge.trigger('NativeCall')")
当系统低于 4.4 时,evaluateJavascript 是无法使用的,因此单纯的使用 loadUrl 无法获取 JS 返回值,需要使用
prompt
的方法进行兼容,让 H5 端通过prompt
进行数据的发送,客户端进行拦截并获取数据。evaluateJavascript
(版本 4.4+)// 4.4+后使用该方法便可调用并获取函数返回值; mWebView.evaluateJavascript("javascript:JSBridge.trigger('NativeCall')", new ValueCallback<String>() { @Override public void onReceiveValue(String value) { //此处为 js 返回的结果 } });
IOS:
stringByEvaluatingJavaScriptFromString
webview.stringByEvaluatingJavaScriptFromString("alert('NativeCall')")
JSBridge 的接入
- JS 部分(bridge): 在 JS 环境中注入 bridge 的实现代码,包含了协议的拼装/发送/参数池/回调池等一些基础功能。
- Native 部分(SDK): 在客户端中 bridge 的功能映射代码,实现了 URL 拦截与解析/环境信息的注入/通用功能映射等功能。
将这两部分封装成一个 Native SDK
,由客户端统一引入。客户端在初始化一个 WebView 打开页面时,如果页面地址在白名单中,会直接在 HTML 的头部注入对应的 bridge.js。这样的做法有以下的好处:
- 双方的代码统一维护,避免出现版本分裂的情况。有更新时,只要由客户端更新 SDK 即可,不会出现版本兼容的问题;
- App 的接入十分方便,只需要按文档接入最新版本的 SDK,即可直接运行整套 Hybrid 方案,便于在多个 App 中快速的落地;
- H5 端无需关注,这样有利于将 bridge 开放给第三方页面使用。
协议的调用,一定是需要确保执行在 bridge.js 成功注入后。由于客户端的注入行为属于一个附加的异步行为,从 H5 方很难去捕捉准确的完成时机,因此需要通过客户端监听页面完成后,基于上面的事件回调机制通知 H5 端,页面中即可通过window.addEventListener('bridgeReady', e => {})
进行初始化。
APP 中 H5 的接入方式
在线 H5: 将 H5 代码部署到服务器上,只要把对应的 URL 地址 给到客户端,用 WebView 打开该 URL,即可嵌入。
- 优点:
- 独立性强,有非常独立的开发/调试/更新/上线能力
- 资源放在服务器上,完全不会影响客户端的包体积
- 接入成本很低,完全的热更新机制
- 缺点:
- 完全的网络依赖,在离线的情况下无法打开页面
- 首屏加载速度依赖于网络,网络较慢时,首屏加载也较慢
通常,这种方式更适用在一些比较轻量级的页面上,例如一些帮助页、提示页、使用攻略等页面。这些页面的特点是功能性不强,不太需要复杂的功能协议,且不需要离线使用。在一些第三方页面接入上,也会使用这种方式,例如页面调用微信 JS-SDK。
- 优点:
内置包 H5: 本地化的嵌入方式,将代码进行打包后下发到客户端,并由客户端直接解压到本地储存中。通常运用在一些比较大和比较重要的模块上。
- 优点:
- 由于其本地化,首屏加载速度快,用户体验更为接近原生
- 可以不依赖网络,离线运行
- 缺点:
- 开发流程/更新机制复杂化,需要客户端,甚至服务端的共同协作
- 会相应的增加 App 包体积
- 优点:
WebView URL Scheme 跳转拦截实现
bridge.js 架构
核心代码:
- 最重要的开放 API:
nativeCall
与postMessage
- 客户端获取参数函数:
getParam
- 事件回调系统中的
addEvent
和fireEvent
- 用于发送协议的
send
使用示例(获取网络状态为例)
Bridge.nativeCall('getNetwork', {}, e => {
if (e.data && e.data.type === 0) {
// 无网络状态
} else {
// type: wifi/2g/3g/4g
}
})
H5 --> Native
H5
- 生成唯一
handler
标识,从 0 开始累加 - 将参数按
handler
值的规则存入参数池(_paramsStore
)中 - 以
handler
注册自定义事件,绑定callback
,并将 callback 也存入_callbackStore
中,注册自定义事件addEvent()
。储存的目的主要是为了事件解绑时使用 - 以
iframe的形式
发送协议,并携带唯一标识 handler,发送协议请求 send()
let i = 0 function nativeCall(scheme = throwError(), params, callback) { // 对参数进行字符串化,并进行编码 params = params ? decode(JSON.stringify(params)) : '' // 生成唯一 handler 标识 const handler = i++, handlerKey = getHandlerKey(handler) // 将参数存储进参数池 _paramsStore.save(handlerKey.p, params) if (isFn(callback)) { // 将回调储存进回调池 _callbackStore.save(handlerKey.c, callback) // 注册自定义事件,并绑定回调:回调会在接受到 postMessage 时被触发执行 addEvent(handlerKey.e, e => { const { data, handler } = e.data // 完成一次完整交互时,将自定义事件解绑 removeEvent(handler) callback.call(MTJs, data) }) } // 发送协议请求 send(`${scheme}?handler=${handler}`) } function send(scheme) { setTimeout(() => { // 创建 iframe 并设置 src const iframe = document.createElement('iframe') iframe.src = scheme iframe.style.display = 'none' document.body.appendChild(iframe) // 延迟删除节点 setTimeout(() => { iframe.parentNode.removeChild(iframe) }, 300) }, 0) return this }
- 生成唯一
Native
客户端接收到请求后,会使用
handler
调用getParam
从参数池中获取对应的参数。function getParam(handler = throwError()) { const key = getHandlerKey(handler).p return _paramsStore.get(key) }
执行协议对应的功能
Native --> H5
- Native: Native 完成功能后,直接调用
Bridge.postMessage(handler, data)
,将执行结果和之前nativeCall
传过来的标识
回传给H5
。 - H5: H5 在接收到唯一标识后初始化对应的自定义事件,挂载数据后触发。
function postMessage(e) {
// 客户端携带 handler 和 data 调用 postMessage
const { handler, data } = e
// 获取对象的自定义事件名 e__handler
const evName = getHandlerKey(handler).e
// 创建并触发自定义事件
fireEvent(evName, e)
return this
}
function fireEvent(evName, data) {
// 创建自定义事件对象
let eventItem
if (isFn(doc.CustomEvent)) {
eventItem = new doc.CustomEvent(evName, {
bubbles: true,
cancelable: true,
})
} else if (isFn(doc.createEvent)) {
eventItem = doc.createEvent('Event')
eventItem.initEvent(evName, true, true)
}
// 将数据挂载到事件对象中
if (data && eventItem) {
eventItem.data = data
}
// 触发自定义事件
if (eventItem) {
win.dispatchEvent(eventItem)
} else {
log('Bridge Error: dispatchEvent')
}
return this
}
Android 兼容性
问题: 在 Android 4.4
以下时,使用的 loadUrl
进行 js 函数的调用,此时是无法获取函数的返回值的,即:Android 4.4-
时,安卓并无法通过 · 这个函数来获取到协议的参数,需要做兼容性的处理。
解决方法: WebView 中的 prompt
拦截
当安卓接受到协议,并拿到
handler
值使用无兼容性问题的
loadUrl
执行js:Bridge.getParam(handler)
,直接将返回值直接通过 js 中的 prompt 发出:webview.loadUrl("javascript:prompt('Bridge:commonJsExecute#1', Bridge.getParam(1))")
通过重写
onJsPrompt
这个方法,拦截上一步发出的prompt
的内容,并解析出相应的参数@Override public boolean onJsPrompt(WebView webiew, String url, String message, String defaultValue) { // 获取 prompt 内容 message,匹配并解析出对应的参数字符串,并解析 }
协议的制定
可将协议分成功能协议和业务协议
功能协议
用于完善整套方案的基础功能的一些通用协议,以 command://作为通用头,封装在 SDK 之中,可以在全线 App、全线 WebView 中使用。
初始化机制
由于 bridge.js 注入的异步性,需要由客户端在注入完成后通知 H5。通用的初始化事件,这里约定为 init
// 用于标记避免事件被重复触发 // 由于客户端中是通过监听 WebView 的生命周期钩子来触发的,而 iframe 之类的操作会导致这些钩子的多次触发,因此需要双方各做一层防御性措施。 let isLoaded = false window.addEventListener( '_init_', e => { if (isLoaded) return false isLoaded = true // 代码逻辑... }, false )
通过该事件,直接初始化传给 H5 一些环境参数和系统信息等
e.data = { name: 'CloudFilter', // 应用名 appVersion: '1.0.0', // app版本号 version: '1.112', // H5包版本号 platform: 2, // 平台 - 1: ios; 2: Android area: '中国大陆', // 地区 language: 'zh', // 语言 env: 2, // 当前App环境 - 0: release; 1: pre; 2: dev data: {}, // 参数池 }
可以约定更多的页面生命周期事件。例如因为 App 很经常性的隐藏到后台,因此在被激活时,我们可以设置个生命周期: resume,可以用于告知 H5 页面被激活。
包更新机制
问题: Hybrid 模块 的其中一种方式是将前端代码打包后内置于 App 本地,以便拥有最快的启动性能和离线访问能力。这种方式最大的麻烦点:代码的更新
解决方法: 需要一套新的热更新机制,这套机制需要由客户端/前端/服务端三端提供对应的资源,共同协作完成整套流程。
资源
- H5: 每个代码包都有一个唯一且递增的版本号
- Native: 提供包下载且解压到对应目录的服务,前端可以由下面这个协议来调用该功能。
Bridge.nativeCall('downloadModule', { module: appName, // 应用名称 url: zipUrl, // 最新包线上地址 })
- 服务端: 提供一个接口,可以获取线上最新代码包的版本号和下载地址。
流程
- 前端更新代码打包后按版本号上传至指定的服务器上
- 每次打开页面时,H5 请求接口获取线上最新代码包版本号,并与本地包进行版本号比对,当线上的版本号 大于 本地包版本号时,发起包下载协议
- 客户端接收到协议后,直接去线上地址下载最新的代码包,并解压替换到当前目录文件
环境系统 和 多语言系统
可以通过 init 中携带的数据 data.env 来匹配
环境机制通常主要用于匹配后端的环境,正式环境和测试环境对应不同的接口。还需要注意代码包的更新,包更新条件要包含三个方面: 版本号、环境和 App 版本,在不同环境不同 App 版本下,也应该更新到相应的最新代码包。
事件中转站
当页面中有弹窗或者 SPA 切换页面时,安卓的返回实体键应该能完成对应的回退,而不是因为 WebView 没有 history 就直接关闭。
可以定制一个事件中心(eventListeners ),用于监听客户端的实体返回键:
// 事件中心 addEvent('_eventListeners', e => { const type = e.data.type switch (type) { case 'back': // 关闭弹窗: this.closeDialog() // 退回页面: this.goLastPage() break case 'hideLoading': // 隐藏loading: this.hideLoading() break default: break } })
数据传递机制
很多场景需要做到 Native 与 H5 保持数据的同步,此时就可以使用类似上面的原理,制定一套数据传递协议:
// 推送数据 Bridge.nativeCall('putData', { a: 1, b: 2, }) // 监听数据通道 addEvent('getData', e => { // type: 代表数据类型,可自行约定 // data: 数据 const { type, data } = e.data switch (type) { case 'list': // 获取客户端传递过来的列表数据 data break default: break } })
Hybrid 模块通常需要从对应的入口进入,因此有一种可以优化的方式:由 App 在启动时先去获取线上数据,在进入 WebView 后直接通过 init 或者触发 getData 直接发送给 H5,这样能减少请求数量,优化用户体验。
代理请求
H5 中最常用的就是请求,通常我们可以直接使用 ajax,但是这里有几个问题比较棘手:
- 最常见的请求跨域
- 数据算法加密
- 用户登录校验
客户端的请求便不会出现这些问题,因此我们可以由客户端代理我们发出的请求,可以定制 4 个协议:
getProxy
、postProxy
、getProxyLogined
、postProxyLogined
,其中带有Logined
的协议代表着在请求时会自动携带已登录用户的token
和uid
等参数,使用在一些需要登录信息的接口上。这样做的好处是:- H5 方就无需处理繁多的各项复杂信息,不需要进行跨端传输
- 能够对 H5 与 Native 的请求出口进行统一,方便加工处理
Bridge.nativeCall('getProxy', { url, data, headers }, e => { if (e.data && e.data.code == 110) { // 请求失败 } else { // 请求成功,返回数据 e.data } })
更多通用性协议
定义更多的通用性协议,这里有个原则可以遵守,即协议应该是基础性功能,应该是纯净的,适用于所有的业务方。
getNetwork
:获取网络状态;openApp
:唤起其它 App;setShareInfo
与callShare
:分享内容到第三方平台;link
:使用新的 WebView 打开页面;closeWebview
:关闭 WebView;setStorage
与getStorage
:设置与获取缓存数据;loading
:调用客户端通用 Loading;setWebviewTitle
:设置 WebView 标题;saveImage
:保存图片到本地;- ...
业务协议
这类协议区别于功能协议,它们会杂合一定程度的业务逻辑,而这些逻辑只是针对于特定的项目。
// 示例
Bridge.nativeCall(
'app://getResultImage',
{
category_id,
material_id: materialId,
subcat_id: subcatId,
origin_image,
zip_url,
},
data => {
// 获取处理后的效果图路径
}
)
参考: