看完就懂的Hybrid框架设计方案
👉目录
1 前言
2 通信方案
3 离线包方案
4 容器基础能力
5 开发调试
6 稳定性与安全
7 番外篇
8 总结
01
JSBridge:它是前端和客户端通信的基础,是整套框架的核心之一。 Webview 容器:作为 H5 容器,需要提供一些基础的能力。 离线资源管理:客户端对本地离线资源的拉取/更新、拦截等策略。 开发调试:开发调试是业务开发的重要组成部分。 离线包管理后台:离线包版本管理系统。 后台服务:根据客户端版本,返回对应版本的离线包。 离线包协议:前端和客户端约定的离线包协议,前端需要构建出约定的离线包格式。 框架稳定性与安全:白屏检测,异常处理,异常上报等。
02
// 正常网页跳转地址const url = 'https://qq.com/xxx?param=xxx'// 约定跳转 urlconst fakeUrl = 'scheme://getUserInfo/action?param=xx&callbackid=xx'
协议用于通信标识:客户端只拦击该类型的协议。 路径用于标识客户端模块及方法。 参数用于数据传递。
// 1. A 标签发起一次<a href="scheme://getUserInfo/action?param=xx&callbackid=xx">用户信息</a>// 2. 在JS中创建一个iframe,然后动态插入到 DOM 中$('body').append('<iframe src="scheme://getUserInfo/action?param=xx&callbackid=xx"></iframe>');// 3. location.href 跳转location.href = 'scheme://getUserInfo/action?param=xx&callbackid=xx'
@Overridepublic boolean shouldOverrideUrlLoading(WebView view, String url) {// 1 根据url,判断是否是所需要的拦截的调用 判断协议/域名if (是){// 2 取出路径,确认要发起的native调用的指令是什么// 3 取出参数,拿到JS传过来的数据// 4 根据指令调用对应的native方法,传递数据return true;}return super.shouldOverrideUrlLoading(view, url);}
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {//1 根据url,判断是否是所需要的拦截的调用 判断协议/域名if (是){// 2 取出路径,确认要发起的native调用的指令是什么// 3 取出参数,拿到JS传过来的数据// 4 根据指令调用对应的native方法,传递数据// 确认拦截,拒绝WebView继续发起请求decisionHandler(WKNavigationActionPolicyCancel);} else {decisionHandler(WKNavigationActionPolicyAllow);}return YES;}
location.href = 'scheme://getUserInfo/action?param=111&callbackid=xx'location.href = 'scheme://getUserInfo/action?param=222&callbackid=xx'
方式二:弹窗拦截(alert/confirm/prompt)- 无明显短板,需要序列化参数,支持同步返回数据。
const data = {module: 'base',action:'getUserInfo',params:'xxxx',callbackId:'xxxx',};const jsonData = JSON.stringify([data]);// 发起调用,可以同步获取调用结果const ret = prompt(jsonData);
@Overridepublic boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {//1 根据传来的字符串反解出数据,判断是否是所需要的拦截而非常规H5弹框if (是){// 2 取出指令参数,确认要发起的native调用的指令是什么// 3 取出数据参数,拿到JS传过来的数据// 4 根据指令调用对应的native方法,传递数据return true;}return super.onJsPrompt(view, url, message, defaultValue, result);}
- (void)webView:(WKWebView *)webView runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt defaultText:(nullable NSString *)defaultText initiatedByFrame:(WKFrameInfo *)frame completionHandler:(void (^)(NSString * _Nullable result))completionHandler{// 1 根据传来的字符串反解出数据,判断是否是所需要的拦截而非常规H5弹框if (是){// 2 取出指令参数,确认要发起的native调用的指令是什么// 3 取出数据参数,拿到JS传过来的数据// 4 根据指令调用对应的native方法,传递数据// 直接返回JS空字符串completionHandler(@"");}else{//直接返回JS空字符串completionHandler(@"");}}
方式三:JSContext 注入 - 能力强大,遗憾的是只有 UIWebview 支持。不推荐使用
//准备要传给native的数据,包括指令,数据,回调等const data = {module: 'base',action:'getUserInfo',params:'xxxx',callbackId:'xxxx',};//直接使用这个客户端注入的函数nativeObject.getUserInfo(data);
方式四:安卓 addJavascriptInterface - 目前推荐的方案,具备 JSContext 注入的所有优点(限安卓 4.2 以上版本)
// 通过addJavascriptInterface()将Java对象映射到JS对象//参数1:Javascript对象名//参数2:Java对象名mWebView.addJavascriptInterface(new AndroidtoJs(), "nativeObject");
nativeObject.getUserInfo("js调用了android中的getUserInfo方法");
方式五:WKWebView MessageHandler 注入 - 官方钦点的通信 API,无需 JSON 化传数据,不丢消息,但不支持同步返回。
//准备要传给native的数据,包括指令,数据,回调等const data = {module: 'base',action:'getUserInfo',params:'xxxx',callbackId:'xxxx',};//传递给客户端,不支持同步获取结果window.webkit.messageHandlers.nativeObject.postMessage(data)
-(void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{//1 解读JS传过来的JSValue data数据NSDictionary *msgBody = message.body;//2 取出指令参数,确认要发起的native调用的指令是什么//3 取出数据参数,拿到JS传过来的数据//4 根据指令调用对应的native方法,传递数据}
最佳方式
iOS:推荐使用 MessageHandler + prompt 拦截两个方案并存,同时实现异步和同步调用。 Android:addJavaScriptInterface 能力强大,使用很方便,当下没有任何缺点。
iOS: evaluatingJavaScript。 安卓: 其实 2 个区别不大,使用方法差异也不大:
4.4 以上 evaluatingJavaScript。 4.4 以下 loadUrl。
function calljs(data){console.log(JSON.parse(data))//1 识别客户端传来的数据//2 对数据进行分析,从而调用或执行其他逻辑}
//不展开了,data是一个字典,把字典序列化NSString *paramsString = [self _serializeMessageData:data];NSString* javascriptCommand = [NSString stringWithFormat:@"calljs('%@');", paramsString];//要求必须在主线程执行JSif ([[NSThread currentThread] isMainThread]) {[self.webView evaluateJavaScript:javascriptCommand completionHandler:nil];} else {__strong typeof(self)strongSelf = self;dispatch_sync(dispatch_get_main_queue(), ^{[strongSelf.webView evaluateJavaScript:javascriptCommand completionHandler:nil];});}
calljs('{data:xxx,data2:xxx}');
mWebView.loadUrl("javascript:calljs(\'{data:xxx,data2:xxx}\')");
平台无关:两端的通信机制是有差异的,但对上层业务来说不需要关心这些差异;SDK 是纯 JS 逻辑的封装,和上层使用的业务框架无关(Vue / React 等均支持) 易用性:接入简单,通过 npm 安装后即可使用;有一定语义化的封装,比如查询设备信息,可以直接调用 sdk.getSystemInfo,而不用先去建立底层的通信;API 同时支持 Promsie / Callback 两种调用风格等 可扩展:SDK 除了要有良好的模块划分,还需要可扩展,为后续功能迭代打下基础
const invokeMap = new Map();let invokeId = 0;class BridgeNameSpace {/*** 调用Native功能* @param eventName - 事件名称* @param params - 通讯数据* @param callback - 回调函数*/invoke = (eventName, params, callback) => {invokeId += 1;invokeMap.set(invokeId, callback);if (isAndroid) {window.BridgeNameSpace.invokeHandler(eventName, params, invokeId);} else {window.webkit.messageHandlers.invokeHandler.postMessage({event: eventName,params,callbackId: invokeId,});}};/*** 调用Native功能* @param eventName - 事件名称* @param params - 通讯数据* @param callback - 回调函数*/invokeSync(eventName, params, callback) {invokeId += 1;invokeMap.set(invokeId, callback);if (isAndroid) {window.BridgeNameSpace.invokeHandler(eventName, params, invokeId);} else { // 将消息体直接JSON字符串化,调用 Prompt(),并且可以直接拿到返回值const result = prompt(JSON.stringify(params));return result;}}/*** Native将invoke结果返回给js的回调句柄* @param id - callbackId* @param params - 通讯数据*/invokeCallbackHandler = (id, params) => {const fn = invokeMap.get(id);if (typeof fn === 'function') {fn(params);}invokeMap.delete(id);};getSystemInfo(callback) {const promsie = new Promise((resolve, reject) => {this.invoke('getSystemInfo', {}, (res) => {if (res.status === 'success') {resolve(res);} else {reject(res);}});});if (callback) {return promsie.then(callback).catch(callback);}return promsie;}}window.BridgeNameSpace = new BridgeNameSpace();
JS 调用 invoke,生成一个唯一的 callbackId,将 callbackId 和 callback 注册到全局变量 invokeMap 中。 iOS 端,JS 将参数通过 MessageHandler 传递给 Native;安卓通过 Interface 注入的方式,JS 可以直接调用 Native 的方法。 Native 执行业务逻辑,并调用回调函数 BridgeNameSpace.invokeCallbackHandler。 通过调用时生成的唯一的 callbackId, 从 invokeMap 中找到最初发起调用的 JS callback,执行并回传数据。
BridgeNameSpace.getSystemInfo().then(res => {console.log(res);}).catch(err => {console.log(err);});BridgeNameSpace.getSystemInfo((res) => {console.log(res);});
场景二:当 Webview 可见时,JS 捕获这个时机来做相应的业务逻辑
const publishMap = {};class BridgeNameSpace {/*** 订阅 Native 事件* @param eventName - 事件名* @param callback - 回调函数*/subscribe = (eventName, callback) => {if (!publishMap[eventName]) {publishMap[eventName] = [];}const oldEvents = publishMap[eventName];publishMap[eventName] = oldEvents.concat(callback);};/*** Native将publish结果返回给js的回调句柄* @param eventName - 事件名* @param params - 调用参数*/subscribeCallbackHandler = (eventName, params) => {const cbs = publishMap[eventName] || [];if (cbs.length) {cbs.forEach((cb) => cb(params));}};/*** ⻚⾯可⻅通知*/onPageVisible(callback) {this.subscribe('onPageVisible',callback,);}}
BridgeNameSpace.onPageInvisible(() => {});场景三:打开了两个 Webview 页面 A B,B 页面向 A 页面传递一些数据
(一个 App 内在使用多套框架时,不同框架之间通信也可以基于这个模型)
Webview A 订阅事件,不同于场景二的订阅模式,订阅结果需要维护在 Native,所以这里需要有一次 JS -> Native 调用。 Webview B 发起通知,先通知到 Native,这里也有一次 JS -> Native 调用。 Native 收到通知后,发起一次广播,之前所有注册过的 Webview 都会收到通知,这里有一次 Native -> JS 调用。
JS -> Native 订阅其实就是一次基本的 JS -> Native 函数调用,这里需要约定一个特定的事件名。 JS -> Native 通知同理,也需要约定一个特定的事件名。 Native -> JS 广播,是类似于 invokeCallbackHandler、subscribeCallbackHandler 的回调调用,我们也用一个 notifyMap 来维护这个映射关系。
const notifyMap = new Map();class BridgeNameSpace {/*** 混合式框架向Native发送通知 notify* @param eventName - 事件名,命名空间为当前包* @param params - 参数对象,由通知业务自己定义* @param callback - 回调函数,回调是否通知成功*/notify = (eventName, params, callback) => {this.invoke('notify', { event: eventName, params }, callback);};/*** webview 事件处理函数,可与notify配合使用* 事件订阅方法,可对本应用及跨应用事件进行订阅* @param {String} eventName* @param {Function} callback*/subscribeNotify = (eventName, callback) => {this.invoke('subscribeNotification', { event: eventName }, (res) => {if (res.status === 'success') {notifyMap.set(eventName, callback);} else {callback(res);}});};/*** Native将notify结果返回给js的回调句柄* @param eventName - 事件名* @param params - 调用参数*/notifyCallbackHandler = (eventName, params) => {const fn = notifyMap.get(eventName);if ('function' === typeof fn) {fn(params);} else {notifyMap.delete(eventName);}};}
// Webview A 订阅BridgeNameSpace.subscribeNotify('QSOverlayPlayerBackClick',(res) => {console.log(res);});// Webview B 通知BridgeNameSpace.notify('QSOverlayPlayerBackClick',{ test: 'a' },(res) => {if (res.status === 'success') { console.log('通知成功');}});
不同环境的兼容适配(比如浏览器、微信、不同的 App 访问等)。 按模块职责进行划分,比如基础、路由、网络、UI 等。 规范函数命名:Native 回调均命名为 xxCallbackHandler、不支持 promise 风格调用的函数均已 onXX 开头。
03
build├── index.html└── static├── css│ ├── main.f855e6bc.css├── js│ ├── 787.d4aba7ab.chunk.js│ ├── main.8381e2a9.js└── img└── arrow.80454996.svg
page-frame.html,页面的入口文件。 config.json 页面配置文件,包含 Webview 容器的一些配置项,下面会单独介绍。 其他 js/css/img 等资源路径不作要求,因为构建时会自动处理好文件引用路径(即使有设置 publicPath,路径中也只是多了publicPath 一层路径)。
zip└── page-frame.html├── config.json├── css│ ├── main.f855e6bc.css├── js│ ├── 787.d4aba7ab.chunk.js│ ├── main.8381e2a9.js└── img└── arrow.80454996.svg
{"global": {"showNavigationBar": false,"themes": {"black": {"backgroundColor": "#0a0c0e"},"white": {"backgroundColor": "#FFFFFF"}}},"pages": {"index": {"showNavigationBar": false},"detail": {"showNavigationBar": true,"themes": {} }}}
[{name: 'https://domain-one.com/path/page-frame.html',test: function(options) {const {path} = options;return /NewsTZBD/i.test(path);},config: {global: {showNavigationBar: false,themes: {panda: {backgroundColor: "#f5f6fa",},black: {backgroundColor: "#12161f",},blue: {backgroundColor: "#f5f6fa",}},},pages: {index: {showNavigationBar: false,},},},}, {name: 'https://domain-two.com/path/page-frame.html',config: {},}]
pid:和页面访问地址一一对应。 verify_code:pid 和访问地址的加密校验码,访问带 pid 的 url 时,需要做一些安全校验。 pkg_md5:离线包 md5 值,用于校验离线包本身是否被篡改。 gray_rule:灰度规则。 pkg_url:离线包 cdn 地址。 sdk: 依赖的 App 最低版本,和 app 版本有一一对应的关系。 status:发布状态(未发布、灰度发布、全量)。 comment:本次发布描述。 author: 发布人。
最新离线包:离线包更新尽可能快 资源离线化:尽可能使用本地资源 高命中率:重要的模块,通过预下载,可以大大提高离线包命中率
离线包优先级。 离线包 CDN 地址。 离线包校验参数。
App 启动时。 N 分钟内 App 激活更新。
class BridgeNameSpace {/*** @params{Object} params 传递数据 { url, p_showNav}* params.url*/navigateTo(params) {this.invoke('navigateTo', params, () => {//})}}const url = 'https://domain.com/path/index.html?pid=xxx#/index';BridgeNameSpace.navigateTo({p_url: url,p_showNav: true,});
域名校验,不支持非白名单内的域名。 离线包 md5 校验,防止包被篡改。 verify_code 校验当前访问地址和 pid 是否匹配。
离线包构建时需要明确支持的最高 App 版本,版本信息可以放到项目工程配置文件里。 App 在拉取配置文件/拉取单个离线包时,后台根据当前 App 版本及灰度规则返回正确的离线包。
首先 sdk2.3.0 对应的离线包不能返回,因为它们要求最小支持 App 版本是 10.2.0,一旦返回了可能导致有些 API 调用失败,[email protected] 上没有对应的实现。 如果命中了灰度,则返回 [email protected] 下的离线包版本 1。 如果未命中灰度,则返回 [email protected] 下的离线包版本 1,JSBridge SDK 通常是向下兼容的,低版本离线包调用的 JSBridge API 高版本的 App 都支持。
04
Native UI 组件:Toast、Loading。 内嵌 Native 能力:Native Header、分享面板、下拉刷新。
class BridgeNameSpace {/*** 显示toast* @param {String} position 弹出位置,center(中间),top(顶部)* @param {String} text 要提示的⽂字*/showToast(position, text, callback) {this.invoke('showToast', { position, text }, callback);},/*** loading view控制 loadingBar* @param {String} action: show/hide, 控制显示/隐藏*/loadingBar(action, callback) {this.invoke('loadingBar', { action }, callback);}}
统一 App 风格,做到一致的交互体验。 JS 异常导致白屏时,防止 App 陷入假死状态,Native Header 可以控制页面后退。
左边区域比较简单,只有一个返回按钮,关闭当前 Webview。 标题部分,可以设置标题和子标题,注意需要控制和 document.title 的关系。 功能区:可以设置分享、字体控件等入口。
class BridgeNameSpace {setHeaderConfig(config, callback) {this.invoke('setHeaderConfig', {title: config.title,subTitle: config.subTitle,right: [{actionName: 'font',}, {actionName: 'share',// 可传入图标,没有使用系统默认的icon: '',}]}, callback);},/*** 监听按钮点击事件*/onHeaderButtonClick(callback) {this.on('onHeaderButtonClick', callback);}}
class BridgeNameSpace {/*** 启用下拉刷新(默认关闭),前端仍然可以决定是否使用 Native 刷新控件* @param {Boolean} enabled 下拉刷新开启标识* @param callback*/enablePullDownRefresh(enabled, callback) {this.invoke('enablePullDownRefresh', { enabled }, callback);},/*** 下拉刷新,通过 API 调用即可触发,和手动刷新一致* @function startPullDownRefresh*/startPullDownRefresh(callback) {this.invoke('startPullDownRefresh', {}, callback);}/*** 下拉刷新完成调用,将收起下拉刷新条*/stopPullDownRefresh(callback) {this.invoke('stopPullDownRefresh', {}, callback);},/*** 下拉刷新触发通知* @param {Function} callback 回调函数*/onPullDownRefresh(callback) {this.on('onPullDownRefresh', callback);}}
常用的功能点,比如分享到微信、QQ,我们考虑封装到 Native 模块内部,直接通过 API 调用即可,方便业务快速接入使用。 不常用的功能模块(比如复制链接、设置皮肤等),通过传入参数控制,做到灵活配置化。
05
开发阶段:开发阶段能够热更新,实时查看改动效果,突出快。 发布前:测试环境、预发布环境充分验证,需要环境切换能力。 正式发布:验证最终效果是否符合预期,需要环境切换能力。
扫码:可以扫任意的 http(s) 协议地址,可以是 CDN 地址,也可以是同网段的 ip 地址。 输入框:支持手动输入 URL。 打开按钮:打开输入框里面的地址。 导航开关:打开的页面是否展示 Native Header。
测试环境:对应 App 开发、测试、预发布等非正式环境。 正式环境:对应正式环境。
06
资源安全性检测:检查离线包是否有被篡改,可以是包维度的检查,也可以是针对具体的资源文件。 域名白名单:App 内加载的所有 H5 检查域名是否是白名单之内。非白名单内的用户限制调用 JSBridge,并做好相应的安全提示。
在一些特殊的业务场景,比如证券交易,容器需要限制不满足合规要求的操作。 像微信小程序一样,限制使用浏览器 API。
07
08
本篇文章的完成,离不开前人经验的总结,甚至有部分代码是直接参考,以下是主要参考链接:
移动 H5 首屏秒开优化方案探讨:https://blog.cnbang.net/tech/3477/
70%以上业务由H5开发,手机QQ Hybrid 的架构如何优化演进?:https://mp.weixin.qq.com/s/evzDnTsHrAr2b9jcevwBzA
📢📢欢迎加入腾讯云开发者社群,享前沿资讯、大咖干货,找兴趣搭子,交同城好友,更有鹅厂招聘机会、限量周边好礼等你来~
(长按图片立即扫码)