使用 JavaScript 桥接访问原生 API

本页将探讨用于建立原生桥(也称为 JavaScript 桥)的各种方法和最佳实践,以促进 WebView 中的 Web 内容与宿主 Android 应用之间的通信。

这使 Web 开发者能够使用 JavaScript 访问标准 Web API 通常不提供的原生平台功能,例如摄像头、文件系统或高级硬件传感器。

用例

JavaScript 桥接实现支持各种集成方案,在这些方案中,Web 内容需要更深入地访问 Android 操作系统。下面列出了一些示例:

  • 平台集成:从网页触发原生 Android 界面组件(例如,生物识别提示、BottomSheetDialog)。
  • 性能:将繁重的计算任务分流到原生 Java 或 Kotlin 代码。
  • 数据持久性:访问本地加密数据库或共享偏好设置。
  • 大数据传输:在应用和 Web 渲染器之间传递媒体文件或复杂的数据结构。

通信机制

Android 提供三代主要 API 来建立原生桥接。 虽然这些方法仍然可用,但在安全性、易用性和性能方面存在显著差异。

使用 addWebMessageListener(推荐)

addWebMessageListener 是网页内容与原生应用代码之间最现代且推荐的通信方法。它将 JavaScript 界面的易用性与消息传递系统的安全性相结合。

工作原理:应用添加一个具有特定名称和一组允许的来源规则的监听器。然后,WebView 会确保 JavaScript 对象从网页开始加载的那一刻起就存在于全局范围 (window.objectName) 中。

初始化:为确保 WebView 在任何脚本运行之前注入 JavaScript 对象,您必须先调用 addWebMessageListener,然后再调用 loadUrl()

主要功能

  • 安全性和信任:与旧版 API 不同,此方法在初始化期间需要 Set<String>allowedOriginRules。这是建立信任的主要机制。

    指定可信来源(例如 https://example.com)后,WebView 会保证仅向从该确切来源加载的网页公开注入的 JavaScript 对象。

    原生监听器回调会在每次收到消息时接收 sourceOrigin 参数。如果您的网桥支持多个允许的来源,您可以使用此方法来验证发件人的确切来源。

    由于 WebView 在平台级严格执行这些来源检查,因此您的应用通常可以信任从可信 sourceOrigin 收到的消息,从而无需在大多数标准实现中进行严格的载荷验证。

    • WebView 会根据架构 (HTTP/HTTPS)、主机和端口来匹配规则。
    • WebView 会忽略路径。例如,https://example.com 允许 https://example.com/loginhttps://example.com/home
    • WebView 严格限制通配符只能用于子网域的主机开头。例如,https://*.example.comhttps://foo.example.com 匹配,但与 https://example.com 不匹配。如果您需要同时匹配 https://example.com 及其子网域,则必须将每个来源规则分别添加到许可名单中(例如 "https://example.com", "https://*.example.com")。您不能在方案中使用通配符,也不能在网域中间使用通配符。

    这会将网桥限制为仅限经过验证的网域,从而防止未经授权的第三方内容或注入的 iframe 执行原生代码。

  • 多框架支持:适用于符合来源规则的所有框架。

  • 线程:监听器回调在应用的主线程(界面线程)上运行。如果您的网桥需要处理复杂的数据处理、JSON 解析或数据库查找,您必须将该工作分流到后台线程,以防止应用界面因“应用无响应”(ANR) 错误而冻结。

  • 双向:当网页发送消息时,应用会收到一个 JavaScriptReplyProxy,该对象可用于将消息发送回该特定框架。您可以保留此 replyProxy 对象,并随时使用它向相应 Facebook 页面发送任意数量的消息,而不仅仅是回复该页面发送的每条消息。如果原始框架离开或被销毁,则使用代理上的 postMessage() 发送的消息会被静默忽略。

  • 应用端启动:虽然网页必须始终启动与应用的通信渠道,但原生应用可以单方面提示网页开始此流程。原生应用可以使用 addDocumentStartJavaScript()(在网页加载之前评估 JavaScript)或 evaluateJavaScript()(在网页加载之后评估 JavaScript)与网页通信。

限制:此 API 以字符串或 byte[] 数组的形式发送数据。对于更复杂的数据结构(例如 JSON 对象),您必须将其序列化为上述格式之一,然后在另一端进行反序列化以重建数据结构。

用法示例

如需了解双向消息交换的完整序列,事件会按以下顺序进行:

  1. 启动(应用):原生应用使用 addWebMessageListener 注册监听器,并使用 loadUrl() 加载网页。
  2. 消息发送(网页):网页的 JavaScript 调用 myObject.postMessage(message) 来启动通信。
  3. 消息接收和回复(应用):应用在监听器回调中接收消息,并使用提供的 replyProxy.postMessage() 进行回复。
  4. 接收回复(网页):网页在 myObject.onmessage() 回调函数中接收异步回复。

Kotlin

val myListener = WebViewCompat.WebMessageListener { _, _, _, _, replyProxy ->
    // Handle the message from JS
    replyProxy.postMessage("Acknowledged!")
}

// Check whether the WebView version supports the feature.
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
    val allowedOrigins = setOf("https://www.example.com")
    WebViewCompat.addWebMessageListener(webView, "myObject", allowedOrigins, myListener)
}

