我是如何设计封装与客户端交互的桥接类的

252 阅读5分钟

起因是,公司准备将有部分业务用h5进行开发。虽说之前有一版在用的,但由于开发初期没设计好,后续大伙都不太想维护了, 所以准备开发一套新的在新项目中使用。

作为业务使用者我期望最终是这样子调用的

// 调用native方法
jsBridge.callNative(cmd, params, callback);

// 比如跳转登录页
jsBridge.callNative('GoToLogin', {}, () => {
    location.reload();
});
// 比如播放歌曲
jsBridge.callNative('PlayMusic', {musicID: 123456}, (data, cmd) => {
    updateUI(data);
});
// 比如打开新页面
jsBridge.callNative('GoToNewWebView', {url: 'https://www.xxx.com'});

// 注册指令:
jsBridge.registeCmd(cmd, handler);

// 注册监听歌曲播放器状态改变事件 // 即约定客户端歌曲播放器状态发生改变时通知h5。
jsBridge.registeCmd('MusicPlayerStatusChange', (data, cmd) => {
    updateUI(data);
});

但是呢,贴合业务好用的api一般都不底层。

初步设计入参

设计指令入参

需求整理,期望:

1.各平台保持一致的入参格式。

2.为了方便扩展, 方法的入参是单个对象而非多个。

3.同一平台所有指令使用统一方法调用。

4.可以支持回调。

interface IParams {
    cmd: string;
    params: any;
    callback: Function;
}

但是找到的通讯方式均不好支持传递回调方法。

解决思路:建立map,将key传递给客户端, 并在回调参数中返回。

另外一个解决思路: 即对于每个需要回调的指令,再约定一个回调指令。

两者比较:前者的key可以是动态的,某些时候可以作为通讯id使用(假设这样的场景:不同地方均触发了相同指令,期望可以用不同的回调方法处理)。

所以:

interface IParams {
    cmd: string;
    params: any;
    callbackID?: string|number;
}

设计回调入参

interface IResponse {
    code: number; // 约定0为成功
    msg?: string;
    callbackID?: string|number;
    data: any;
}

如果, 我是说如果, 我们在响应对方时也期望收到对方响应(连续通讯?),该如何?(此时callbackID被占用,虽然大概率不会遇到这种场景);

换个角度, 我们将回调也视为指令(一次新的指令调用), 就可以得到以下接口(在响应中:cmd 为请求中的 callbackID):

interface IResponse {
    cmd: string;
    code: number;
    msg?: string;
    callbackID?: string;
    params: any;
}

我们将 callback 改为 responseCmd

interface IResponse {
    cmd: string;
    code: number;
    msg?: string;
    responseCmd?: string;
    params: any;
}

确定入参

由于我们将回调视为新一次的指令请求: 故统一接口为:

interface IParams {
    cmd: string;
    params: any;
    responseCmd?: string;
    code?: number;
    msg?: string;
}

各平台依据入参格式进行具体实现

h5

一个可行的h5 实现:

按照惯例,不应该将该方法直接绑定到 window, 建议绑定到一个对象上。 示例使用jsBridge

class JSBirdge{
    callH5(params: IParams): void {
        console.log(params)
        // doSomeThing;
    };
}
window.jsBirdge = new JSBirdge();

然后和客户端商议, 如下示例调用 h5 指令是否可行:

jsBirdge.callH5({cmd: 'xxx', params: {params_1: 11}, responseCmd: 'cmd'});

实现注册h5指令:

interface ICallback{
  (data: any, responseCmd?: string) : void;
}
class JSBirdge{
    // 用于缓存h5 指令;
    private cmds: {[p: string]: ICallback} = {};
    public registeCmd(cmd: string, callback: ICallback){
        this.cmds[cmd] = callback
    }
    public callH5(params: IParams): void {
        console.log(params)
        if (params.code) {
            // doSomeThing;
            return;
        }
        this.cmds[params?.cmd]?.(params.params, params.responseCmd);
    };
}

实现调用客户端指令的底层接口:

