从重构到扩展——跨端通讯SDK

技术

在移动端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的劣势逐渐彰显:

  1. 没有npm包管理机制,缺少来源统一的SDK,尽管通信方式绝大多数情况下不会发生变化,但是各个项目下对SDK本身做了不同程度的魔改,那么在切换项目开发的时候入手成本就会变高。
  2. 缺少类型提示,在主流的VScode + TS大环境下,引入一个没有任何类型定义的JS文件、靠AnyScript + 类型断言才能不报红的开发体验是糟糕的,即使加上了d.ts定义也只能兼顾在当前的项目类型提示完善。
  3. 有调试SDK的需求时,看着一坨编译后的代码一筹莫展,无从下手。即使改了之后,因为跨端通讯SDK的验证链路也相对较长(双端与H5的双向通讯都要进行验证),也不一定敢在另一个项目中直接引用。
  4. 由于通信方式限制,SDK的方法需要暴露在全局变量上,原版SDK并没有暴露修改内部行为的方法,除了修改SDK本身之外,想扩展/修改方法只能依赖重写暴露在全局的方法,这样的行为并不友好。

综上,重构一版基于TS & npm包管理的跨端通讯SDK是非常有必要的,利于持续维护、持续扩展。

重构前的结构

整体结构

部分关键代码如下: picture.image

先别惊讶,重构前的源代码即是如此,源文件修改自JockeyJS ,针对需要的功能做了一些增删。

关键点

  1. 整体为一个闭包函数,在最后一步将Jockey对象暴露在Window上,让人难免想到了jQuery。

  2. 设计上采用了发布-订阅模式。

重构过程

整体设计

首先我们需要考虑的是选择怎样组织整体的代码结构,初版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重构,比较值得注意的几个点:

  1. 不同于如今常用的箭头函数,原版的this有通过变量保存调用,所以需要注意指向问题;

  2. 原版SDK是编译后的文件,有很多正常写代码时不会用到的hack手段,比如:

  • for循环初始条件中定义变量;
  • 多个语句通过括号与逗号配合条件判断连接组合执行,例: t instanceof Function && ((n = t), (t = null)), (t = t || {}), (n = n || function () {}) ,所以在翻译这些功能时需要格外注意执行的顺序和变量的赋值;

重构之后的结构

整体结构

采用TypeScript重写,选择面向对象语法,保留原先的发布-订阅模式,构建流程采用rollup打包,最终生成umd/cjs/esm三种模式的代码,同时自动生成d.ts文件。整体结构的类图如下:

picture.image

关键代码解读

Jockey.send方法

Jockey.send方法用来向Native发送数据,这里的主要通讯流程:

  1. Jockey调用Dispatch.send方法;
  2. Dispatch.send调用Dispatch.dispatchMessage方法;
  3. Dispatch.dispatchMessage内部创建一个iframe元素,填入src,并添加到dom中;
  4. iframe经由WebView发送指定jockey://开头的网络请求,并注册回调函数到Dispatch.callbacks成员变量;
  5. 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流程中注册的回调函数,主要流程:

  1. 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);
  }
}           
  1. 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

*文:航飞

0
0
0
0
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论