Dostęp do natywnych interfejsów API za pomocą mostu JavaScript

Na tej stronie omawiamy różne metody i sprawdzone sposoby tworzenia mostu natywnego, zwanego też mostem JavaScript, który ułatwia komunikację między treściami z internetu w komponencie WebView a aplikacją na Androida.

Dzięki temu programiści stron internetowych mogą używać JavaScriptu do uzyskiwania dostępu do funkcji platformy natywnej, takich jak aparat, system plików czy zaawansowane czujniki sprzętowe, które nie są zwykle dostępne w standardowych interfejsach API.

Przypadki użycia

Implementacja mostu JavaScript umożliwia różne scenariusze integracji, w których treści internetowe wymagają głębszego dostępu do systemu operacyjnego Android. Oto kilka przykładów:

  • Integracja z platformą: wywoływanie natywnych komponentów interfejsu Androida (np. promptów biometrycznych, BottomSheetDialog) ze strony internetowej.
  • Wydajność: przenoszenie złożonych zadań obliczeniowych do natywnego kodu Java lub Kotlin.
  • Trwałość danych: dostęp do lokalnych zaszyfrowanych baz danych lub ustawień współdzielonych.
  • Przesyłanie dużych ilości danych: przekazywanie plików multimedialnych lub złożonych struktur danych między aplikacją a rendererem internetowym.

Mechanizmy komunikacji

Android oferuje 3 główne generacje interfejsów API do tworzenia mostu natywnego. Wszystkie są nadal dostępne, ale znacznie się od siebie różnią pod względem bezpieczeństwa, użyteczności i wydajności.

Używanie addWebMessageListener (zalecane)

addWebMessageListener to najnowocześniejsze i zalecane podejście do komunikacji między treściami internetowymi a kodem aplikacji natywnej. Łączy łatwość użycia interfejsu JavaScript z bezpieczeństwem systemu przesyłania wiadomości.

Jak to działa: aplikacja dodaje odbiornik o określonej nazwie i zestawie dozwolonych reguł źródła. Następnie komponent WebView zapewnia, że obiekt JavaScript jest obecny w zakresie globalnym (window.objectName) od momentu rozpoczęcia wczytywania strony.

Inicjowanie: aby mieć pewność, że komponent WebView wstrzyknie obiekt JavaScript przed uruchomieniem skryptu, musisz wywołać addWebMessageListener przed przejściem na stronę (np. wywołując WebViewCompat.navigate lub loadUrl).

Najważniejsze funkcje:

  • Bezpieczeństwo i zaufanie: w przeciwieństwie do starszych interfejsów API ta metoda wymaga podczas inicjowania Set<String> allowedOriginRules. Jest to podstawowy mechanizm budowania zaufania.

    Gdy określisz zaufane źródło, np. https://example.com, komponent WebView gwarantuje, że wstrzyknięte obiekty JavaScript będą udostępniane tylko stronom internetowym wczytanym z tego źródła.

    Wywołanie zwrotne odbiornika natywnego otrzymuje z każdą wiadomością parametr sourceOrigin. Możesz go użyć do sprawdzenia dokładnego źródła nadawcy, jeśli Twój most obsługuje wiele dozwolonych źródeł.

    Ponieważ komponent WebView ściśle egzekwuje te kontrole źródła na poziomie platformy, Twoja aplikacja może na ogół polegać na wiadomościach otrzymywanych z zaufanego źródła sourceOrigin jako prawdziwych, co eliminuje potrzebę rygorystycznej weryfikacji ładunku w większości standardowych implementacji.

    • Komponent WebView dopasowuje reguły do schematu (HTTP/HTTPS), hosta i portu.
    • Komponent WebView ignoruje ścieżki. Na przykład https://example.com zezwala na https://example.com/login i https://example.com/home.
    • Komponent WebView ściśle ogranicza symbole wieloznaczne do początku hosta w przypadku subdomen. Na przykład https://*.example.com pasuje do https://foo.example.com, ale nie do https://example.com. Jeśli chcesz dopasować zarówno https://example.com, jak i jego subdomeny, musisz dodać każdą regułę źródła osobno do listy dozwolonych (np. "https://example.com", "https://*.example.com"). Nie możesz używać symboli wieloznacznych w schemacie ani w środku domeny.

    Ogranicza to most do zweryfikowanych domen, co uniemożliwia wykonywanie kodu natywnego przez nieautoryzowane treści osób trzecich lub wstrzyknięte elementy iframe.

  • Obsługa wielu ramek: działa we wszystkich ramkach, które pasują do reguł źródła.

  • Wątki: wywołanie zwrotne odbiornika jest wykonywane w głównym wątku aplikacji (UI) . Jeśli Twój most musi obsługiwać złożone przetwarzanie danych, analizowanie JSON lub wyszukiwanie w bazie danych, musisz przenieść to zadanie do wątku w tle, aby zapobiec zawieszaniu się interfejsu aplikacji z powodu błędu „aplikacja nie odpowiada” (ANR).

  • Dwukierunkowe: gdy strona internetowa wysyła wiadomość, aplikacja otrzymuje JavaScriptReplyProxy, którego może użyć do wysyłania wiadomości z powrotem do tej konkretnej ramki. Możesz zachować ten obiekt replyProxy i używać go w dowolnym momencie do wysyłania dowolnej liczby wiadomości do strony, a nie tylko do odpowiadania na każdą wiadomość wysyłaną przez stronę. Jeśli ramka źródłowa zostanie zamknięta lub zniszczona, wiadomości wysyłane za pomocą postMessage() w proxy są ignorowane.

  • Inicjowanie po stronie aplikacji: chociaż strona internetowa musi zawsze inicjować kanał komunikacji z aplikacją, aplikacja natywna może jednostronnie poprosić stronę internetową o rozpoczęcie tego procesu. Aplikacja natywna może komunikować się ze stroną internetową za pomocą addDocumentStartJavaScript() (aby ocenić JavaScript przed wczytaniem strony) lub evaluateJavaScript() (aby ocenić JavaScript po wczytaniu strony).

