Suggerimenti per JNI

JNI è l'interfaccia nativa Java. Definisce un modo per il bytecode che Android compila dal codice gestito (scritto nei linguaggi di programmazione Java o Kotlin) per interagire con il codice nativo (scritto in C/C++). JNI è indipendente dal fornitore, supporta il caricamento del codice da librerie condivise dinamiche e, sebbene a volte sia macchinoso, è ragionevolmente efficiente.

Nota: poiché Android compila Kotlin in bytecode compatibile con ART in modo simile al linguaggio di programmazione Java, puoi applicare le indicazioni riportate in questa pagina sia al linguaggio di programmazione Kotlin che a quello Java in termini di architettura JNI e dei relativi costi. Per scoprire di più, consulta Kotlin e Android.

Se non la conosci già, leggi la specifica Java Native Interface per capire come funziona JNI e quali funzionalità sono disponibili. Alcuni aspetti dell'interfaccia non sono immediatamente ovvi alla prima lettura, quindi potresti trovare utili le prossime sezioni.

Per sfogliare i riferimenti JNI globali e vedere dove vengono creati ed eliminati, utilizza la visualizzazione Heap JNI in Memory Profiler in Android Studio 3.2 e versioni successive.

Suggerimenti generali

Cerca di ridurre al minimo l'impronta del livello JNI. Ci sono diverse dimensioni da considerare. La tua soluzione JNI deve cercare di seguire queste linee guida (elencate di seguito in ordine di importanza, a partire dalla più importante):

  • Ridurre al minimo il marshalling delle risorse nel livello JNI. Il marshalling nel livello JNI ha costi non trascurabili. Cerca di progettare un'interfaccia che riduca al minimo la quantità di dati da marshalling e la frequenza con cui devi eseguire il marshalling dei dati.
  • Evita la comunicazione asincrona tra il codice scritto in un linguaggio di programmazione gestito e il codice scritto in C++, se possibile. In questo modo, l'interfaccia JNI sarà più facile da gestire. In genere, puoi semplificare gli aggiornamenti asincroni della UI mantenendo l'aggiornamento asincrono nella stessa lingua della UI. Ad esempio, invece di richiamare una funzione C++ dal thread UI nel codice Java tramite JNI, è meglio eseguire un callback tra due thread nel linguaggio di programmazione Java, con uno dei due che esegue una chiamata C++ bloccante e poi notifica al thread UI quando la chiamata bloccante è completata.
  • Riduci al minimo il numero di thread che devono toccare o essere toccati da JNI. Se devi utilizzare pool di thread sia in Java che in C++, cerca di mantenere la comunicazione JNI tra i proprietari del pool anziché tra i singoli thread di lavoro.
  • Mantieni il codice dell'interfaccia in un numero ridotto di posizioni di origine C++ e Java facilmente identificabili per facilitare i refactoring futuri. Valuta la possibilità di utilizzare una libreria per la generazione automatica di JNI, se appropriato.

JavaVM e JNIEnv

JNI definisce due strutture di dati chiave: "JavaVM" e "JNIEnv". Entrambi sono essenzialmente puntatori a puntatori a tabelle di funzioni. Nella versione C++, sono classi con un puntatore a una tabella di funzioni e una funzione membro per ogni funzione JNI che indiretta attraverso la tabella. La JavaVM fornisce le funzioni dell'"interfaccia di invocazione", che consentono di creare ed eliminare una JavaVM. In teoria puoi avere più JavaVM per processo, ma Android ne consente solo una.

JNIEnv fornisce la maggior parte delle funzioni JNI. Tutte le tue funzioni native ricevono un JNIEnv come primo argomento, ad eccezione dei metodi @CriticalNative, consulta chiamate native più veloci.

JNIEnv viene utilizzato per l'archiviazione locale dei thread. Per questo motivo, non puoi condividere un JNIEnv tra thread. Se un frammento di codice non ha altro modo per ottenere il proprio JNIEnv, devi condividere JavaVM e utilizzare GetEnv per scoprire JNIEnv del thread. (se presente; vedi AttachCurrentThread di seguito).

Le dichiarazioni C di JNIEnv e JavaVM sono diverse da quelle C++. Il file di inclusione "jni.h" fornisce typedef diversi a seconda che sia incluso in C o C++. Per questo motivo, è sconsigliabile includere argomenti JNIEnv nei file di intestazione inclusi da entrambe le lingue. In altre parole, se il file di intestazione richiede #ifdef __cplusplus, potresti dover eseguire un lavoro aggiuntivo se qualsiasi elemento nell'intestazione fa riferimento a JNIEnv.

Thread

Tutti i thread sono thread Linux pianificati dal kernel. Di solito vengono avviati da codice gestito (utilizzando Thread.start()), ma possono anche essere creati altrove e poi allegati a JavaVM. Ad esempio, un thread iniziato con pthread_create() o std::thread può essere allegato utilizzando le funzioni AttachCurrentThread() o AttachCurrentThreadAsDaemon(). Finché un thread non è collegato, non ha JNIEnv e non può effettuare chiamate JNI.

In genere, è meglio utilizzare Thread.start() per creare qualsiasi thread che deve chiamare il codice Java. In questo modo ti assicuri di avere spazio sufficiente nello stack, di trovarti nel ThreadGroup corretto e di utilizzare lo stesso ClassLoader del tuo codice Java. Inoltre, è più facile impostare il nome del thread per il debug in Java che dal codice nativo (vedi pthread_setname_np() se hai un pthread_t o thread_t e std::thread::native_handle() se hai un std::thread e vuoi un pthread_t).

L'allegato di un thread creato in modo nativo comporta la costruzione e l'aggiunta di un oggetto java.lang.Thread al ThreadGroup "principale", rendendolo visibile al debugger. Chiamare AttachCurrentThread() in un thread già allegato non ha effetto.