Java

WebMessageListener myListener = (view, message, sourceOrigin, isMainFrame, replyProxy) -> {
    // Handle the message from JS
    replyProxy.postMessage("Acknowledged!");
};

// Check whether the WebView version supports the feature.
if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
    Set<String> allowedOrigins = Set.of("https://www.example.com");
    WebViewCompat.addWebMessageListener(webView, "myObject", allowedOrigins, myListener);
}

以下 JavaScript 代码展示了 addWebMessageListener 的客户端实现,可让网页内容通过 myObject 代理接收来自原生应用的消息并发送自己的消息。

myObject.onmessage = function(event) {
    console.log("App says: " + event.data);
};
myObject.postMessage("Hello world!");

使用 postWebMessage(替代方案)

Android 引入了此功能,以提供类似于 Web 的 window.postMessage 的基于消息传递的异步替代方案。

运作方式:该应用使用 WebViewCompat.postWebMessage 将载荷发送到网页的主框架。如需建立双向通信渠道,您可以创建 WebMessageChannel,并通过消息将其中一个端口传递给 Web 内容。

特征

  • 异步:与 addWebMessageListener 类似,此方法使用异步消息传递,可确保网页在应用于后台处理数据时仍能响应用户互动。
  • 了解来源:您可以指定 targetOrigin,以确保 WebView 仅向受信任的网站传送数据。

限制

  • 范围:此 API 将通信限制为主框架。不支持直接寻址或向 iframe 发送消息。
  • URI 限制:您无法将此方法用于使用 data: URI、file: URI 或 loadData() 加载的内容,除非您将目标来源指定为“*”。这样,任何网页都可以接收消息。
  • 身份风险:网页内容无法明确验证发件人的身份。网页收到的消息可能源自您的原生应用或其他 iframe。

如果您的 Android 版本较旧,不支持 addWebMessageListener,但您需要一个简单的异步通道来传输基于字符串的数据,请使用此方法。

使用 addJavascriptInterface(旧版)

最旧的方法是直接将原生对象实例注入到 WebView 中。

工作原理:您需要定义一个 Kotlin 或 Java 类,使用 @JavascriptInterface 注释允许的方法,然后使用 addJavascriptInterface(Object, String) 将该类的实例添加到 WebView。

特征

  • 同步:JavaScript 执行环境会一直处于阻塞状态,直到 Android 代码中的方法返回。
  • 线程安全:系统会在后台线程上调用方法,因此需要在 Kotlin 或 Java 端进行仔细的同步。
  • 安全风险:默认情况下,WebView 中的每个框架(包括 iframe)都可以使用 addJavascriptInterface。它缺少基于来源的访问权限控制。由于 WebView 的异步行为,无法安全地确定调用您的接口的框架的网址。您不得依赖 WebView.getUrl() 等方法进行安全验证,因为这些方法无法保证准确性,并且不会指明是哪个特定框架发出了请求。

机制摘要

下表简要比较了三种主要的原生桥接实现机制:

方法 addWebMessageListener postWebMessage addJavascriptInterface
实现 异步(主线程上的监听器) 异步 同步
安全 最高(基于许可名单) 高(了解来源) 低(不检查来源)
复杂性 简单
方向 双向 双向 从网站到应用
最低 WebView 版本 版本 82(和 Jetpack Webkit 1.3.0) 版本 45(和 Jetpack Webkit 1.1.0) 所有版本
推荐

处理大型数据传输

在传输大型载荷(例如数兆字节的字符串或二进制文件)时,您必须谨慎管理内存,以避免在 32 位设备上出现应用无响应 (ANR) 错误或崩溃。本部分将讨论在宿主应用和 Web 内容之间传输大量数据时涉及的各种技术和限制。

使用字节数组传输二进制数据