Ograniczenie: ten interfejs API wysyła dane jako ciągi znaków lub tablice byte[]. W przypadku bardziej złożonych struktur danych, takich jak obiekty JSON, musisz je serializować do jednego z tych formatów, a następnie deserializować po drugiej stronie, aby odtworzyć strukturę danych.

Przykład użycia:

Aby zrozumieć pełną sekwencję dwukierunkowej wymiany wiadomości, zdarzenia przebiegają w tej kolejności:

  1. Inicjowanie (aplikacja): aplikacja natywna rejestruje odbiornik za pomocą addWebMessageListener i inicjuje nawigację po stronie (np. za pomocą WebViewCompat.navigate lub loadUrl).
  2. Wysyłanie wiadomości (strona internetowa): JavaScript strony internetowej wywołuje myObject.postMessage(message), aby zainicjować komunikację.
  3. Odbieranie wiadomości i odpowiadanie na nią (aplikacja): aplikacja odbiera wiadomość w wywołaniu zwrotnym odbiornika i odpowiada za pomocą podanego replyProxy.postMessage().
  4. Odbieranie odpowiedzi (strona internetowa): strona internetowa odbiera asynchroniczną odpowiedź w funkcji wywołania zwrotnego 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);
}

Poniższy kod JavaScript pokazuje implementację po stronie klienta funkcji addWebMessageListener, która umożliwia treściom internetowym odbieranie wiadomości z aplikacji natywnej i wysyłanie własnych wiadomości za pomocą proxy myObject.

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

Używanie postWebMessage (alternatywa)

Android wprowadził tę metodę, aby zapewnić asynchroniczną alternatywę opartą na przesyłaniu wiadomości, podobną do window.postMessage w internecie.

Jak to działa: aplikacja używa WebViewCompat.postWebMessage, aby wysłać ładunek do głównej ramki strony internetowej. Aby utworzyć dwukierunkowy kanał komunikacji, możesz utworzyć WebMessageChannel i przekazać jeden z jego portów z wiadomością do treści internetowych.

Cechy:

  • Asynchroniczne: podobnie jak addWebMessageListener, ta metoda używa asynchronicznego przesyłania wiadomości, co zapewnia, że strona internetowa pozostaje responsywna na interakcje użytkownika, podczas gdy aplikacja przetwarza dane w tle.
  • Świadomość źródła: możesz określić targetOrigin, aby mieć pewność, że komponent WebView dostarcza dane tylko do zaufanej witryny.