Android non sospende i thread che eseguono codice nativo. Se è in corso la garbage collection o il debugger ha emesso una richiesta di sospensione, Android metterà in pausa il thread la volta successiva che effettua una chiamata JNI.

I thread collegati tramite JNI devono chiamare DetachCurrentThread() prima di uscire. Se la codifica diretta è scomoda, in Android 2.0 (Eclair) e versioni successive puoi utilizzare pthread_key_create() per definire una funzione distruttore che verrà chiamata prima dell'uscita del thread e chiamare DetachCurrentThread() da lì. Utilizza questa chiave con pthread_setspecific() per archiviare JNIEnv in thread-local-storage, in modo che venga passato al distruttore come argomento.

jclass, jmethodID e jfieldID

Se vuoi accedere al campo di un oggetto dal codice nativo, devi procedere nel seguente modo:

  • Recupera il riferimento all'oggetto della classe con FindClass
  • Recupera l'ID campo per il campo con GetFieldID
  • Ottieni i contenuti del campo con qualcosa di appropriato, ad esempio GetIntField

Allo stesso modo, per chiamare un metodo, devi prima ottenere un riferimento all'oggetto classe e poi un ID metodo. Gli ID sono spesso solo puntatori a strutture di dati di runtime interni. La loro ricerca potrebbe richiedere diversi confronti di stringhe, ma una volta trovati, la chiamata effettiva per ottenere il campo o richiamare il metodo è molto rapida.

Se il rendimento è importante, è utile cercare i valori una sola volta e memorizzare nella cache i risultati nel codice nativo. Poiché esiste un limite di una JavaVM per processo, è ragionevole memorizzare questi dati in una struttura locale statica.

I riferimenti alle classi, gli ID campo e gli ID metodo sono garantiti validi fino allo scaricamento della classe. Le classi vengono scaricate solo se tutte le classi associate a un ClassLoader possono essere sottoposte a garbage collection, il che è raro, ma non impossibile in Android. Tieni presente, tuttavia, che jclass è un riferimento di classe e deve essere protetto con una chiamata a NewGlobalRef (vedi la sezione successiva).

Se vuoi memorizzare nella cache gli ID quando viene caricato un corso e memorizzarli nuovamente nella cache se il corso viene scaricato e ricaricato, il modo corretto per inizializzare gli ID è aggiungere al corso appropriato un frammento di codice simile a questo:

Kotlin

companion object {
    /*
     * We use a static class initializer to allow the native code to cache some
     * field offsets. This native function looks up and caches interesting
     * class/field/method IDs. Throws on failure.
     */
    private external fun nativeInit()

    init {
        nativeInit()
    }
}

Java

    /*
     * We use a class initializer to allow the native code to cache some
     * field offsets. This native function looks up and caches interesting
     * class/field/method IDs. Throws on failure.
     */
    private static native void nativeInit();

    static {
        nativeInit();
    }

Crea un metodo nativeClassInit nel codice C/C++ che esegue le ricerche di ID. Il codice verrà eseguito una volta, quando la classe viene inizializzata. Se la classe viene scaricata e poi ricaricata, verrà eseguita di nuovo.

Riferimenti locali e globali

Ogni argomento passato a un metodo nativo e quasi ogni oggetto restituito da una funzione JNI è un "riferimento locale". Ciò significa che è valido per la durata del metodo nativo corrente nel thread corrente. Anche se l'oggetto continua a esistere dopo la restituzione del metodo nativo, il riferimento non è valido.

Ciò vale per tutte le sottoclassi di jobject, tra cui jclass, jstring e jarray. L'ambiente di runtime ti avviserà della maggior parte degli utilizzi errati dei riferimenti quando i controlli JNI estesi sono abilitati.

L'unico modo per ottenere riferimenti non locali è tramite le funzioni NewGlobalRef e NewWeakGlobalRef.

Se vuoi conservare un riferimento per un periodo di tempo più lungo, devi utilizzare un riferimento "globale". La funzione NewGlobalRef accetta il riferimento locale come argomento e ne restituisce uno globale. Il riferimento globale è garantito fino alla chiamata DeleteGlobalRef.

Questo pattern viene comunemente utilizzato per memorizzare nella cache un jclass restituito da FindClass, ad esempio:

jclass localClass = env->FindClass("MyClass");
jclass globalClass = reinterpret_cast<jclass>(env->NewGlobalRef(localClass));

Tutti i metodi JNI accettano riferimenti locali e globali come argomenti. È possibile che i riferimenti allo stesso oggetto abbiano valori diversi. Ad esempio, i valori restituiti da chiamate consecutive a NewGlobalRef sullo stesso oggetto potrebbero essere diversi. Per verificare se due riferimenti si riferiscono allo stesso oggetto, devi utilizzare la funzione IsSameObject. Non confrontare mai i riferimenti con == nel codice nativo.

Una conseguenza di ciò è che non devi presupporre che i riferimenti agli oggetti siano costanti o unici nel codice nativo. Il valore che rappresenta un oggetto può essere diverso da un'invocazione di un metodo all'altra ed è possibile che due oggetti diversi abbiano lo stesso valore in chiamate consecutive. Non utilizzare i valori jobject come chiavi.

I programmatori sono tenuti a "non allocare eccessivamente" i riferimenti locali. In termini pratici, ciò significa che se crei un numero elevato di riferimenti locali, ad esempio durante l'esecuzione di un array di oggetti, devi liberarli manualmente con DeleteLocalRef anziché lasciare che JNI lo faccia per te. L'implementazione è necessaria solo per riservare slot per 16 riferimenti locali, quindi se ne hai bisogno di più devi eliminarli man mano o utilizzare EnsureLocalCapacity/PushLocalFrame per riservarne altri.

