使用 JavaScript 桥接访问原生 API

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

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

使用场景

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

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

通信机制

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

使用 addWebMessageListener(推荐)

addWebMessageListener 是网页内容与原生应用代码之间通信的最新方法,也是推荐的方法。它结合了 JavaScript 接口的易用性和消息传递系统的安全性。

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

初始化:为确保 WebView 在任何脚本运行之前注入 JavaScript 对象,您必须先调用 addWebMessageListener,然后再导航到该网页(例如调用 WebViewCompat.navigate 或 loadUrl)。

主要功能:

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

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

    原生监听器回调会为每条消息接收一个 sourceOrigin 参数。如果您的桥支持多个允许的来源,您可以使用此参数来验证发送者的确切来源。

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

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

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

  • 多框架支持:适用于与来源 规则匹配的所有框架。

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

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

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

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

用法示例:

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

  1. 启动(应用):原生应用使用 addWebMessageListener注册监听器,并启动网页导航(例如使用 WebViewCompat.navigate或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,并将其中一个 端口与消息一起传递给网页内容。

特点:

  • 异步:与 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 端进行仔细同步。
  • 安全风险:默认情况下,addJavascriptInterface 可供 WebView 中的每个框架(包括 iframe)使用。它缺少基于来源的访问权限控制。由于 WebView 的异步行为,无法安全地确定调用接口的框架的网址。您不得依赖 WebView.getUrl() 等方法进行安全 验证,因为它们无法保证准确性,并且不会指明 哪个特定框架发出了请求。

数据类型转换和强制转换

使用 addJavascriptInterface 时,基于 Chromium 的 Java 桥会在 JavaScript 运行时和 Android 应用代码之间转换数据类型。

以下强制转换规则适用于方法参数和返回值。

参数类型映射(JavaScript 到 Java)

当 JavaScript 将实参传递给带注解的 Java 或 Kotlin 方法时,桥会将 JavaScript 值强制转换为相应的 Java 参数类型:

Java 参数类型 JavaScript 实参值 强制转换行为
byte、short、int、long 数字(整数) 值会强制转换为目标整数类型。超出范围的值会根据标准数字强制转换规则进行回绕。
byte、short、int、long NaN 强制转换为 0。
byte、short、int、long Infinity 对于 byte 和 short,强制转换为 -1;对于 int 和 long,强制转换为 Integer.MAX_VALUE 和 Long.MAX_VALUE。
float、double 数字 强制转换为相应的 Java 浮点值。
float、double NaN / Infinity 强制转换为 Float.NaN、Double.NaN、Float.POSITIVE_INFINITY 或 Double.POSITIVE_INFINITY。
char 数字(整数) 转换为相应的 Unicode 代码点。
char 非整数、NaN、Infinity 强制转换为 \u0000。
boolean true / false 强制转换为 Java true 或 false。
boolean 数字、字符串、对象 强制转换为 false(包括非空字符串和非零数字)。
String 字符串 字符串值会保留。
String 数字、布尔值 格式化为字符串表示形式(例如 "42"、"true"、"false")。
String null / undefined null 强制转换为 Java null;undefined 强制转换为字面量字符串 "undefined"。
String 对象、ArrayBuffer、TypedArray 强制转换为字面量字符串 "undefined"。
原始数组(例如 int[]、byte[]、boolean[])或 String[] 数组 ([...]) 转换为目标元素类型的 1D Java 数组。稀疏数组会使用默认值(0、false、null)填充未分配的索引。
原始数组(例如 int[]、byte[]) TypedArray (Int8Array、Uint8Array、Int32Array、Float64Array) 元素会强制转换为相应的 Java 原始数组。
多维数组(例如 int[][]) 嵌套数组 ([[...]]) 不支持。多维数组参数的求值结果为 null。
ArrayBuffer、DataView ArrayBuffer、DataView 不支持作为数组。ArrayBuffer 和 DataView 实例的求值结果为 null。
Object 或自定义类 JavaScript 对象 ({...}) 不支持。任意 JavaScript 对象字面量在 Java 中的求值结果为 null。
Object 或自定义类 注入的 Java 对象封装容器 支持(往返)。将底层 Java 实例传递给 Java 方法。如果 Java 类型与参数签名不匹配,则抛出 JavaScript 异常。
封装类型(例如 Integer、Double、Boolean) 数字、布尔值 不支持。封装的原始类型被视为不透明对象,求值结果为 null。
任何原始类型 null / undefined 强制转换为默认值(0、0.0、\u0000、false)。
Object、String、数组 null 强制转换为 Java null。
返回值类型映射(Java 到 JavaScript)

当带注解的 Java 或 Kotlin 方法返回值时,桥会将其转换为 JavaScript 类型:

Java 返回值类型 JavaScript 值 JavaScript typeof
boolean true / false "boolean"
byte、short、int、long、float、double 数字 "number"
char 数字(Unicode 代码点) "number"
String(非 null) 字符串值 "string"
String (null) undefined "undefined"
void undefined "undefined"
Java 数组(例如 int[]、String[]) undefined "undefined"。不支持数组返回值。Java 方法不会执行,并且会返回 undefined,而不会引发异常。
Java 对象 / 自定义类型(非 null) 对象封装容器 "object"。围绕 Java 实例创建 JavaScript 封装容器。JavaScript 代码可以调用此对象上使用 @JavascriptInterface 注解的任何公共方法。
Java 对象 / 自定义类型 (null) null "object"
封装的原始类型(例如 Integer、Double) 对象封装容器 "object"。作为不透明的 Java 对象封装容器返回,没有可访问的 @JavascriptInterface 方法,因此该值在 JavaScript 中不可用。

方法和成员可访问性

JavaScript 桥会强制执行严格的成员访问权限和可见性规则,以防止意外执行代码:

  • 字段不会公开:Java 字段(包括 public 和 public final 字段)无法从 JavaScript 访问,求值结果为 undefined。
  • 注解要求:只有使用 @JavascriptInterface显式注解的方法才会向 JavaScript 公开。
  • 可见性限制:方法必须是 public。private 和 protected 方法永远不会向 JavaScript 公开,即使它们带有 @JavascriptInterface 注解也是如此。
  • 静态方法:带有 @JavascriptInterface 注解的静态方法可从 JavaScript 调用。
  • 继承和替换:@JavascriptInterface 注解不会被 继承,当子类替换方法时。如果子类替换了父类中的带注解的方法,则子类必须在被替换的方法上显式添加 @JavascriptInterface 注解,以将其公开给 JavaScript。如果父类中带注解,则从父类继承的未被替换的公共方法仍然可访问。
  • 反射保护:标准 Java 反射方法(例如 getClass())会被阻止并抛出 JavaScript 异常,以防止远程 代码执行漏洞。
  • 方法重载:支持重载的 Java 方法。桥仅根据传递的实参数量解析方法调用,而不考虑实参类型。使用无效的实参数量调用重载方法会引发 JavaScript 异常。如果两个重载具有相同的实参数量,系统会任意选择一个。

机制摘要

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

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

处理大型数据传输

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

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

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

  • 二进制优势:在 原生应用和网页内容之间传输二进制数据(例如图片文件或音频)。
  • 限制:即使使用字节数组,系统也会在应用和 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 的客户端实现,使网页内容能够使用上一个示例中注入的 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 方法,通过拦截网页内容请求的自定义占位符网址来流式传输大型二进制文件。

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.com、bar.example.com)仍然有效且安全。

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

  • 最大限度地减少攻击面:仅公开 网页所需的特定方法或数据。

  • 在运行时检查功能:最新的桥 API(包括 addWebMessageListener)是 Jetpack Webkit 库的一部分。因此,请务必先 使用 WebViewFeature.isFeatureSupported() 检查支持情况,然后再 调用它们。