在移动端App开发中,由于H5 Web页面具有原生应用不具备的多平台复用、热更新等诸多便利特性,我们往往会将一部分对性能体验要求不是特别高的页面采用H5 Web完成,然后App基于WebView作为容器承载页面,而跨端通讯就是这一场景下的刚需功能。
1.WebView URL Scheme拦截;
2.原生App获取JS上下文,将API注入Window;
3.WebView 中的 prompt/confirm/alert 拦截;
得物App现有的跨端通讯方式主要为URL Scheme拦截,所以本篇着重介绍跨端通讯中URL Scheme拦截的实现原理以及对应的JS-SDK的重构与优化。
URL Scheme拦截
原理
H5向App发送数据
当我们在App WebView中加载了一个H5 Web网站,App就可以获取到当前这个WebView的JSContext,与此同时,我们在WebView中发起的网络请求,都可以在Native层得到通知,于是在WebView中,App可以进行监听和捕获这些请求。
App向H5发送数据
JSContext
一个JSContext表示了一次JS的执行环境。我们可以通过创建一个JSContext去调用JS脚本,访问一些JS定义的值和函数,同时也提供了让JS访问Native对象,方法的接口。
因此,App只需要调用暴露在Window上的函数,就可以完成数据的通信。
重构
为什么要重构?
跨端通信SDK本质上是应用层面的一种协议的实现,因此不需要频繁的迭代和维护,根据SDK选取的通信方式和一些简单的代码组织,我们很快就可以构建出一套适用业务的通信SDK,在业务早期,我们很多项目中都是采用同一个单文件JS静态资源来做跨端通讯,但是随着业务需求越来越复杂,项目越来越多,单文件的静态js的劣势逐渐彰显:
- 没有npm包管理机制,缺少来源统一的SDK,尽管通信方式绝大多数情况下不会发生变化,但是各个项目下对SDK本身做了不同程度的魔改,那么在切换项目开发的时候入手成本就会变高。
- 缺少类型提示,在主流的VScode + TS大环境下,引入一个没有任何类型定义的JS文件、靠AnyScript + 类型断言才能不报红的开发体验是糟糕的,即使加上了d.ts定义也只能兼顾在当前的项目类型提示完善。
- 有调试SDK的需求时,看着一坨编译后的代码一筹莫展,无从下手。即使改了之后,因为跨端通讯SDK的验证链路也相对较长(双端与H5的双向通讯都要进行验证),也不一定敢在另一个项目中直接引用。
- 由于通信方式限制,SDK的方法需要暴露在全局变量上,原版SDK并没有暴露修改内部行为的方法,除了修改SDK本身之外,想扩展/修改方法只能依赖重写暴露在全局的方法,这样的行为并不友好。
综上,重构一版基于TS & npm包管理的跨端通讯SDK是非常有必要的,利于持续维护、持续扩展。
重构前的结构
整体结构
部分关键代码如下:
先别惊讶,重构前的源代码即是如此,源文件修改自JockeyJS ,针对需要的功能做了一些增删。
关键点
-
整体为一个闭包函数,在最后一步将Jockey对象暴露在Window上,让人难免想到了jQuery。
-
设计上采用了发布-订阅模式。
重构过程
整体设计
首先我们需要考虑的是选择怎样组织整体的代码结构,初版SDK是将对象组合并且挂载到Window对象,那么根据“两点之间,直线最短”的理论,用面向对象重构是最合适有效的。
TS对面向对象的支持也相当完备,我们可以用 Interface / Abstract class 特性进一步规范class的类型和成员类型。
所以我们可以拆分成两个主要的类,其中Jockey类作为向外部暴露的类,Dipatch类作为DisPatchAbstract类的实现——跨端通讯方法的实际执行者,被Jockey的构造函数注入到内部。
关键代码如下:
abstract class DisPatchAbstract
// ...
abstract jockey?: Jockey;
// ...
}
export class DisPatch implements DisPatchAbstract {
// ...
}
export class Jockey {
// ...
constructor({ dispatch }: { dispatch?: DisPatchAbstract}) {
this.dispatcher.jockey = this;
}
// ...
}
重构细节
重构首先要保证的是原有功能的正确执行,因此准确“翻译”原版的每一行代码是最重要的,这是个体力活,只要细心认真、保证测试覆盖率就能做好。
针对本次SDK重构,比较值得注意的几个点:
-
不同于如今常用的箭头函数,原版的this有通过变量保存调用,所以需要注意指向问题;
-
原版SDK是编译后的文件,有很多正常写代码时不会用到的hack手段,比如:
- for循环初始条件中定义变量;
- 多个语句通过括号与逗号配合条件判断连接组合执行,例:
t instanceof Function && ((n = t), (t = null)), (t = t || {}), (n = n || function () {})
,所以在翻译这些功能时需要格外注意执行的顺序和变量的赋值;
重构之后的结构
整体结构
采用TypeScript重写,选择面向对象语法,保留原先的发布-订阅模式,构建流程采用rollup打包,最终生成umd/cjs/esm三种模式的代码,同时自动生成d.ts文件。整体结构的类图如下:
关键代码解读
Jockey.send方法
Jockey.send方法用来向Native发送数据,这里的主要通讯流程:
- Jockey调用Dispatch.send方法;
- Dispatch.send调用Dispatch.dispatchMessage方法;
- Dispatch.dispatchMessage内部创建一个iframe元素,填入src,并添加到dom中;
- iframe经由WebView发送指定
jockey://
开头的网络请求,并注册回调函数到Dispatch.callbacks成员变量; - Native层拦截请求,拿到传递的数据,触发Jockey.triggerCallback函数(下一小节会详细分析这一步的流程)
关键代码片段:
class Jockey {
// ...
send<T = any>(type: string, payload?: CallBack<T> | any | null, callBack?: CallBack<T>) {
if (payload instanceof Function) {
callBack = payload;
payload = null;
}
payload = payload || {};
callBack =
callBack ||
function () {
// DO NOTHING
};
const envelope = {...payload}
this.dispatcher && this.dispatcher.send(envelope, callBack);
}
// ...
}
class DisPatch implements DisPatchAbstract {
// ...
send(envelope: Envelope, complete: CallBack) {
this.dispatchMessage('event', envelope, complete);
dispatchMessage(type: string, envelope: Envelope, complete: (data: unknown) => void) {
this.callbacks[envelope.id] = (returnStr: unknown) => {
complete(returnStr);
};
const src =
'jockey://' + type + '/' + envelope.id + '?' + encodeURIComponent(JSON.stringify(envelope));
let iframe: null | HTMLIFrameElement = document.createElement('iframe');
iframe.setAttribute('src', src);
document.documentElement.appendChild(iframe);
iframe.parentNode && iframe.parentNode.removeChild(iframe);
iframe = null;
}
// ...
}
Jockey.triggerCallback方法
Jockey.triggerCallback主要由Native层调用,Native通过调用Jockey.triggerCallback方法,来触发我们在Jockey.send流程中注册的回调函数,主要流程:
- Native层在接收到send方法传递的数据后,执行H5端需要的操作之后,在WebView的JS上下文环境中执行Jockey.triggerCallback;
安卓环境执行的示例代码:
public class DefaultJockeyImpl extends JockeyImpl {
@Override
public void triggerCallbackOnWebView(WebView webView, int messageId) {
String url = String.format("javascript:Jockey.triggerCallback(\"%d\")",
messageId);
webView.loadUrl(url);
}
}
- Jockey.triggerCallback调用Disptach.triggerCallback,然后触发我们在Jockey.send阶段注册的回调,并传入回调数据;
关键代码片段:
class Dispatch {
// ...
triggerCallback(id: string, returnStr: unknown) {
setTimeout(() => {
this.callbacks[id](returnStr);
}, 0);
}
// ...
}
Jockey.on & Jockey.trigger方法
Jockey.on & Jockey.trigger的流程和Jockey.send & Jockey.triggerCallback流程类似,在此不在过多赘述。
不同于Jockey.send方法只注册一个回调函数,Jockey.on调用后在内部维护了一个该事件的回调队列,每次监听相同的event都会往队列中加入一个回调,Native层调用Jockey.trigger后,会遍历这个队列,并执行其中的回调函数。
重构之后的扩展
在完成重构之后,得益于TS强大的类型支持以及合理的设计,就可以方便地进行功能扩展和优化了。
Jockey.off
在初版SDK中,Jockey.off(取消监听)的功能为清空指定event类型的监听回调队列,并不支持清除指定的单个回调,这个特性并不符合熟悉removeEventListener能力的前端jser的直觉,于是我们扩展了和removeEventListener一样的特性,接收第二个参数,并且全等匹配在监听队列中回调函数的引用,如果相同就会清除单个监听而非原版的整个监听事件移除。
开放实例化时机 & 勾子函数
在原版中,Jockey对象的挂载是在立即执行的闭包函数中,这样想修改类的行为就需要在全局环境中拿到Jockey对象对其进行修改,这样并不优雅,在esm的引入模式下,SDK的引入方其实可以获得更多的控制权。
于是,重构后决定在esm模式下导出class本身,开放手动实例化的能力,并且支持一些常用的勾子。
关键代码实现:
class Jockey {
private hooks: Required<Hooks> = {
beforeOn: () => true
};
constructor({ dispatch, applyHooks }: { dispatch?: DisPatchAbstract; applyHooks?: Hooks } = {}) {
if (applyHooks) {
this.applyHooks(applyHooks);
}
}
applyHooks(applyHooks: Hooks) {
Object.entries(applyHooks).forEach((item) => {
const [hookName, hook] = item;
this.hooks[hookName] = hook;
});
}
send() {
if (!this.hooks.beforeSend()) {
return;
}
// ...
}
}
发现问题的能力有时候比解决问题的能力更重要,其实重构不是一件难事,只需要一点勇气、一点耐心和一点细心。
解决一个“历史包袱”,就可以解决一个痛点,解决了越来越多的痛点,技术团队才能更好地发展,才能更好地向前一步。
参考文章
深入理解JSCore
原生App与javascript交互之JSBridge接口原理、设计与实现
H5与Native交互之JSBridge技术
JSBridge的原理 - 掘金
JavaScript 发布-订阅模式 - 掘金
对应链接如下 https://tech.meituan.com/2018/08/23/deep-understanding-of-jscore.html https://segmentfault.com/a/1190000008012111 https://segmentfault.com/a/1190000010356403 https://juejin.cn/post/6844903585268891662 https://juejin.cn/post/6844903850105634824
*文:航飞