Tieni presente che jfieldID e jmethodID sono tipi opachi, non riferimenti a oggetti, e non devono essere passati a NewGlobalRef. Anche i puntatori ai dati non elaborati restituiti da funzioni come GetStringUTFChars e GetByteArrayElements non sono oggetti. (Possono essere passati tra i thread e sono validi fino alla chiamata Release corrispondente.)

Un caso insolito merita una menzione a parte. Se colleghi un thread nativo con AttachCurrentThread, il codice che stai eseguendo non rilascerà mai automaticamente i riferimenti locali finché il thread non viene scollegato. Tutti i riferimenti locali che crei dovranno essere eliminati manualmente. In generale, qualsiasi codice nativo che crea riferimenti locali in un ciclo probabilmente deve eseguire un'eliminazione manuale.

Fai attenzione quando utilizzi i riferimenti globali. I riferimenti globali possono essere inevitabili, ma sono difficili da eseguire il debug e possono causare comportamenti (errati) della memoria difficili da diagnosticare. A parità di altri fattori, una soluzione con meno riferimenti globali è probabilmente migliore.

Stringhe UTF-8 e UTF-16

Il linguaggio di programmazione Java utilizza UTF-16. Per comodità, JNI fornisce metodi che funzionano anche con UTF-8 modificato. La codifica modificata è utile per il codice C perché codifica \u0000 come 0xc0 0x80 anziché 0x00. Il vantaggio è che puoi contare su stringhe con terminazione zero in stile C, adatte all'uso con le funzioni di stringa libc standard. Lo svantaggio è che non puoi passare dati UTF-8 arbitrari a JNI e aspettarti che funzionino correttamente.

Per ottenere la rappresentazione UTF-16 di un String, utilizza GetStringChars. Tieni presente che le stringhe UTF-16 non sono terminate con zero e \u0000 è consentito, quindi devi conservare la lunghezza della stringa e il puntatore jchar.

Non dimenticare di Release le stringhe che Get. Le funzioni stringa restituiscono jchar* o jbyte*, che sono puntatori in stile C a dati primitivi anziché riferimenti locali. Sono garantiti validi fino alla chiamata di Release, il che significa che non vengono rilasciati quando il metodo nativo restituisce un valore.

I dati passati a NewStringUTF devono essere in formato UTF-8 modificato. Un errore comune è leggere i dati dei caratteri da un file o da un flusso di rete e passarli a NewStringUTF senza filtrarli. A meno che tu non sappia che i dati sono validi in formato MUTF-8 (o ASCII a 7 bit, che è un sottoinsieme compatibile), devi eliminare i caratteri non validi o convertirli nel formato Modified UTF-8 corretto. In caso contrario, è probabile che la conversione UTF-16 fornisca risultati imprevisti. CheckJNI, attivo per impostazione predefinita per gli emulatori, analizza le stringhe e interrompe la VM se riceve input non validi.

Prima di Android 8, in genere era più veloce operare con stringhe UTF-16, in quanto Android non richiedeva una copia in GetStringChars, mentre GetStringUTFChars richiedeva un'allocazione e una conversione in UTF-8. Android 8 ha modificato la rappresentazione di String per utilizzare 8 bit per carattere per le stringhe ASCII (per risparmiare memoria) e ha iniziato a utilizzare un garbage collector mobile. Queste funzionalità riducono notevolmente il numero di casi in cui ART può fornire un puntatore ai dati String senza crearne una copia, anche per GetStringCritical. Tuttavia, se la maggior parte delle stringhe elaborate dal codice sono brevi, è possibile evitare l'allocazione e la deallocazione nella maggior parte dei casi utilizzando un buffer allocato nello stack e GetStringRegion o GetStringUTFRegion. Ad esempio:

    constexpr size_t kStackBufferSize = 64u;
    jchar stack_buffer[kStackBufferSize];
    std::unique_ptr<jchar[]> heap_buffer;
    jchar* buffer = stack_buffer;
    jsize length = env->GetStringLength(str);
    if (length > kStackBufferSize) {
      heap_buffer.reset(new jchar[length]);
      buffer = heap_buffer.get();
    }
    env->GetStringRegion(str, 0, length, buffer);
    process_data(buffer, length);

Array primitivi

JNI fornisce funzioni per accedere ai contenuti degli oggetti array. Mentre gli array di oggetti devono essere accessibili una voce alla volta, gli array di primitive possono essere letti e scritti direttamente come se fossero dichiarati in C.

Per rendere l'interfaccia il più efficiente possibile senza vincolare l'implementazione della VM, la famiglia di chiamate Get<PrimitiveType>ArrayElements consente al runtime di restituire un puntatore agli elementi effettivi o allocare memoria e creare una copia. In entrambi i casi, il puntatore non elaborato restituito è garantito come valido fino all'emissione della chiamata Release corrispondente (il che implica che, se i dati non sono stati copiati, l'oggetto array verrà bloccato e non potrà essere spostato nell'ambito della compattazione dell'heap). Devi Release ogni array che Get. Inoltre, se la chiamata Get non va a buon fine, devi assicurarti che il codice non tenti di Release un puntatore NULL in un secondo momento.

Puoi determinare se i dati sono stati copiati passando un puntatore non NULL per l'argomento isCopy. Questo è raramente utile.

La chiamata Release accetta un argomento mode che può avere uno dei tre valori. Le azioni eseguite dal runtime dipendono dal fatto che abbia restituito un puntatore ai dati effettivi o una loro copia:

  • 0
    • Effettivo: l'oggetto array è stato staccato.
    • Copia: i dati vengono copiati di nuovo. Il buffer con la copia viene liberato.
  • JNI_COMMIT
    • Reale: non fa nulla.
    • Copia: i dati vengono copiati di nuovo. Il buffer con la copia non viene liberato.
  • JNI_ABORT
    • Effettivo: l'oggetto array è stato staccato. Le scritture precedenti non vengono interrotte.
    • Copia: il buffer con la copia viene liberato e le eventuali modifiche apportate andranno perse.