借助 WebMessageCompat 类,您可以直接发送 byte[] 数组,而无需将二进制数据序列化为 Base64 字符串。由于 Base64 会使数据大小增加大约 33% 的开销,因此这种方法在内存使用方面效率更高,速度也更快。

  • 二进制优势:在原生应用和 Web 内容之间传输图片文件或音频等二进制数据。
  • 限制:即使使用字节数组,系统也会在应用与 WebView 用于呈现网页内容的隔离进程之间的进程间通信 (IPC) 边界复制数据。不过,对于非常大的文件,这种方法仍会消耗大量内存。

以下代码示例演示了如何在原生应用端设置 addWebMessageListener 以接收标记为 WebMessageCompat.TYPE_ARRAY_BUFFER 的消息,并通过检查 WebViewFeature.MESSAGE_ARRAY_BUFFER 可选地回复二进制数据。

Kotlin

fun setupWebView(webView: WebView) {
  if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
      val listener = WebViewCompat.WebMessageListener { view, message, sourceOrigin, isMainFrame, replyProxy ->

          // Check if the received message is an ArrayBuffer
          if (message.type == WebMessageCompat.TYPE_ARRAY_BUFFER) {
              val binaryData: ByteArray = message.arrayBuffer
              // Process your binary data (image, audio, etc.)
              println("Received bytes: ${binaryData.size}")

              // Optional: Send a binary reply back to JavaScript.
              // This example sends a 3-byte array for simplicity.
              if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER)) {
                  val replyBytes = byteArrayOf(0x01, 0x02, 0x03)
                  replyProxy.postMessage(replyBytes)
              }
          }
      }

      // "myBridge" matches the window.myBridge in JavaScript
      WebViewCompat.addWebMessageListener(
          webView,
          "myBridge",
          setOf("https://example.com"), // Security: restrict origins
          listener
      )
  }
}

Java

public void setupWebView(WebView webView) {
  if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
      WebViewCompat.WebMessageListener listener = (view, message, sourceOrigin, isMainFrame, replyProxy) -> {

          // Check if the received message is an ArrayBuffer
          if (message.getType() == WebMessageCompat.TYPE_ARRAY_BUFFER) {
              byte[] binaryData = message.getArrayBuffer();
              // Process your binary data (image, audio, etc.)
              System.out.println("Received bytes: " + binaryData.length);

              // Optional: Send a binary reply back to JavaScript.
              // This example sends a 3-byte array for simplicity.
              if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_ARRAY_BUFFER)) {
                  byte[] replyBytes = new byte[]{0x01, 0x02, 0x03};
                  replyProxy.postMessage(replyBytes);
              }
          }
      };

      // "myBridge" matches the window.myBridge in JavaScript
      WebViewCompat.addWebMessageListener(
          webView,
          "myBridge",
          Set.of("https://example.com"), // Security: restrict origins
          listener
      );
  }
}

以下 JavaScript 代码演示了 addWebMessageListener 的客户端实现,使 Web 内容能够使用上一个示例中注入的 window.myBridge 代理与原生应用之间发送和接收二进制数据 (ArrayBuffer)。

// Function to send an image or binary buffer to the app
async function sendBinaryToApp() {
    const response = await fetch('image.jpg');
    const buffer = await response.arrayBuffer();

    // Check if the injected bridge object exists
    if (window.myBridge) {
        // You can send the ArrayBuffer directly
        window.myBridge.postMessage(buffer);
    }
}

// Receiving binary data from the app
if (window.myBridge) {
    window.myBridge.onmessage = function(event) {
        if (event.data instanceof ArrayBuffer) {
            console.log('Received binary data from App, length:', event.data.byteLength);
            // Process the binary data (for example, as a Uint8Array)
            const bytes = new Uint8Array(event.data);
            console.log('First byte:', bytes[0]);
        }
    };
}

高效的大规模数据加载

对于非常大的文件(超过 10 MB),请使用 shouldInterceptRequest 方法来流式传输数据:

  1. 网页向自定义占位网址发起 fetch() 调用。例如,https://app.local/large-file
  2. Android 应用会在 WebViewClient.shouldInterceptRequest 中拦截此请求。
  3. 应用以 InputStream 形式返回数据。

这样一来,就可以分块流式传输数据,而不是一次性将整个载荷加载到内存中。

以下 JavaScript 函数演示了客户端代码,该代码使用对自定义占位网址的标准 fetch() 调用,从原生应用高效加载大型二进制文件。

