Bei der Erwähnung von JNI verspüren viele Programmierer unbewusst eine unerklärliche Angst. JNI sieht verdächtig schwierig aus, und auf den ersten Blick ähnelt sein Mechanismus der Magie. Wer sich jedoch näher damit befasst hat, weiß seine Eigenschaften sehr zu schätzen.

Falls Sie noch nichts von dieser Technologie gehört haben: Java Native Interface oder JNI ist ein Standard-Java-Mechanismus, der es Java-Code ermöglicht, mit C- und C++-Code zu interagieren. Wikipedia sagt: „JNI ermöglicht es Programmierern, native Methoden zu schreiben, um Situationen zu bewältigen, in denen eine Anwendung nicht vollständig in der Programmiersprache Java geschrieben werden kann, z. B. wenn die Standard-Java-Klassenbibliothek die plattformspezifischen Funktionen oder die Programmbibliothek nicht unterstützt“.

Klingt toll, nicht wahr?

Hier sind also drei Gründe, warum wir JNI lieben:

  1. JNI macht einige Prozesse möglich, die nicht in Java implementiert sind. Zum Beispiel hardwareabhängige oder direkte OS-API-Befehle. Für Android-Entwickler eröffnet es eine Menge Möglichkeiten außerhalb von Dalvik. Kompilierter C/C++-Code funktioniert auf jedem Java-Gerät, da JNI unabhängig von Dalvik ist, da es für die JVM entwickelt wurde.
  2. Möglichkeit zur Steigerung der Anwendungsleistung mit Hilfe von Low-Level-Bibliotheken für Dinge wie Grafik, Berechnungen, verschiedene Arten von Rendering usw.
  3. Eine große Anzahl von Bibliotheken wurde bereits für all die verschiedenen Aufgaben geschrieben. Und die Möglichkeit, den Code wiederzuverwenden, ohne ihn in einer anderen Sprache neu schreiben zu müssen, macht das Leben eines Entwicklers viel einfacher. Auf diese Weise werden so beliebte Bibliotheken wie FFmpeg und OpenCV für Android-Entwickler verfügbar.

Doch sehen wir uns diese Technologie einmal genauer an. Wie Linus Torvalds sagte: „Reden ist billig. Show me the code.“

Das Interaktionsschema sieht folgendermaßen aus:

J N I - Java native and C++ interaction using JNI

Um eine C++-Methode von Java aus aufzurufen, müssen Sie Folgendes tun:

  1. Eine Methode in einer Java-Klasse erstellen
    private native void FunctionName( parameters );
    
  2. Erstellen Sie eine Header- und eine cpp-Datei in einem jni-Ordner. Sie wird C++-Code enthalten, der von einer oben erwähnten nativen Funktion aufgerufen wird.
  3. In der Kopfzeile definieren wir die Signatur wie folgt:
    
    extern "C" {
        JNIEXPORT void JNICALL Java_my_package_NativeCallsClass_myFunction(JNIEnv *, jclass);
    }
    
    • extern “C” ist erforderlich, damit der C++-Compiler die Namen der deklarierten Funktionen nicht ändert;
    • JNIEXPORT ist ein notwendiger Modifikator für JNI;
    • Datentypen mit dem Präfix „j“: jdouble, jobject, jstring, etc – spiegeln Java-Objekte und -Typen in C/C++ wider;
    • JNIEnv* ist eine Schnittstelle für Java. Sie ermöglicht den Aufruf von Java-Methoden, die Erstellung von Java-Objekten und andere nützliche Java-Funktionen;
    • Der zweite wichtige Parameter ist jobject oder jclass, der angibt, ob die Methode statisch ist. Wenn ja, ist das Argument jclass (ein Link zu einer Klasse, in der der Code deklariert ist) und wenn statisch, ist es jobject (ein Link zu einem Objekt, in dem die Methode aufgerufen wurde).
      Eigentlich müssen Sie den Code nicht manuell schreiben. Man kann ein Javah-Dienstprogramm verwenden, aber ich fand es einfacher und übersichtlicher, es selbst zu tun. Die Funktion selbst ist in einer .cpp-Datei realisiert;
  4. Kehren Sie zu Java zurück, wo wir die native Funktion definiert haben, und fügen Sie hinzu:
    >System.loadLibrary( "Library name by which we compile cpp files mentioned above" );
    

    ganz am Anfang der Datei, oberhalb der Deklaration der nativen Methode. Ein Bibliotheksname wird in Android.mk beibehalten.

    LOCAL_MODULE:= Name
    # If we assemble with .mk files
    
  5. Ich möchte auch Ihre Aufmerksamkeit auf die Dateien wie Android.mk und Application.mk lenken. In Android.mk speichern wir die Namen aller .cpp-Dateien aus dem jni-Ordner, die wir kompilieren werden, alle spezifischen Flags und auch Pfade zu Headern und zusätzlichen Bibliotheken, mit anderen Worten, einige Verknüpfungsparameter und Einstellungen und andere Dinge, die für das Assemblieren einer Bibliothek benötigt werden.
    In Android.mk werden zusätzliche Assemblierungsparameter wie die erforderliche Plattformversion, der Architekturtyp usw. gespeichert.