由于还不确定客户端的实现,即如何调用客户端方法, 暂时用抽象方法代替

abstract class JSBridgeBase{
    // 用于缓存h5 指令;
    private cmds: {[p: string]: ICallback} = {};
    public registeCmd(cmd: string, callback: ICallback){
        this.cmds[cmd] = callback
    }
    public callH5(params: IParams): void {
        console.log(params)
        if (params.code) {
            // doSomeThing;
            return;
        }
        this.cmds[params?.cmd]?.(params.params, params.responseCmd);
    };
    
    abstract callAndroid(params: {cmd: string, params: any, responseCmd?: string}): void;
    abstract callIOS(params: {cmd: string, params: any, responseCmd?: string}): void;
    abstract getPlatForm(): 'ios' | 'android' | string;
}

添加上层调用方法:

这里对registeCmd 做一下调整,用于调用客户端时缓存回调方法:1.如果传入的 cmd 为空, 则随机生成一个;2.将cmd作为返回值;

exec_once_ 开头的指令表示只需要执行一次, 将在执行后从注册表删除。

interface ICallback{
  (data: any, responseCmd?: string) : void;
}
abstract class JSBridgeBase{
  // 用于缓存h5 指令;
  private cmds: {[p: string]: ICallback} = {};
  public registeCmd(cmd: string, callback: ICallback) {
    if (!callback) return '';
    cmd || (cmd = `exec_once_${Date.now()}_${Math.random().toString().slice(2)}`);
    this.cmds[cmd] = callback;
    return cmd;
  }
  public callH5(params: IParams): void {
    const { responseCmd = '' } = params;
    console.log(params)
    if (params.code) {
      // doSomeThing;
      return;
    }
    this.cmds[params?.cmd]?.(params.params, params.responseCmd);
    /^exec_once_[0-9]+_[0-9]+$/.test(responseCmd) && (delete this.cmds[responseCmd]);
  };
    
  abstract callAndroid(params: {cmd: string, params: any, responseCmd?: string}): void;
  abstract callIOS(params: {cmd: string, params: any, responseCmd?: string}): void;
  abstract getPlatForm(): 'ios' | 'android' | string;
  
  public callNative(cmd: string, data: any, callback?: ICallback, options?: {[p: string]: any}) {
    const platform = this.getPlatForm();
    const responseCmd = callback ? this.registeCmd('', callback) : '';
    const params = { cmd: cmd, params: data, responseCmd, ...options };
    if (platform === 'ios') this.callIOS(params);
    if (platform === 'android') this.callAndroid(params);
  };
}

添加一些拦截钩子

在开发阶段, 我们可能需要打印一些日志来调试代码,这里添加了两个钩子函数:beforeCallNative, beforeCallH5。

abstract class JSBridgeBase{
  // 用于缓存h5 指令;
  private cmds: {[p: string]: ICallback} = {};
  protected constructor (public options?: {
    beforeCallNative?: (params: IParams) => void;
    beforeCallH5?: (params: IParams) => void;
  }) {}
  public registeCmd(cmd: string, callback: ICallback) {
    if (!callback) return '';
    cmd || (cmd = `exec_once_${Date.now()}_${Math.random().toString().slice(2)}`);
    this.cmds[cmd] = callback;
    return cmd;
  }
  public callH5(params: IParams): void {
    const { responseCmd = '' } = params;
    this.options?.beforeCallH5?.(params);
    if (params.code) {
      // doSomeThing;
      return;
    }
    this.cmds[params?.cmd]?.(params.params, params.responseCmd);
    /^exec_once_[0-9]+_[0-9]+$/.test(responseCmd) && (delete this.cmds[responseCmd]);
  };
    
  abstract callAndroid(params: {cmd: string, params: any, responseCmd?: string}): void;
  abstract callIOS(params: {cmd: string, params: any, responseCmd?: string}): void;
  abstract getPlatForm(): 'ios' | 'android' | string;
  
