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

前言

在 Hybrid 開發的過程當中,因爲前端和客戶端同窗存在認知差別,致使在解決一些 bridge 問題時存在必定的溝通成本和信息不對稱。本文從前端視角切入,講述 bridge 方法如何和客戶端進行交互,以及在此過程當中進行的各類中間處理。javascript

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 實現中,主要採用了前兩種通訊方式,並以注入式爲主、攔截式爲兜底策略進行通訊,本文也會主要介紹基於這兩種方式的實現原理和應用場景。java

注入式( 即第一種方式 )有更好的性能和更優的開發體驗,但並不是兼容全部系統版本web

Android 的 JavascriptInterfaceAndroid 4.2 版本前因沒有註解致使暴露了包括系統類 ( java.lang.Runtime ) 方法在內的其餘不該暴露的接口,存在較大的安全隱患;而 iOS 的 WKScriptMessageHandler 僅支持 iOS 8.0+ 的版本。小程序

所以、在較低的系統版本中,會採用攔截式( 即第二種方式 )做爲與 Native 通訊的方法。promise

  • Native 調用 JavaScript 方法

Native 調用特定 Webview 內的 JavaScript 方法主要存在 2 種方式:安全

  1. 直接經過 URL 執行 JavaScript 語句,例如 javascript:alert('calling...');
  2. 經過 Android 和 iOS 同名的方法 evaluateJavascript() 來執行 JavaScript 語句。

第二種方式僅兼容 Android 4.4+ 和 iOS 8.0+,而相比第一種,它的優點在於能夠獲取到 JavaScript 的返回值,是官方提供的推薦通訊方式。markdown

調用 NATIVE 方法

結合目前各種主流的 JSSDK 實現,調用 NATIVE 方法的流程大體以下: less

調用兼容方法

咱們把 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

相關文章
相關標籤/搜索