Un motivo per controllare il flag isCopy è sapere se devi chiamare Release con JNI_COMMIT dopo aver apportato modifiche a un array. Se alterni modifiche ed esecuzione di codice che utilizza i contenuti dell'array, potresti essere in grado di saltare il commit no-op. Un altro possibile motivo per controllare il flag è la gestione efficiente di JNI_ABORT. Ad esempio, potresti voler ottenere un array, modificarlo sul posto, passare parti ad altre funzioni e poi eliminare le modifiche. Se sai che JNI sta creando una nuova copia per te, non è necessario crearne un'altra "modificabile". Se JNI ti sta passando l'originale, devi creare una tua copia.

È un errore comune (ripetuto nel codice di esempio) presumere di poter saltare la chiamata Release se *isCopy è false. Non è questo il caso. Se non è stato allocato alcun buffer di copia, la memoria originale deve essere bloccata e non può essere spostata dal garbage collector.

Tieni presente inoltre che il flag JNI_COMMIT non rilascia l'array e alla fine dovrai chiamare di nuovo Release con un flag diverso.

Chiamate alle regioni

Esiste un'alternativa alle chiamate, come Get<Type>ArrayElements e GetStringChars, che può essere molto utile quando vuoi solo copiare dati in entrata o in uscita. Tieni in considerazione:

    jbyte* data = env->GetByteArrayElements(array, NULL);
    if (data != NULL) {
        memcpy(buffer, data, len);
        env->ReleaseByteArrayElements(array, data, JNI_ABORT);
    }

Questo recupera l'array, ne copia i primi len elementi di byte e poi lo rilascia. A seconda dell'implementazione, la chiamata Get bloccherà o copierà i contenuti dell'array. Il codice copia i dati (forse per la seconda volta), quindi chiama Release; in questo caso JNI_ABORT assicura che non ci sia la possibilità di una terza copia.

Si può ottenere lo stesso risultato in modo più semplice:

    env->GetByteArrayRegion(array, 0, len, buffer);

Questo processo presenta numerosi vantaggi:

  • Richiede una chiamata JNI anziché due, riducendo l'overhead.
  • Non richiede il blocco o copie aggiuntive dei dati.
  • Riduce il rischio di errori di programmazione: non c'è il rischio di dimenticare di chiamare Release dopo un errore.

Analogamente, puoi utilizzare la chiamata Set<Type>ArrayRegion per copiare i dati in un array e GetStringRegion o GetStringUTFRegion per copiare i caratteri da una String.

Eccezioni

Non devi chiamare la maggior parte delle funzioni JNI mentre è in attesa un'eccezione. Il codice dovrebbe rilevare l'eccezione (tramite il valore restituito dalla funzione, ExceptionCheck o ExceptionOccurred) e restituire o cancellare l'eccezione e gestirla.

Le uniche funzioni JNI che puoi chiamare mentre è in attesa un'eccezione sono:

  • DeleteGlobalRef
  • DeleteLocalRef
  • DeleteWeakGlobalRef
  • ExceptionCheck
  • ExceptionClear
  • ExceptionDescribe
  • ExceptionOccurred
  • MonitorExit
  • PopLocalFrame
  • PushLocalFrame
  • Release<PrimitiveType>ArrayElements
  • ReleasePrimitiveArrayCritical
  • ReleaseStringChars
  • ReleaseStringCritical
  • ReleaseStringUTFChars

Molte chiamate JNI possono generare un'eccezione, ma spesso forniscono un modo più semplice per verificare la presenza di errori. Ad esempio, se NewString restituisce un valore diverso da NULL, non è necessario verificare la presenza di un'eccezione. Tuttavia, se chiami un metodo (utilizzando una funzione come CallObjectMethod), devi sempre verificare la presenza di un'eccezione, perché il valore restituito non sarà valido se è stata generata un'eccezione.

Tieni presente che le eccezioni generate dal codice gestito non annullano i frame dello stack nativo. Inoltre, le eccezioni C++, generalmente sconsigliate su Android, non devono essere generate oltre il limite di transizione JNI dal codice C++ al codice gestito. Le istruzioni JNI Throw e ThrowNew impostano solo un puntatore di eccezione nel thread corrente. Al ritorno al codice gestito dal codice nativo, l'eccezione verrà annotata e gestita in modo appropriato.

Il codice nativo può "intercettare" un'eccezione chiamando ExceptionCheck o ExceptionOccurred e cancellarla con ExceptionClear. Come di consueto, ignorare le eccezioni senza gestirle può causare problemi.

Non esistono funzioni integrate per manipolare l'oggetto Throwable stesso, quindi se vuoi (ad esempio) ottenere la stringa di eccezione dovrai trovare la classe Throwable, cercare l'ID metodo per getMessage "()Ljava/lang/String;", richiamarlo e, se il risultato non è NULL, utilizzare GetStringUTFChars per ottenere qualcosa che puoi passare a printf(3) o a un equivalente.

Controllo esteso

JNI esegue un controllo degli errori molto limitato. Gli errori di solito causano un arresto anomalo. Android offre anche una modalità chiamata CheckJNI, in cui i puntatori della tabella delle funzioni JavaVM e JNIEnv vengono scambiati con tabelle di funzioni che eseguono una serie estesa di controlli prima di chiamare l'implementazione standard.

