设计JS Bridge模块,抄这一篇就够了

179 阅读6分钟

概念和应用

通信桥梁:JS Bridge 充当了 Web 应用和客户端之间的通信桥梁。通过 JSBridge,我们可以在 web 和客户端之间进行双向通信,使这两者能够互相调用和传递数据 功能应用:使用JS Bridge,我们可以在 JavaScript 中调用客户端中的功能。如打开相机、录音、获取客户端信息等

框架设计

  • 调用/输出规范化
  • 事件系统:客户端主动和web通讯
  • 模块化:api检测/底层call/客户端相关/其它
  • 跨平台: IOS Android PC
  • 客户端接口设计:原子性、多版本
  • SDK接口设计:规范、友好、解耦
  • 安全性:权限、代码安全
  • 数据监控

通道规范

这里的规范包含web传递给客户端的json设计,也包含客户端回调给web的数据结构,即下文的入参和出参规范

入参规范

  • method, 要调用的方法名
  • params, 传递的参数
  • callback,客户端回调的方法名。这里需要告诉客户端一个window上的函数名,客户端会 evaluateJavascript('window.callbackXXX("json")'),将回调以这样的形式传递给web
const json = {
    method: 'xx', // 和客户端约定的方法名
    params: {}, // 传递给客户端的参数
    callback: 'randomFunctionName_xxx' // 回调函数名
}

出参规范

  • callstatus, 客户端执行状态, ok | fail
  • result: 执行成功,客户端返回的数据。失败时为null
  • err: 执行失败,客户端的错误信息.成功时为null
  • extra: 更详细的错误信息
// 成功返回
const successJson = {
    callstatus: 'ok', // 调用状态:客户端执行接口成功
    result: { 
       xxx: ''  
    }
    err: ''
}

// 程序调用失败
const errJson: {
    callstatus: 'fail',
    result: '',
    err: 'method undefined' // 客户端执行接口错误信息
    extra: '' // 更详细的错误,上报用
}

底层调用

客户端会注入一个函数在window下

  • IOS一般为window.webkit.messageHandlers.xxxx
  • Android一般为 window.xxxx.invoke
  • Window C++的程序就比较多样,可要求客户端同事提供 window.xxxx
// 1.调用
const json = {
    method: 'openUrl',
    params: {
        url:'https://xxxxx.cn'
    },
    callback:'open_url_12345677'
}

// 由客户端提供
const queryKey = 'xxx_web_query';

// IOS
window.webkit.messageHandlers[queryKey].postMessage(json);
// android
window[queryKey].invoke(JSON.stringify(json));
// PC C++ or 其它
window[queryKey](json);

// 2.输出
// 客户端需要将回调事件invoke到callback name上,才能让前端异步获得结果
window.[open_url_12345677] = function(response){
    if(response.callstatus !== 'ok'){
        // 异常处理
        throw new Error(body.err);
    }

    // 成功
    return body.result;
}

代码实现片段

底层invoke call设计片段


const agent = window.navigator.userAgent.toLowerCase();
const isAndroid = /Android/i.test(agent);
const isIos = /iPhone|iPod|iPad/i.test(agent);

class Invoke extends BaseClass {
  private defaultQuery: string;
  
  constructor() {
    super();
    this.defaultQuery = QUERY_KEY;
  }

  isSupport(): boolean {
    return (
      window.webkit?.messageHandlers?.[this.defaultQuery] !== undefined ||
      window[this.defaultQuery] !== undefined;
    );
  }

  /**
   * IOS QUERY
   */
  protected iosQuery(json: IInputJson) {
    window.webkit.messageHandlers[this.defaultQuery].postMessage(json);
  }

  /**
   * ANDROID QUERY
   * @param json
   */
  protected androidQuery(json: IInputJson) {
    window[this.defaultQuery].invoke(JSON.stringify(json));
  }

  /**
   * 最底层 exec json方法
   * @param json
   * @param cb
   */
  private execJson(json: IInputJson, cb: IExecCallBack) {
    try {
      // IOS移动端
      if (isIos) {
        return this.iosQuery(json);
      }

      // android端
      if (isAndroid) {
        return this.androidQuery(json);
      }

      return cb({ err: 'client_not_support', message: JSON.stringify(json) });
    } catch (ex: any) {
      cb({
        err: 'client_exec_error',
        message: `${ex.message}, params: ${JSON.stringify(json)}`,
      });
    }
  }

  /**
   * exec
   * @param method
   * @param params
   * @param cb
   * @returns
   */
  private execWithCallback(method: string, params: any, cb: IExecCallBack) {
    // 嵌套性回调函数,所有回调fn都放在__client_api__下
    const innerCallback = genRandomCallbackName(method);

    const json = {
      method,
      params,
      callback: `${WINDOW_OBJ_NAME}.${innerCallback}`,
      version: this.version,
    };

    if (!window[WINDOW_OBJ_NAME]) {
      window[WINDOW_OBJ_NAME] = {};
    }

    window[WINDOW_OBJ_NAME][innerCallback] = (res: any) => {
      // 支持业务方自己处理response
      delete window[WINDOW_OBJ_NAME][innerCallback];
      callbackHandle(res, cb);
    };

    return this.execJson(json, cb);
  }

  /**
   * promise exec
   * @param method
   * @param params
   * @returns
   */
  public call(method: string, params?: Record<string, any>, rawCallback?: any): Promise<any> {
    return new Promise((resolve, reject) => {
      this.execWithCallback(method, params || {}, (err, result) => {
        err ? reject(err) : resolve(result);
      }, rawCallback);
    });
  }
}

api检测模块

该模块必须有,提供给web业务方使用时,先检测是否支持需要调用的api

api.check检测接口