  public callNative(cmd: string, data: any, callback?: ICallback, options?: {[p: string]: any}) {
    const platform = this.getPlatForm();
    const responseCmd = callback ? this.registeCmd('', callback) : '';
    const params = { cmd: cmd, params: data, responseCmd, ...options };
    this.options?.beforeCallNative?.(params);
    if (platform === 'ios') this.callIOS(params);
    if (platform === 'android') this.callAndroid(params);
  };
}

在客户端未实现前h5暂时告一段落

当前完整代码

interface IParams {
    cmd: string;
    params: any;
    responseCmd?: string;
    code?: number;
    msg?: string;
}
interface ICallback{
  (data: any, responseCmd?: string) : void;
}
abstract class JSBridgeBase{
  // 用于缓存h5 指令;
  private cmds: {[p: string]: ICallback} = {};
  protected constructor (public options?: {
    beforeCallNative?: (params: IParams) => void;
    beforeCallH5?: (params: IParams) => void;
  }) {}
  public registeCmd(cmd: string, callback: ICallback) {
    if (!callback) return '';
    cmd || (cmd = `exec_once_${Date.now()}_${Math.random().toString().slice(2)}`);
    this.cmds[cmd] = callback;
    return cmd;
  }
  public callH5(params: IParams): void {
    const { responseCmd = '' } = params;
    this.options?.beforeCallH5?.(params);
    if (params.code) {
      // doSomeThing;
      return;
    }
    this.cmds[params?.cmd]?.(params.params, params.responseCmd);
    /^exec_once_[0-9]+_[0-9]+$/.test(responseCmd) && (delete this.cmds[responseCmd]);
  };
    
  abstract callAndroid(params: {cmd: string, params: any, responseCmd?: string}): void;
  abstract callIOS(params: {cmd: string, params: any, responseCmd?: string}): void;
  abstract getPlatForm(): 'ios' | 'android' | string;
  
  public callNative(cmd: string, data: any, callback?: ICallback, options?: {[p: string]: any}) {
    const platform = this.getPlatForm();
    const responseCmd = callback ? this.registeCmd('', callback) : '';
    const params = { cmd: cmd, params: data, responseCmd, ...options };
    this.options?.beforeCallNative?.(params);
    if (platform === 'ios') this.callIOS(params);
    if (platform === 'android') this.callAndroid(params);
  };
}

iOS

假设iOS使用 WKWebView 实现,并向window 绑定了webkit.messageHandlers.XXX 对象, 并告诉你像如下方式调用客户端指令:

window.webkit.messageHandlers.XXX.postMessage(params)

那么:

class JSBridge extends JSBridgeBase{

  // ... other codes
  callIOS (params: { cmd: string; params: any; responseCmd?: string }) {
    window.webkit?.messageHandlers?.XXX?.postMessage?.(params)
  }
}

安卓

假设安卓 使用 addJavaScriptInterface 向webview 注入了XXX 对象,且提供一个callAndroid 方法,

并告诉你像如下方式调用客户端指令:

XXX.callAndroid(JSON.stringify(params))

那么:

class JSBridge extends JSBridgeBase{

  // ... other codes
  callAndroid (params: { cmd: string; params: any; responseCmd?: string }) {
    window.XXX?.callAndroid?.(JSON.stringify(params))
  }
}

最终实现

如果客户端按照上述假设实现:

JSBridgeBase传送门

class JSBridge extends JSBridgeBase{

  getPlatForm (): 'ios' | 'android' | string {
    if (window.XXX?.callAndroid) return 'android';
    // 根据客户端使用的
    if (window.webkit?.messageHandlers?.XXX?.postMessage) return 'ios';
    return '';
  }
  
  callAndroid (params: { cmd: string; params: any; responseCmd?: string }) {
    window.XXX?.callAndroid?.(JSON.stringify(params))
  }
  
  callIOS (params: { cmd: string; params: any; responseCmd?: string }) {
    window.webkit?.messageHandlers?.XXX?.postMessage?.(params)
  }

