Android / iOS Webview 容器下 JSBridge SDK 原理浅析 —— 前端视角

·  阅读 1592

前言

在 Hybrid 开发的过程中,由于前端和客户端同学存在认知差异,导致在解决一些 bridge 问题时存在一定的沟通成本和信息不对称。本文从前端视角切入,讲述 bridge 方法如何和客户端进行交互,以及在此过程中进行的各种中间处理。

Native 与 Webview 的通信方式

  • JavaScript 调用 Native 方法

在 Webview 内,JavaScript 调用 Native 方法主要存在 3 种方式:

  1. Native 向 Webview 的 Context ( 即 Webview 中的 window ) 注入一个暴露指定 Native 方法 ( Android )接受 JavaScript 消息 ( iOS ) 的对象。
  2. 拦截 Webview 内的某类特定的 URL Scheme,并根据 URL 来执行对应的 Native 方法。
  3. 拦截 JavaScript 的 console.logalertpromptconfirm,并执行对应的 Native 方法。

在目前主流的 JSSDK 实现中,主要采用了前两种通信方式,并以注入式为主、拦截式为兜底策略进行通信,本文也会主要介绍基于这两种方式的实现原理和应用场景。

注入式( 即第一种方式 )有更好的性能和更优的开发体验,但并非兼容所有系统版本

Android 的 JavascriptInterfaceAndroid 4.2 版本前因没有注解导致暴露了包括系统类 ( java.lang.Runtime ) 方法在内的其他不应暴露的接口,存在较大的安全隐患;而 iOS 的 WKScriptMessageHandler 仅支持 iOS 8.0+ 的版本。

因此、在较低的系统版本中,会采用拦截式( 即第二种方式 )作为与 Native 通信的方法。

  • Native 调用 JavaScript 方法

Native 调用特定 Webview 内的 JavaScript 方法主要存在 2 种方式:

  1. 直接通过 URL 执行 JavaScript 语句,例如 javascript:alert('calling...');
  2. 通过 Android 和 iOS 同名的方法 evaluateJavascript() 来执行 JavaScript 语句。

第二种方式仅兼容 Android 4.4+ 和 iOS 8.0+,而相比第一种,它的优势在于可以获取到 JavaScript 的返回值,是官方提供的推荐通信方式。

调用 NATIVE 方法

结合目前各类主流的 JSSDK 实现,调用 NATIVE 方法的流程大致如下:

调用兼容方法

我们把 JavaScript 调用原生方法监听原生事件的入口称为兼容方法,通常兼容方法会根据宿主环境映射到一个特定的原生方法原生事件监听器。

toast() 举例,该方法是一个通知客户端弹出有指定文字的的兼容方法,它在 JSSDK 中的实现可以是这样的:

666666.png

此处,core.pipeCall() 为 JSSDK 中调用原生方法的核心入口,其中主要涉及到以下几个参数:

  • method: 兼容方法的方法名称
  • params: 兼容方法的入参
  • callback: 获取到 Native 返回后的回调函数,在业务代码中调用兼容方法时定义
  • rules: 兼容方法的规则,包括映射的原生方法名、入参/出参预处理函数、宿主 ID版本兼容信息,可能存在复数个,后续会匹配出适用的一条规则,若没有的话则会报错并采用兜底规则

SDK Bridge 入口

进入 pipeCall() 后,接下来依次执行容器环境校验和 onInvokeStart() 生命周期函数。然后,会通过入参中的 rules 解析出 Native 可读的 realMethodrealParams,以及回调中可能会用到的出参预处理环境信息:

async pipeCall({ method, params, callback, rules }) {
    if (!isBrowser && this.container === 'web') {
      return Promise.resolve()
    }
    let config = {
      method,
      params,
    }
    if (this.onInvokeStart) {
      config = this.onInvokeStart(hookConfig)
    }
    const { realMethod, realParams, rule, env } = await this.transformConfig(method, params, rules)
    ...
}
复制代码

transformConfig() 中,会匹配适用规则、映射原生方法的名字、完成入参预处理

async transformConfig(method, params, rules) {
    const env = this.env || (await this.getEnv)
    const rule = this.getRuleForMethod(env, rules) || {}

    let realMethod = (rule.map && rule.map.method) || method

    const realParams = rule.preprocess ? rule.preprocess(params, { env, bridge: this.bridge }) : params
    return { realMethod, realParams, rule, env }
  }
复制代码

最后调用 SDK 注入的 window.JSBridge.call()

在传入的回调中,依次做了全局出参预处理方法出参预处理( 从之前解析出的 rule 中获取)、执行业务代码中之前传入的回调函数,最后执行环境变量的 onInvokeEnd() 生命周期函数