// 判断当前客户端版本是否支持指定JS接口 check()
const json = {
    method: 'api.check',
    params: {            
        apis: ['system.clipboard.write', 'system.voice.startRecord']
    },
    callback:'api_check_xxxxx'
}
// 返回
{
    callstatus:'ok',
    result:{
        'system.clipboard.write':true,
        'system.voice.startRecord':false
    }
}

api.list获取支持列表

// 返回支持的 api列表
const json = {
    method: 'api.list',
    callback:'api_list_xxxxx'
}
// 返回
{
    callstatus:'ok',
    result: ['api.check', 'api.list', 'system.voice.startRecord','system.clipboard.write']
}

client客户端

客户端相关的模块,必须要有客户端信息的接口,其它的接口根据业务需要自行添加即可

getClientInfo 获取客户端信息

// 1.获取客户端信息getClientInfo()
const json = {
    method: 'client.getClientInfo',
    callback:'client_getClientInfo_xxxxx'
}

// 返回
{
    callstatus:'ok',
    result: {
        version:'10.0.2434.44', // 客户端外发的版本
        appName:'', // 客户端名称
        channel: '', // 客户端渠道
    }
}

event 事件(重要)

在jsbridge的设计里面,除开web主动调用客户端功能外,还必须设计客户端主动调用web的事件系统

  • 事件系统的应用场景:用户状态变更或其它一些客户端信息变更需要通知web的场景
  • 事件需要注册,客户端才会主动回调,避免回调到前端出现function undefined的情况。所以将js bridge的event事件设计成类型js的事件系统。web业务方需要的时候再去监听,不需要时需要自己手动移除监听

注册事件

// 注册监听的事件 event.addEventListener
json = {
    method:'event.addEventListener',
    params:{
        eventName: 'event.system.voice.onRecording',
        // 调用频率,约定成默认值
        callback: '__client_api_.xxx_xxxx'
    }
}

// 监听事件成功
{
    callstatus:'ok',
    result: true 
}

移除注册事件

// 移除监听的事件 event.removeEventListener
json = {
    method:'event.removeEventListener',
    params: {
        eventName: 'event.system.voice.onRecording'
    }
}

// 移除监听事件成功
{
    callstatus:'ok',
    result: true 
}

调用示例

import {event} from 'js-bridge';

componentDidMount() {
    event.addEventListener('client.userchange', () => {
        // TODO
    });
}

componentWillUnmount() {
    event.removeEventListener('client.userchange');
}


代码实现片段

/*
 * 事件注册和管理服务
 *
 */
import execService, { genRandomCallbackName, callbackHandle } from './exec';
import { WINDOW_OBJ_NAME, METHODS } from './../constant';

class EventService {
  private mapping: any;
  constructor() {
    this.mapping = {};
  }

  /**
   * 增加监听事件
   * @param eventName 事件名
   * @param callback 回调函数
   */
  addEventListener(eventName, cb: Function) {
    let innerCallback = genRandomCallbackName(eventName);
    this.mapping[eventName] = innerCallback;

    window[WINDOW_OBJ_NAME][innerCallback] = (res) => {
      return callbackHandle(res, cb);
    };

    return execService.execPromise(METHODS.EVENT.ADD_EVENT_LISTENER,
      {
        eventName,
        callback: `${WINDOW_OBJ_NAME}.${innerCallback}`,
        callbackName: `${WINDOW_OBJ_NAME}.${innerCallback}` // TODO: 暂时保留
      });
  }

  /**
   * 移除事件监听
   * @param eventName 事件名
   */
  removeEventListener(eventName) {
    execService.execPromise(METHODS.EVENT.REMOVE_EVENT_LISTENER, { eventName });

    let innerCallback = this.mapping[eventName];

    if (innerCallback) {
      delete window[WINDOW_OBJ_NAME][innerCallback];
      delete this.mapping[eventName];
    }
  }
}

export default new EventService();

错误码

错误码解释
method undefined方法不存在
permission deny没有权限

版本升级原则

客户端

  • 接口实现保持原子性
  • 保留多版本
  • 同一个接口,小改动,不影响业务方,可以在原接口上修改
  • 同一个接口,大改动,需要新增接口。为了避免代码冗肿,监控数据会告诉客户端代码删除的时间节点

web调用方

  • 做多版本接口的兼容代码检测
  • 不支持的接口调用,业务方做不支持提示

权限

需要服务端接入,内部使用可忽略

  • 白名单
  • 接口权限管理
  • 调用频次
  • 接口鉴权

监控

埋点上报,监控sdk分布和调用情况

  • 调用方
  • 前端版本
  • 客户端环境
  • 接口
  • 成功失败
  • 耗时(效率监控)
字段解释枚举
action事件业务方
client端名称userAgent
js_version前端版本1.0.2
api_method调用接口xxxx
api_params接口查询参数xxx
api_result调用结果true/false
api_cost接口耗时毫秒为单位
extra其它

踩坑

并发回调效率

  • 原因:客户端并发回调, 由于JS引擎单线程导致IO并发效率,正常单次回调10ms内,并发可能会存在几百ms的耗时
  • 方案:js单线程无法避开,可尝试patch call批量调用客户端接口

数据格式

  • 支持string,布尔值,对象,数组
  • 不自持二进制数据格式

通道体积限制

涉及大体积的数据传输,需要设计分页,而且性能也会差

  • Android 20M
  • IOS 6M
  • PC C++ ?

iframe嵌套

  • 同域下:window.parent, 找到top。 需注意内存泄漏问题
  • 不同域:不同套代码,屏蔽;同套代码,window.top 承接回调,用postMessage做分发

有序列化和反序列化效率

代码

github.com/Li-Sparrow/…

拓展阅读