Ograniczenia:

  • Zakres: ten interfejs API ogranicza komunikację do głównej ramki. Nie obsługuje bezpośredniego adresowania ani wysyłania wiadomości do elementów iframe.
  • Ograniczenia dotyczące identyfikatorów URI: nie możesz używać tej metody w przypadku treści wczytanych za pomocą identyfikatorów URI data:, file: lub loadData(), chyba że jako źródło docelowe określisz „*”. Dzięki temu każda strona może otrzymać wiadomość.
  • Ryzyko związane z tożsamością: treści internetowe nie mają jasnego sposobu na sprawdzenie tożsamości nadawcy. Wiadomość, którą otrzymuje strona internetowa, może pochodzić z Twojej aplikacji natywnej lub innego elementu iframe.

Użyj tej metody, gdy potrzebujesz prostego, asynchronicznego kanału do przesyłania danych opartych na ciągach znaków w starszych wersjach Androida, które nie obsługują addWebMessageListener.

Używanie addJavascriptInterface (starsza wersja)

Najstarsza metoda polega na wstrzyknięciu instancji obiektu natywnego bezpośrednio do komponentu WebView.

Jak to działa: definiujesz klasę Kotlin lub Java, dodajesz dozwolone metody za pomocą adnotacji @JavascriptInterface i dodajesz instancję klasy do komponentu WebView za pomocą addJavascriptInterface(Object, String).

Cechy:

  • Synchroniczne: środowisko wykonawcze JavaScript jest blokowane do momentu, aż metoda w kodzie Androida zwróci wartość.
  • Bezpieczeństwo wątków: system wywołuje metody w wątku w tle, co wymaga starannej synchronizacji po stronie Kotlin lub Java.
  • Ryzyko związane z bezpieczeństwem: domyślnie addJavascriptInterface jest dostępne dla każdej ramki w komponencie WebView, w tym dla elementów iframe. Nie ma kontroli dostępu opartej na źródle. Ze względu na asynchroniczne działanie komponentu WebView nie można bezpiecznie określić adresu URL ramki, która wywołuje Twój interfejs. Nie możesz polegać na metodach takich jak WebView.getUrl() w celu weryfikacji bezpieczeństwa , ponieważ nie gwarantują one dokładności i nie wskazują , która konkretna ramka wysłała żądanie.

Podsumowanie mechanizmów

W tabeli poniżej znajdziesz szybkie porównanie 3 głównych mechanizmów implementacji mostu natywnego:

Metoda addWebMessageListener postWebMessage addJavascriptInterface
Implementacja Asynchroniczna (odbiornik w głównym wątku) Asynchroniczna Synchroniczna
Bezpieczeństwo Najwyższe (oparte na liście dozwolonych) Wysokie (świadomość źródła) Niskie (brak sprawdzania źródła)
Złożoność Umiarkowana Umiarkowana Prosta
Kierunek Dwukierunkowe Dwukierunkowe Od strony internetowej do aplikacji
Minimalna wersja komponentu WebView Wersja 82 (i Jetpack Webkit 1.3.0) Wersja 45 (i Jetpack Webkit 1.1.0) Wszystkie wersje
Zalecane Tak Nie Nie

Obsługa przesyłania dużych ilości danych

Podczas przesyłania dużych ładunków, takich jak ciągi znaków lub pliki binarne o rozmiarze wielu megabajtów, musisz starannie zarządzać pamięcią, aby uniknąć błędów „aplikacja nie odpowiada” (ANR) lub awarii na urządzeniach 32-bitowych. W tej sekcji omawiamy różne techniki i ograniczenia związane z przesyłaniem znacznych ilości danych między aplikacją hosta a treściami internetowymi.

Przesyłanie danych binarnych za pomocą tablic bajtów

Za pomocą klasy WebMessageCompat możesz wysyłać tablice byte[] bezpośrednio zamiast serializować dane binarne do ciągów znaków Base64. Ponieważ Base64 dodaje do rozmiaru danych około 33% narzutu, jest to znacznie bardziej wydajne i szybsze.

  • Zalety danych binarnych: przesyłaj dane binarne, takie jak pliki graficzne lub dźwiękowe, między aplikacją natywną a treściami internetowymi.
  • Ograniczenie: nawet w przypadku tablic bajtów system kopiuje dane przez granicę komunikacji międzyprocesowej (IPC) między aplikacją a izolowanym procesem, którego komponent WebView używa do renderowania treści internetowych. W przypadku bardzo dużych plików nadal zużywa to znaczną ilość pamięci.