I controlli aggiuntivi includono:

  • Array: tentativo di allocare un array di dimensioni negative.
  • Puntatori errati: passaggio di un jarray/jclass/jobject/jstring errato a una chiamata JNI o passaggio di un puntatore NULL a una chiamata JNI con un argomento non annullabile.
  • Nomi delle classi: passaggio di qualsiasi elemento diverso dallo stile "java/lang/String" del nome della classe a una chiamata JNI.
  • Chiamate critiche: effettuare una chiamata JNI tra un'operazione di recupero "critica" e il suo rilascio corrispondente.
  • Direct ByteBuffers: passaggio di argomenti non validi a NewDirectByteBuffer.
  • Eccezioni: effettuare una chiamata JNI mentre è in attesa un'eccezione.
  • JNIEnv*s: utilizzo di un JNIEnv* dal thread errato.
  • jfieldIDs: utilizzo di un jfieldID NULL o di un jfieldID per impostare un campo su un valore del tipo errato (ad esempio, tentativo di assegnare un StringBuilder a un campo String), utilizzo di un jfieldID per un campo statico per impostare un campo istanza o viceversa oppure utilizzo di un jfieldID di una classe con istanze di un'altra classe.
  • jmethodIDs: utilizzo del tipo errato di jmethodID durante l'esecuzione di una chiamata JNI Call*Method: tipo restituito errato, mancata corrispondenza statico/non statico, tipo errato per "this" (per chiamate non statiche) o classe errata (per chiamate statiche).
  • Riferimenti: utilizzo di DeleteGlobalRef/DeleteLocalRef sul tipo di riferimento errato.
  • Modalità di rilascio: passaggio di una modalità di rilascio errata a una chiamata di rilascio (un valore diverso da 0, JNI_ABORT o JNI_COMMIT).
  • Sicurezza dei tipi: restituzione di un tipo incompatibile dal metodo nativo (ad esempio, restituzione di un oggetto StringBuilder da un metodo dichiarato per restituire una stringa).
  • UTF-8: passaggio di una sequenza di byte UTF-8 modificata non valida a una chiamata JNI.

L'accessibilità di metodi e campi non è ancora stata controllata: le limitazioni di accesso non si applicano al codice nativo.

Esistono diversi modi per attivare CheckJNI.

Se utilizzi l'emulatore, CheckJNI è attivo per impostazione predefinita.

Se hai un dispositivo sottoposto a rooting, puoi utilizzare la seguente sequenza di comandi per riavviare il runtime con CheckJNI attivato:

adb shell stop
adb shell setprop dalvik.vm.checkjni true
adb shell start

In entrambi i casi, all'avvio del runtime, l'output di logcat sarà simile al seguente:

D AndroidRuntime: CheckJNI is ON

Se hai un dispositivo normale, puoi utilizzare questo comando:

adb shell setprop debug.checkjni 1

Ciò non influirà sulle app già in esecuzione, ma qualsiasi app avviata da quel momento in poi avrà CheckJNI abilitato. Se modifichi la proprietà impostandola su un altro valore o se riavvii il dispositivo, CheckJNI verrà disattivato di nuovo. In questo caso, la prossima volta che un'app viene avviata, nell'output di logcat vedrai un messaggio simile a questo:

D Late-enabling CheckJNI

Puoi anche impostare l'attributo android:debuggable nel file manifest dell'applicazione per attivare CheckJNI solo per la tua app. Tieni presente che gli strumenti di compilazione Android lo fanno automaticamente per determinati tipi di build.

Librerie native

Puoi caricare il codice nativo dalle librerie condivise con lo standard System.loadLibrary.

In pratica, le versioni precedenti di Android presentavano bug in PackageManager che rendevano inaffidabile l'installazione e l'aggiornamento delle librerie native. Il progetto ReLinker offre soluzioni alternative per questo e altri problemi di caricamento delle librerie native.

Chiama System.loadLibrary (o ReLinker.loadLibrary) da un inizializzatore di classe statica. L'argomento è il nome della libreria "non decorato", quindi per caricare libfubar.so devi passare "fubar".

Se hai una sola classe con metodi nativi, è opportuno che la chiamata a System.loadLibrary si trovi in un inizializzatore statico per quella classe. Altrimenti, potresti voler effettuare la chiamata da Application per assicurarti che la libreria venga sempre caricata e sempre in anticipo.

Esistono due modi in cui il runtime può trovare i metodi nativi. Puoi registrarli in modo esplicito con RegisterNatives oppure puoi consentire al runtime di cercarli dinamicamente con dlsym. I vantaggi di RegisterNatives sono che ottieni un controllo preventivo dell'esistenza dei simboli, inoltre puoi avere librerie condivise più piccole e veloci non esportando altro che JNI_OnLoad. Il vantaggio di lasciare che il runtime rilevi le funzioni è che il codice da scrivere è leggermente inferiore.

Per usare RegisterNatives:

  • Fornisci una funzione JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved).
  • Nel tuo JNI_OnLoad, registra tutti i tuoi metodi nativi utilizzando RegisterNatives.
  • Crea con uno script di versione (preferito) o utilizza -fvisibility=hidden in modo che dalla tua libreria venga esportato solo il tuo JNI_OnLoad. In questo modo si ottiene un codice più rapido e più piccolo e si evitano potenziali collisioni con altre librerie caricate nell'app (ma si creano stack trace meno utili se l'app si arresta in modo anomalo nel codice nativo).

L'inizializzatore statico dovrebbe essere simile al seguente:

Kotlin

companion object {
    init {
        System.loadLibrary("fubar")
    }
}

Java

static {
    System.loadLibrary("fubar");
}

La funzione JNI_OnLoad dovrebbe avere un aspetto simile al seguente se scritta in C++:

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved) {
    JNIEnv* env;
    if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
        return JNI_ERR;
    }

    // Find your class. JNI_OnLoad is called from the correct class loader context for this to work.
    jclass c = env->FindClass("com/example/app/package/MyClass");
    if (c == nullptr) return JNI_ERR;

    // Register your class' native methods.
    static const JNINativeMethod methods[] = {
        {"nativeFoo", "()V", reinterpret_cast<void*>(nativeFoo)},
        {"nativeBar", "(Ljava/lang/String;I)Z", reinterpret_cast<void*>(nativeBar)},
    };
    int rc = env->RegisterNatives(c, methods, sizeof(methods)/sizeof(JNINativeMethod));
    if (rc != JNI_OK) return rc;

    return JNI_VERSION_1_6;
}