Lassen Sie mich das Ganze zusammenfassen. Aufrufen von C++ aus Java:

  • Wir erstellen eine Funktion mit einem nativen Modifikator und rufen sie von einer beliebigen Java-Methode aus auf;
  • Der Java-Compiler erzeugt den Bytecode;
  • Der C/C++-Compiler erstellt eine dynamische Bibliothek .so;
  • Wenn wir die Anwendung ausführen, beginnt das Java-Gerät, den Bytecode zu verarbeiten;
  • Wenn es auf den Aufruf loadLibrary trifft, fügt es dem Prozess eine .so-Datei hinzu;
  • Wenn es auf den Aufruf der nativen Methode trifft, sucht es eine Methode in den geöffneten .so-Dateien anhand ihrer Signatur;
  • Wenn die Methode vorhanden ist, wird sie aufgerufen. Wenn nicht, stürzt die Anwendung ab.

Aber was ist, wenn wir das Gegenteil tun müssen (eine Java-Methode aus C/C++-Code aufrufen)?

Manchmal müssen wir eine Methode aus einer Java-Native aufrufen. Zum Beispiel, wenn es eine lang andauernde Operation in der nativen Methode gibt und wir ihren Fortschritt verfolgen müssen. Dies wird als Callback bezeichnet.

Die Logik eines Rückrufs:

Java-Code ruft eine C++-Methode auf ->
Nach der Verarbeitung ruft die Methode ihr SDK auf und sendet die benötigten Informationen. Um diese Informationen an die App zu senden, müssen Sie Folgendes tun:

  1. Im Ordner jni erstellen Sie eine Klasse AndroidGUIHandler, die IGUIHandler erweitert. Ihre Methoden empfangen Parameter vom SDK (wstring und andere), konvertieren sie in ein Java-kompatibles Format und rufen eine Java-Methode auf, die diese Parameter sendet.
  2. (Die Methoden der Klasse AndroidGUIHandler haben keine Signatur und sehen genauso aus wie C++ Methoden).
  3. Bevor Sie Java-Methoden aufrufen, müssen Sie zunächst mit Hilfe einer Wrapping-Klasse eine Verbindung zu einem Java-Thread herstellen. Dann müssen Sie eine weitere Wrapping-Klasse für Rückrufe verwenden. In dieser Klasse suchen Sie mit den jni-Methoden GetObjectClass und GetMethodID nach Java-Methoden, die Sie aufrufen müssen (bei der Suche nach Klassen und Methoden wird der Reflexionsmechanismus verwendet). Und dann rufen Sie Standard-Jni-Methoden wie CallIntMethod, CallVoidMethod auf, um zuvor gefundene Methoden aus Java aufzurufen und ihnen alle erforderlichen Informationen aus dem SDK zu senden.

Und das ist die Grundlage der gegebenen Technologie. Ich würde sagen, wenn Sie JNI nicht lieben, kennen Sie es wahrscheinlich nicht gut genug. Aber, wie immer, gibt es auch hier einige Schwachstellen:

  • JNI fängt Ausnahmen wie NullPointerException oder IllegalArgumentException nicht ab, da dies die Leistung beeinträchtigt und die meisten Funktionen in C-Bibliotheken mit dieser Art von Problemen nicht umgehen können;
  • ABER: JNI erlaubt die Verwendung von Java Exception. Wir können diesen Fehler also fast ausschließen, indem wir den JNI-Code manuell verarbeiten und die Fehlercodes überprüfen und dann Ausnahmen in Java auslösen;
  • Die Schwierigkeit, mit JNI von nativen Threads aus zu arbeiten. Um die Interaktion zu vereinfachen, müssen Sie eine Wrapping-Klasse schreiben, die alle erforderlichen Manipulationen durchführt;
  • Zunahme der Größe einer apk-Datei;
  • „Teurer“ Übergang von Java-Code zu nativem Code und zurück;
  • Das Debuggen eines C++-Codes ist ein Problem für sich;
  • In einigen Fällen kann die Arbeit mit JNI VIEL langsamer sein als mit einem Java-Analogon;
  • Der größte Nachteil von JNI ist jedoch, dass jeder native Code Ihre Java-Anwendung fest an eine bestimmte Plattform bindet. Und die Verwendung von JNI macht das Konzept von Write Once or Run Anywhere zunichte. Und das ist es, womit Sie zu kämpfen haben werden.

Und trotz all ihrer Mängel (niemand ist perfekt) wird diese Technologie geliebt und geschätzt.