  static create(opt: {
    beforeCallNative?: (params: IParams) => void;
    beforeCallH5?: (params: IParams) => void;
  }){
    if (window.XXX?.callAndroid || window.webkit?.messageHandlers?.XXX?.postMessage) return new JSBridge(opt)
  }
}

// 初始化
window.jsBridge = JSBridge.create({
  beforeCallNative(params){
    console.log('beforeCallNative', params.cmd, params)
  },
  beforeCallH5(params){
    console.log('beforeCallH5', params.cmd, params)
  },
});

调试工具开发

最简单的测试页面实现是一个页面放上一堆按钮,每次点击执行预定的指令然后查看执行结果即可。

为了方便排查问题, 在关键节点(beforeCallNative,beforeCallH5)打上日志还是有必要的。

按照当前的实现:h5这边可以控制的两个关键节点 beforeCallNative, beforeCallH5 分别代表着是否向客户端发送指令和是否接收到来自客户端的指令。

日志打印

分析需求:

1.查看所有调用记录(期望是成对出现的, 即调用指令与回调指令成对出现)。

2.可以按照指令筛选。

3.可以删除历史记录。


interface ILogItem{
  source: 'h5' | 'native';
  params: IParams;
  responses?: ILogItem[];
}


// 解析日志, 正则匹配的记录为回调函数。应该放在父级的 responses 中
// 查找父级,如果没找到,则返回false,如果找到了, 则压入父级的 parsedList 中;
function parseItem(list: ILogItem[], item: ILogItem): boolean{
  if (!item.params.cmd) return false;
  if (!/^exec_once_[0-9]+_[0-9]+$/.test(item.params.cmd)) return false;
  return list.some(i => {
    if (i.params.responseCmd === item.params.cmd) {
      i.responses || (i.responses = []);
      i.responses.push(item);
      return true;
    }
    return parseItem(i.responses || [], item);
  });
}
class Log{
  list: ILogItem[] = [];
  add(item: ILogItem){
    // 没有父级的记录, 新建一条
    parseItem(this.list, item) || this.list.push(item);
  }
  removeAll(){
    this.list = [];
  }
  removeByCmd(cmd: string){
    this.list = this.list.filter(i => i.params.cmd !== cmd);
  }
  filterByCmd(cmd: string){
    cmd ?  this.list.filter(i => i.params.cmd === cmd) : this.list;
  }
}

初始化

const log = new Log();
window.jsBridge = JSBridge.create({
  beforeCallNative(params){
    log.add({source: 'native', params})
  },
  beforeCallH5(params){
    log.add({source: 'h5', params})
  },
});

测试页面

关于测试页面的具体实现就不展示了, 这里主要贴出相关数据结构。

应该包含指令列表, 指令详情(单例测试), 场景列表, 场景详情页面

interface ITestCase {
  name: string; // 用例的名称 // 如: 测试 xxx 指令的 xxx 边界
  desc: string; // 这里描述 预期效果等
  cmd: string; // 调用的指令
  params: {[p: string]: any}; // 传递的参数;
}

点击事件处理逻辑

function handleTestCaseClick(case: ITestCase) {
  // 需要传个空方法;按照约定, 只有在指定responseCmd时才会回调。
  jsBridge.callNative(case.cmd, case.params, () => {})
}

单例

一组针对某个指令的测试用例。

主要用于测试正常以及各种边界情况的用例

const testCases: Array<{cmd: string; testCases: ITestCaseItem[]}> = [];

场景

针对某个场景进行测试的用例组。

这些用例可能针对不同的指令;

例如: 播放歌曲相关的指令: 播放某一首歌, 上一曲,下一曲, 播放/暂停;

const scenes: Array<{name: string; testCases: ITestCaseItem[]}> = [];

测试用例管理页面

每次新增指令, 均需要重新修改测试页面然后发布测试(虽然可以在测试页面上修改传参, 但是请相信我,在手机上输入代码的效率不怎么高), 不仅过程繁琐,而且还容易出错。 所以可以开发用例管理页面, 将测试用例存到数据库。

建表可参考:

创建三个表,用例表, 指令表, 场景表。

指令和场景可以关联所需的用例;