起因是,公司准备将有部分业务用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))
}
}
最终实现
如果客户端按照上述假设实现:
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[]}> = [];
测试用例管理页面
每次新增指令, 均需要重新修改测试页面然后发布测试(虽然可以在测试页面上修改传参, 但是请相信我,在手机上输入代码的效率不怎么高), 不仅过程繁琐,而且还容易出错。 所以可以开发用例管理页面, 将测试用例存到数据库。
建表可参考:
创建三个表,用例表, 指令表, 场景表。
指令和场景可以关联所需的用例;