Per utilizzare invece l'"individuazione" dei metodi nativi, devi assegnare loro un nome in un modo specifico (vedi la specifica JNI per i dettagli). Ciò significa che se la firma di un metodo è errata, non lo saprai fino alla prima volta che il metodo viene effettivamente richiamato.

Qualsiasi chiamata FindClass effettuata da JNI_OnLoad risolverà le classi nel contesto del caricatore di classi utilizzato per caricare la libreria condivisa. Quando viene chiamato da altri contesti, FindClass utilizza il caricatore di classi associato al metodo nella parte superiore dello stack Java oppure, se non è presente (perché la chiamata proviene da un thread nativo appena collegato), utilizza il caricatore di classi "di sistema". Il caricatore di classi di sistema non conosce le classi della tua applicazione, quindi non potrai cercare le tue classi con FindClass in questo contesto. In questo modo, JNI_OnLoad diventa un luogo comodo per cercare e memorizzare nella cache le classi: una volta che hai un jclass riferimento globale valido, puoi utilizzarlo da qualsiasi thread collegato.

Chiamate native più rapide con @FastNative e @CriticalNative

I metodi nativi possono essere annotati con @FastNative o @CriticalNative (ma non entrambi) per velocizzare le transizioni tra codice gestito e nativo. Tuttavia, queste annotazioni comportano alcuni cambiamenti nel comportamento che devono essere presi in considerazione attentamente prima dell'utilizzo. Sebbene menzioniamo brevemente queste modifiche di seguito, consulta la documentazione per i dettagli.

L'annotazione @CriticalNative può essere applicata solo ai metodi nativi che non utilizzano oggetti gestiti (nei parametri o nei valori restituiti o come this implicito) e questa annotazione modifica l'ABI di transizione JNI. L'implementazione nativa deve escludere i parametri JNIEnv e jclass dalla firma della funzione.

Durante l'esecuzione di un metodo @FastNative o @CriticalNative, la garbage collection non può sospendere il thread per il lavoro essenziale e potrebbe bloccarsi. Non utilizzare queste annotazioni per i metodi a esecuzione prolungata, inclusi i metodi in genere veloci, ma generalmente illimitati. In particolare, il codice non deve eseguire operazioni di I/O significative o acquisire blocchi nativi che possono essere mantenuti a lungo.

Queste annotazioni sono state implementate per l'uso del sistema a partire da Android 8 e sono diventate API pubbliche testate da CTS in Android 14. Queste ottimizzazioni probabilmente funzionano anche su dispositivi Android 8-13 (anche se senza le solide garanzie CTS), ma la ricerca dinamica dei metodi nativi è supportata solo su Android 12 e versioni successive, la registrazione esplicita con JNI RegisterNatives è strettamente necessaria per l'esecuzione su versioni di Android 8-11. Queste annotazioni vengono ignorate su Android 7 e versioni precedenti. La mancata corrispondenza dell'ABI per @CriticalNative comporterebbe un marshalling degli argomenti errato e probabilmente arresti anomali.

Per i metodi critici per il rendimento che richiedono queste annotazioni, è vivamente consigliato di registrare esplicitamente i metodi con JNI RegisterNatives anziché fare affidamento sul "rilevamento" basato sul nome dei metodi nativi. Per ottenere prestazioni ottimali all'avvio dell'app, è consigliabile includere i chiamanti dei metodi @FastNative o @CriticalNative nel profilo di base. A partire da Android 12, una chiamata a un metodo nativo @CriticalNative da un metodo gestito compilato è quasi economica come una chiamata non inline in C/C++, a condizione che tutti gli argomenti rientrino nei registri (ad esempio fino a 8 argomenti integrali e fino a 8 argomenti in virgola mobile su arm64).

A volte è preferibile dividere un metodo nativo in due: un metodo molto veloce che può non riuscire e un altro che gestisce i casi lenti. Ad esempio:

Kotlin

fun writeInt(nativeHandle: Long, value: Int) {
    // A fast buffered write with a `@CriticalNative` method should succeed most of the time.
    if (!nativeTryBufferedWriteInt(nativeHandle, value)) {
        // If the buffered write failed, we need to use the slow path that can perform
        // significant I/O and can even throw an `IOException`.
        nativeWriteInt(nativeHandle, value)
    }
}

@CriticalNative
external fun nativeTryBufferedWriteInt(nativeHandle: Long, value: Int): Boolean

external fun nativeWriteInt(nativeHandle: Long, value: Int)

Java

void writeInt(long nativeHandle, int value) {
    // A fast buffered write with a `@CriticalNative` method should succeed most of the time.
    if (!nativeTryBufferedWriteInt(nativeHandle, value)) {
        // If the buffered write failed, we need to use the slow path that can perform
        // significant I/O and can even throw an `IOException`.
        nativeWriteInt(nativeHandle, value);
    }
}

@CriticalNative
static native boolean nativeTryBufferedWriteInt(long nativeHandle, int value);

static native void nativeWriteInt(long nativeHandle, int value);

Considerazioni a 64 bit

Per supportare le architetture che utilizzano puntatori a 64 bit, utilizza un campo long anziché un campo int quando memorizzi un puntatore a una struttura nativa in un campo Java.

Funzionalità non supportate/compatibilità con le versioni precedenti

Sono supportate tutte le funzionalità JNI 1.6, con la seguente eccezione:

  • DefineClass non è implementato. Android non utilizza bytecode Java o file di classe, pertanto il passaggio di dati di classe binari non funziona.