async function fetchBinaryFromApp() {
    try {
        // This URL doesn't need to exist on the internet
        const response = await fetch('https://app.local/data/large-file.bin');

        if (!response.ok) throw new Error('Network response was not okay');

        // For raw binary data:
        const arrayBuffer = await response.arrayBuffer();
        console.log('Received binary data, size:', arrayBuffer.byteLength);
        // Process buffer (for example, new Uint8Array(arrayBuffer))

        /*
        // OR for an image:
        const blob = await response.blob();
        const imageUrl = URL.createObjectURL(blob);
        document.getElementById('myImage').src = imageUrl;
        */

    } catch (error) {
        console.error('Fetch error:', error);
    }
}

以下代码示例演示了原生应用端如何使用 Kotlin 和 Java 中的 WebViewClient.shouldInterceptRequest 方法,通过拦截 Web 内容请求的自定义占位网址来流式传输大型二进制文件。

Kotlin

webView.webViewClient = object : WebViewClient() {
  override fun shouldInterceptRequest(
      view: WebView?,
      request: WebResourceRequest?
  ): WebResourceResponse? {
      val url = request?.url ?: return null

      // Check if this is our custom placeholder URL
      if (url.host == "app.local" && url.path == "/data/large-file.bin") {
          try {
              // 1. Get your data as an InputStream
              // (from Assets, Files, or a generated byte stream)
              val inputStream: InputStream = context.assets.open("my_data.pb")

              // 2. Define Response Headers (Crucial for CORS/Fetch)
              val headers = mutableMapOf<String, String>()
              headers["Access-Control-Allow-Origin"] = "*" // Allow fetch from any origin

              // 3. Return the response
              return WebResourceResponse(
                  "application/octet-stream", // MIME type (for example, image/jpeg)
                  "UTF-8",                   // Encoding
                  200,                       // Status Code
                  "OK",                      // Reason Phrase
                  headers,                   // Custom Headers
                  inputStream                // The actual data stream
              )
          } catch (e: Exception) {
              // Handle exception
          }
      }
      return super.shouldInterceptRequest(view, request)
  }
}

Java

webView.setWebViewClient(new WebViewClient() {
  @Override
  public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
      String urlPath = request.getUrl().getPath();
      String host = request.getUrl().getHost();

      // Check if this is our custom placeholder URL
      if ("app.local".equals(host) && "/data/large-file.bin".equals(urlPath)) {
          try {
              // 1. Get your data as an InputStream
              // (from Assets, Files, or a generated byte stream)
              InputStream inputStream = getContext().getAssets().open("my_data.pb");

              // 2. Define Response Headers (Crucial for CORS/Fetch)
              Map<String, String> headers = new HashMap<>();
              headers.put("Access-Control-Allow-Origin", "*"); // Allow fetch from any origin

              // 3. Return the response
              return new WebResourceResponse(
                  "application/octet-stream", // MIME type (for example, image/jpeg)
                  "UTF-8",                   // Encoding
                  200,                       // Status Code
                  "OK",                      // Reason Phrase
                  headers,                   // Custom Headers
                  inputStream                // The actual data stream
              );
          } catch (Exception e) {
              // Handle exception
          }
      }
      return super.shouldInterceptRequest(view, request);
  }
});

遵循安全建议

为保护您的应用和用户数据,在实现桥接时请遵循以下准则:

  • 强制执行 HTTPS:为确保恶意第三方内容无法调用应用的原生逻辑,请仅允许与安全来源进行通信。

  • 依赖来源规则:处理信任问题的最佳方法是严格定义 allowedOriginRules 并检查消息回调中提供的 sourceOrigin。除非绝对必要,否则请避免使用与所有来源匹配的完整通配符 (*) 作为唯一的来源规则。使用通配符指代子网域(例如 *.example.com)对于匹配多个子网域(例如 foo.example.combar.example.com)仍然有效且安全。

    注意:虽然来源规则可防范恶意第三方网站和隐藏的 iframe,但无法防范您自己的可信网域中的跨站脚本攻击 (XSS) 漏洞。例如,如果您的网页显示用户生成的内容,并且容易受到存储型 XSS 攻击,攻击者就可以执行充当您可信来源的脚本。考虑在执行敏感的原生平台操作之前,对消息载荷应用验证。

  • 尽量缩小公开范围:仅公开网页所需的特定方法或数据。

  • 在运行时检查功能:最新的桥接 API(包括 addWebMessageListener)是 Jetpack Webkit 库的一部分。因此,在致电之前,请务必先使用 WebViewFeature.isFeatureSupported() 检查是否支持。