Poniższe przykłady kodu pokazują, jak skonfigurować addWebMessageListener po stronie aplikacji natywnej, aby odbierać wiadomości oznaczone jako WebMessageCompat.TYPE_ARRAY_BUFFER i opcjonalnie odpowiadać danymi binarnymi, sprawdzając 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
      );
  }
}

Poniższy kod JavaScript pokazuje implementację po stronie klienta funkcji addWebMessageListener, która umożliwia treściom internetowym wysyłanie i odbieranie danych binarnych (ArrayBuffer) do i z aplikacji natywnej za pomocą proxy window.myBridge wstrzykniętego w poprzednim przykładzie.

// 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]);
        }
    };
}

Wydajne wczytywanie danych na dużą skalę

W przypadku bardzo dużych plików (>10 MB) użyj metody shouldInterceptRequest, aby przesyłać dane strumieniowo:

  1. Strona internetowa inicjuje wywołanie fetch() do niestandardowego adresu URL zastępczego. Na przykład https://app.local/large-file.
  2. Aplikacja na Androida przechwytuje to żądanie w WebViewClient.shouldInterceptRequest.
  3. Aplikacja zwraca dane jako InputStream.

Umożliwia to przesyłanie danych strumieniowo w częściach, a nie wczytywanie całego ładunku do pamięci naraz.

Poniższa funkcja JavaScript pokazuje kod po stronie klienta, który umożliwia wydajne wczytywanie dużego pliku binarnego z aplikacji natywnej za pomocą standardowego wywołania fetch() do niestandardowego adresu URL zastępczego.

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);
    }
}

Poniższe przykłady kodu pokazują, jak po stronie aplikacji natywnej użyć metody WebViewClient.shouldInterceptRequest w Kotlinie i Javie, aby przesyłać strumieniowo duży plik binarny przez przechwytywanie niestandardowego adresu URL zastępczego żądanego przez treści internetowe.

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);
  }
});

Stosowanie się do zaleceń dotyczących bezpieczeństwa

Aby chronić aplikację i dane użytkowników, podczas implementowania mostu postępuj zgodnie z tymi wytycznymi:

  • Wymuszaj HTTPS: aby mieć pewność, że złośliwe treści osób trzecich nie mogą wywoływać natywnej logiki aplikacji, zezwalaj na komunikację tylko z bezpiecznymi źródłami.

  • Polegaj na regułach źródła: najlepszym sposobem na radzenie sobie z zaufaniem jest ścisłe zdefiniowanie allowedOriginRules i sprawdzenie sourceOrigin podanego w wywołaniu zwrotnym wiadomości. Unikaj używania pełnego symbolu wieloznacznego (*), który pasuje do wszystkich źródeł, jako jedynej reguły źródła, chyba że jest to absolutnie konieczne. Używanie symboli wieloznacznych w przypadku subdomen (np. *.example.com) pozostaje prawidłowe i bezpieczne w przypadku dopasowywania wielu subdomen (np. foo.example.com, bar.example.com).

    Uwaga: chociaż reguły źródła chronią przed złośliwymi witrynami innych firm i ukrytymi elementami iframe, nie chronią przed lukami w zabezpieczeniach typu cross-site scripting (XSS) w Twojej zaufanej domenie. Jeśli na przykład Twoja strona internetowa wyświetla treści użytkowników i jest podatna na przechowywane XSS, osoba przeprowadzająca atak może uruchomić skrypt działający jako Twoje zaufane źródło. Zanim wykonasz operacje na platformie natywnej, rozważ zastosowanie weryfikacji do ładunków wiadomości.

  • Minimalizuj obszar ataku: udostępniaj tylko te metody lub dane, których wymaga strona internetowa.

  • Sprawdzaj funkcje w czasie działania: najnowsze interfejsy API mostu, w tym addWebMessageListener, są częścią biblioteki Jetpack Webkit. Dlatego zawsze sprawdzaj, czy są obsługiwane, za pomocą WebViewFeature.isFeatureSupported() przed ich wywołaniem.