Per la compatibilità con le versioni precedenti di Android, potresti dover tenere presente quanto segue:

  • Ricerca dinamica delle funzioni native

    Fino ad Android 2.0 (Eclair), il carattere "$" non veniva convertito correttamente in "_00024" durante le ricerche dei nomi dei metodi. Per risolvere questo problema, è necessario utilizzare la registrazione esplicita o spostare i metodi nativi al di fuori delle classi interne.

  • Scollegamento dei thread

    Fino ad Android 2.0 (Eclair), non era possibile utilizzare una funzione distruttore pthread_key_create per evitare il controllo "thread must be detached before exit". (Il runtime utilizza anche una funzione distruttore della chiave pthread, quindi è una corsa per vedere quale viene chiamata per prima.)

  • Riferimenti globali deboli

    Fino ad Android 2.2 (Froyo), i riferimenti globali deboli non erano implementati. Le versioni precedenti rifiuteranno con decisione i tentativi di utilizzo. Puoi utilizzare le costanti della versione della piattaforma Android per verificare il supporto.

    Fino ad Android 4.0 (Ice Cream Sandwich), i riferimenti globali deboli potevano essere passati solo a NewLocalRef, NewGlobalRef e DeleteWeakGlobalRef. (La specifica consiglia vivamente ai programmatori di creare riferimenti rigidi a variabili globali deboli prima di farci qualsiasi cosa, quindi questo non dovrebbe essere affatto limitante.)

    A partire da Android 4.0 (Ice Cream Sandwich), i riferimenti globali deboli possono essere utilizzati come qualsiasi altro riferimento JNI.

  • Riferimenti locali

    Fino ad Android 4.0 (Ice Cream Sandwich), i riferimenti locali erano in realtà puntatori diretti. Ice Cream Sandwich ha aggiunto l'indirezione necessaria per supportare garbage collector migliori, ma ciò significa che molti bug JNI sono non rilevabili nelle versioni precedenti. Per ulteriori dettagli, consulta Modifiche al riferimento locale JNI in ICS.

    Nelle versioni di Android precedenti ad Android 8.0, il numero di riferimenti locali è limitato a un limite specifico per la versione. A partire da Android 8.0, Android supporta riferimenti locali illimitati.

  • Determinare il tipo di riferimento con GetObjectRefType

    Fino ad Android 4.0 (Ice Cream Sandwich), a causa dell'uso di puntatori diretti (vedi sopra), era impossibile implementare GetObjectRefType correttamente. Abbiamo invece utilizzato un'euristica che esaminava la tabella delle variabili globali deboli, gli argomenti, la tabella delle variabili locali e la tabella delle variabili globali in quest'ordine. La prima volta che ha trovato il tuo puntatore diretto, ha segnalato che il riferimento era del tipo che stava esaminando. Ciò significava, ad esempio, che se chiamavi GetObjectRefType su una jclass globale che risultava essere la stessa jclass passata come argomento implicito al tuo metodo nativo statico, ricevevi JNILocalRefType anziché JNIGlobalRefType.

  • @FastNative e @CriticalNative

    Fino ad Android 7, queste annotazioni di ottimizzazione venivano ignorate. La mancata corrispondenza dell'ABI per @CriticalNative comporterebbe un marshalling errato degli argomenti e probabilmente arresti anomali.

    La ricerca dinamica delle funzioni native per i metodi @FastNative e @CriticalNative non è stata implementata in Android 8-10 e contiene bug noti in Android 11. L'utilizzo di queste ottimizzazioni senza la registrazione esplicita con JNI RegisterNatives probabilmente causerà arresti anomali su Android 8-11.

  • FindClass lancia ClassNotFoundException

    Per la compatibilità con le versioni precedenti, Android genera ClassNotFoundException anziché NoClassDefFoundError quando una classe non viene trovata da FindClass. Questo comportamento è coerente con l'API Java Reflection Class.forName(name).

Domande frequenti: perché visualizzo UnsatisfiedLinkError?

Quando si lavora sul codice nativo, non è raro riscontrare un errore come questo:

java.lang.UnsatisfiedLinkError: Library foo not found

In alcuni casi, il messaggio indica che la libreria non è stata trovata. In altri casi la libreria esiste, ma non è stato possibile aprirla con dlopen(3) e i dettagli dell'errore sono disponibili nel messaggio di dettaglio dell'eccezione.

Motivi comuni per cui potresti riscontrare eccezioni "libreria non trovata":

  • La libreria non esiste o non è accessibile all'app. Utilizza adb shell ls -l <path> per verificarne la presenza e le autorizzazioni.
  • La libreria non è stata creata con l'NDK. Ciò può comportare dipendenze da funzioni o librerie che non esistono sul dispositivo.

Un altro tipo di errori UnsatisfiedLinkError si presenta nel seguente modo:

java.lang.UnsatisfiedLinkError: myfunc
        at Foo.myfunc(Native Method)
        at Foo.main(Foo.java:10)

In logcat vedrai:

W/dalvikvm(  880): No implementation found for native LFoo;.myfunc ()V