return new Promise((resolve, reject) => {
        this.bridge.call(
          realMethod,
          realParams,
          (realRes) => {
            let res = realRes
            try {
              if (globalPostprocess && typeof globalPostprocess === 'function') {
                res = globalPostprocess(res, { params, env })
              }
              if (rule.postprocess && typeof rule.postprocess === 'function') {
                res = rule.postprocess(res, { params, env })
              }
            } catch (error) {
              if (this.onInvokeEnd) {
                this.onInvokeEnd({ error: error, config: hookConfig })
              }
              reject(error)
            }
            if (typeof callback === 'function') {
              callback(res)
            }
            resolve(res)
            if (this.onInvokeEnd) {
              this.onInvokeEnd({ response: res, config: hookConfig })
            }
          },
          Object.assign(this.options, options),
        )
      })
复制代码

调用 Bridge 方法

window.JSBridge.call() 方法会根据入参拼出一条 Message 用于与 Native 通信,并会把传入的 callback 参数添加到全局的 callbackMap 属性中,用一个 callbackId 来标识。Message 的结构设计如下:

export interface JavaScriptMessage {
    func: string;    // 此处的 func 是原生方法名
    params: object;
    __msg_type: JavaScriptMessageType;
    __callback_id?: string;
    __iframe_url?: string;
}
复制代码

接着,会把拼好的 Message 通过 window.JSBridge.sendMessageToNative() 发给 Native,这里会出现两种情况:

private sendMessageToNative(message: JavaScriptMessage): void {
    if (String(message.JSSDK) !== "1" && this.nativeMethodInvoker) {
        const nativeMessageJSON = this.nativeMethodInvoker(message);
        /**
         * 如果该方法有返回,说明客户端采用了同步调用方式
         */
        if (nativeMessageJSON) {
            const nativeMessage = JSON.parse(nativeMessageJSON);
            this.handleMessageFromNative(nativeMessage);
        }
    } else {
        // 如果没有检测到注入的全局API,则fallback到iframe发起调用的方式
        this.javascriptMessageQueue.push(message);
        if (!this.dispatchMessageIFrame) {
            this.tryCreateIFrames();
            return;
        }
        this.dispatchMessageIFrame.src = `${this.scheme}${this.dispatchMessagePath}`;
    }
}
复制代码

注入式调用

在 Native 注入 JS2NativeBridge 对象的情况下,SDK 初始化时会在 window.JSBridge 下添加 nativeMethodInvoker 方法,用于直接调用 Native 暴露的 Bridge API,入参为 JSON 格式的 Message

const nativeMessageJSON = this.nativeMethodInvoker(message);
/**
 * 如果该方法有返回,说明客户端采用了同步调用方式
 */
if (nativeMessageJSON) {
    const nativeMessage = JSON.parse(nativeMessageJSON);
    this.handleMessageFromNative(nativeMessage);
}
复制代码

这里还会有两个分支,如果 Native 的实现是同步调用,那可以直接获取到结果,并由前端执行回调函数;如果实现是异步调用,那则会由客户端执行回调函数。

拦截式调用

在 Native 没有注入 JS2NativeBridge 对象的情况下,会降级采用通过 iframe 命中 URL Scheme 的拦截策略。SDK 初始化时,会生成一个消息队列,用于临时存储待执行的 Message ,并在 Native 拦截到 URL 时进行消费:

// 如果没有检测到注入的全局API,则fallback到iframe发起调用的方式
this.javascriptMessageQueue.push(message);
if (!this.dispatchMessageIFrame) {
    this.tryCreateIFrames();
    return;
}
this.dispatchMessageIFrame.src = `${this.scheme}${this.dispatchMessagePath}`;
复制代码

SDK 初始化时的 Native 对象注入

SDK 在初始化时,会根据 Native 的对象注入来创建对应的 nativeMethodInvoker

/**
 * 探测客户端注入的调用API
 */
export function detectNativeMethodInvoker(): NativeMethodInvoker|undefined {
  let nativeMethodInvoker;

  if (global.JS2NativeBridge && global.JS2NativeBridge._invokeMethod) { // 标准实现
      nativeMethodInvoker = (message: JavaScriptMessage) => {
          return global.JS2NativeBridge._invokeMethod(JSON.stringify(message));
      };
  }

  return nativeMethodInvoker;
}
复制代码

监听 NATIVE 事件

结合目前各类主流的 JSSDK 实现,监听 NATIVE 事件的流程大致如下: 77777.png

调用兼容方法

为了实现反向让 Native 调用 JavaScript 能力的,需要监听 Native 的原生事件来进行回调处理。以 onAppShow() 举例,该方法是 Native 通知 JavaScript 容器( Activity 或 ViewController )回到了前台,可以执行相应的回调函数:

import core from "./core"
import rules from "./onAppShow.rule"

interface JSBridgeRequest {}
interface JSBridgeResponse {}

interface Subscription {
  remove: () => void
  listener: (_: JSBridgeResponse) => void
}

function onAppShow(
  callback: (_: JSBridgeResponse) => void,
  once?: boolean
): Subscription {
  return core.pipeEvent({
    event: "onAppShow",
    callback,
    rules,
    once,
  })
}

onAppShow.rules = rules
export default onAppShow
复制代码

此处,core.pipeEvent() 为 JSSDK 中监听原生事件的核心入口,其中主要涉及到以下几个参数:

  • event: 兼容方法的监听方法名称
  • callback: 获取到原生事件后的回调函数,在业务代码中调用兼容方法时定义
  • rules: 兼容方法的规则,包括映射的原生方法名、入参/出参预处理函数、宿主 ID版本兼容信息,可能存在复数个,后续会匹配出适用的一条规则,若没有的话则会报错并采用兜底规则
  • once: 用于决定是否只调用 1 次

SDK Bridge 入口

进入 pipeEvent() 后,执行容器环境校验,然后通过入参中的 rules 完成入参预处理、解析出 Native 可读的 realMethod,以及回调中可能会用到的出参预处理( 同调用 Native 方法 ),最后调用 SDK 注入的 window.JSBridge.on()

在传入的回调中,依次做了全局出参预处理方法出参预处理( 从之前解析出的 rule 中获取 ),然后执行业务代码中之前传入的回调函数

最后,pipeEvent() 会返回一个用于移除监听器的方法,以及传入的回调函数:

pipeEvent({ event, callback, rules, once }) {
    if (!isBrowser && this.container === 'web') {
      return {
        remove: () => {},
        listener: callback,
      }
    }
    const promise = this.transformConfig(event, null, rules)

    const excutor = promise.then(({ realMethod, rule, env }) => {
      function realCallback(realRes) {
        let res = realRes
        if (globalPostprocess && typeof globalPostprocess === 'function') {
          res = globalPostprocess(res, { env })
        }
        if (rule.postprocess && typeof rule.postprocess === 'function') {
          res = rule.postprocess(res, { env })
        }
        if (rule.postprocess) {
          if (realRes !== null) {
            // 约定如果返回除null以外的任何数据才调用callback
            callback(res)
          }
        } else {
          callback(res)
        }
      }
      const callbackId = this.bridge.on(realMethod, realCallback, once)
      return [realMethod, callbackId]
    })
    return {
      remove: () => {
        excutor.then(([realMethod, callbackId]) => {
          this.bridge.off(realMethod, callbackId)
        })
      },
      listener: callback,
    }
  }
复制代码

调用 Bridge 监听方法

window.JSBridge.event() 方法会把传入的 callback 参数添加到全局的 callbackMap 属性中,用一个 callbackId 来标识;接着,再将这一原生事件添加到全局的 eventMap 属性中,并把刚刚生成的 callbackId 绑定到 eventMap 中对应的原生事件上:

public on(
    event: string,
    callback: Callback,
    once: boolean = false
): string {
    if (
        !event ||
        typeof event !== 'string' ||
        typeof callback !== 'function'
    ) {
        return;
    }
    const callbackId = this.registerCallback(event, callback);
    this.eventMap[event] = this.eventMap[event] || {};
    this.eventMap[event][callbackId] = {
        once
    };
}
复制代码

移除 Bridge 监听方法

public off(event: string, callbackId: string): boolean {
    if (!event || typeof event !== 'string') {
        return true;
    }

    const callbackMetaMap = this.eventMap[event];
    if (
        !callbackMetaMap ||
        typeof callbackMetaMap !== 'object' ||
        !callbackMetaMap.hasOwnProperty(callbackId)
    ) {
        return true;
    }
    this.deregisterCallback(callbackId);
    delete callbackMetaMap[callbackId];
    return true;
}
复制代码

如果你有兴趣...

字节旗下大力智能诚邀你投递简历,业务发展迅猛,HC 多多~

我们从事大力智能作业灯/大力辅导 APP 以及相关海内外教育产品的前端研发工作,业务场景包含 H5,Flutter,小程序以及各种 Hybrid 场景;另外我们团队在 monorepo,微前端,serverless 等各种前沿前端技术也有一定实践与沉淀,常用的技术栈包括但是不限于 React、TS、Nodejs。

扫描下方二维码获取内推码:

欢迎关注「 字节前端 ByteFE 」

简历投递联系邮箱「tech@bytedance.com

分类:
前端
分类:
前端
收藏成功!
已添加到「」, 点击更改