Ciò significa che il runtime ha tentato di trovare un metodo corrispondente, ma non è riuscito. Alcuni dei motivi più comuni sono:

  • La libreria non viene caricata. Controlla l'output di logcat per messaggi sul caricamento della libreria.
  • Il metodo non viene trovato a causa di una mancata corrispondenza del nome o della firma. Questo è comunemente causato da:
    • Per la ricerca pigra dei metodi, la mancata dichiarazione delle funzioni C++ con extern "C" e la visibilità appropriata (JNIEXPORT). Tieni presente che prima di Ice Cream Sandwich, la macro JNIEXPORT era errata, quindi l'utilizzo di un nuovo GCC con un vecchio jni.h non funzionerà. Puoi utilizzare arm-eabi-nm per visualizzare i simboli così come appaiono nella libreria. Se appaiono danneggiati (ad esempio _Z15Java_Foo_myfuncP7_JNIEnvP7_jclass anziché Java_Foo_myfunc) o se il tipo di simbolo è una "t" minuscola anziché una "T" maiuscola, devi modificare la dichiarazione.
    • Per la registrazione esplicita, errori minori durante l'inserimento della firma del metodo. Assicurati che ciò che stai passando alla chiamata di registrazione corrisponda alla firma nel file di log. Ricorda che "B" è byte e "Z" è boolean. I componenti del nome della classe nelle firme iniziano con "L", terminano con ";", utilizzano "/" per separare i nomi di pacchetti/classi e "$" per separare i nomi delle classi interne (ad esempio Ljava/util/Map$Entry;).

L'utilizzo di javah per generare automaticamente le intestazioni JNI può aiutarti a evitare alcuni problemi.

Domande frequenti: perché FindClass non ha trovato il mio corso?

La maggior parte di questi consigli si applica anche alla mancata individuazione di metodi con GetMethodID o GetStaticMethodID o di campi con GetFieldID o GetStaticFieldID.

Assicurati che la stringa del nome della classe abbia il formato corretto. I nomi delle classi JNI iniziano con il nome del pacchetto e sono separati da barre, ad esempio java/lang/String. Se stai cercando una classe di array, devi iniziare con il numero appropriato di parentesi quadre e devi anche racchiudere la classe tra "L" e ";", quindi un array unidimensionale di String sarebbe [Ljava/lang/String;. Se stai cercando una classe interna, utilizza "$" anziché ".". In generale, l'utilizzo di javap sul file .class è un buon modo per trovare il nome interno della classe.

Se attivi la riduzione del codice, assicurati di configurare il codice da conservare. La configurazione di regole di conservazione appropriate è importante perché il riduttore di codice potrebbe altrimenti rimuovere classi, metodi o campi utilizzati solo da JNI.

Se il nome della classe sembra corretto, potresti riscontrare un problema con il caricatore della classe. FindClass vuole avviare la ricerca del corso nel caricatore di corsi associato al tuo codice. Esamina lo stack di chiamate, che avrà un aspetto simile a questo:

    Foo.myfunc(Native Method)
    Foo.main(Foo.java:10)

Il metodo in cima all'elenco è Foo.myfunc. FindClass trova l'oggetto ClassLoader associato alla classe Foo e lo utilizza.

In genere, questa operazione produce il risultato desiderato. Potresti avere problemi se crei un thread autonomamente (magari chiamando pthread_create e poi allegandolo con AttachCurrentThread). Ora non ci sono stack frame della tua applicazione. Se chiami FindClass da questo thread, la JavaVM verrà avviata nel caricatore di classi "system" anziché in quello associato alla tua applicazione, quindi i tentativi di trovare classi specifiche dell'app non andranno a buon fine.

Esistono alcuni modi per ovviare a questo problema:

  • Esegui le ricerche di FindClass una sola volta in JNI_OnLoad e memorizza nella cache i riferimenti alle classi per un utilizzo successivo. Qualsiasi chiamata FindClass effettuata nell'ambito dell'esecuzione di JNI_OnLoad utilizzerà il caricatore di classi associato alla funzione che ha chiamato System.loadLibrary (si tratta di una regola speciale, fornita per rendere più comoda l'inizializzazione della libreria). Se il codice dell'app carica la libreria, FindClass utilizzerà il caricatore di classi corretto.
  • Passa un'istanza della classe alle funzioni che ne hanno bisogno, dichiarando che il tuo metodo nativo accetta un argomento Class e passando Foo.class.
  • Memorizza nella cache un riferimento all'oggetto ClassLoader in un punto comodo ed esegui chiamate loadClass direttamente. Ciò richiede un po' di impegno.

Domande frequenti: come faccio a condividere i dati non elaborati con il codice nativo?

Potresti trovarti nella situazione in cui devi accedere a un buffer di grandi dimensioni di dati non elaborati sia dal codice gestito che da quello nativo. Esempi comuni includono la manipolazione di bitmap o campioni audio. Esistono due approcci di base.

Puoi archiviare i dati in un byte[]. Ciò consente un accesso molto rapido dal codice gestito. Tuttavia, sul lato nativo non è garantito l'accesso ai dati senza doverli copiare. In alcune implementazioni, GetByteArrayElements e GetPrimitiveArrayCritical restituiranno puntatori effettivi ai dati non elaborati nell'heap gestito, ma in altre allocheranno un buffer nell'heap nativo e copieranno i dati.

L'alternativa è archiviare i dati in un buffer di byte diretto. Questi possono essere creati con java.nio.ByteBuffer.allocateDirect o con la funzione JNI NewDirectByteBuffer. A differenza dei normali buffer di byte, lo spazio di archiviazione non viene allocato nell'heap gestito e può sempre essere accessibile direttamente dal codice nativo (ottieni l'indirizzo con GetDirectBufferAddress). A seconda di come viene implementato l'accesso diretto ai buffer di byte, l'accesso ai dati dal codice gestito può essere molto lento.

La scelta di quale utilizzare dipende da due fattori:

  1. La maggior parte degli accessi ai dati avverrà da codice scritto in Java o in C/C++?
  2. Se i dati vengono alla fine passati a un'API di sistema, in quale formato devono essere? Ad esempio, se i dati vengono alla fine passati a una funzione che accetta un byte[], l'elaborazione in un ByteBuffer diretto potrebbe essere sconsigliabile.

Se non c'è un vincitore chiaro, utilizza un buffer di byte diretto. Il supporto è integrato direttamente in JNI e le prestazioni dovrebbero migliorare nelle versioni future.