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 ingombrante, è 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 ai linguaggi di programmazione Kotlin che a Java in termini di architettura JNI e costi associati. Per scoprire di più, consulta Kotlin e Android.

Se non lo conosci già, leggi le specifiche dell'interfaccia nativa Java per avere un'idea del funzionamento di JNI e delle funzionalità disponibili. Alcuni aspetti dell'interfaccia non sono immediatamente evidenti alla prima lettura, pertanto potresti trovare utili le sezioni successive.

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

Suggerimenti generali

Prova a ridurre al minimo l'impatto del livello JNI. Ci sono diverse dimensioni da considerare qui. La tua soluzione JNI deve seguire queste linee guida (elencate di seguito in ordine di importanza, iniziando con la più importante):

  • Riduci al minimo il marshalling delle risorse nel livello JNI. Il marshalling attraverso il livello JNI ha costi non banali. Prova a progettare un'interfaccia che riduce al minimo la quantità di dati necessaria per il marshall e la frequenza di 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 sarà più facile gestire l'interfaccia JNI. In genere è possibile semplificare gli aggiornamenti asincroni dell'interfaccia utente mantenendo l'aggiornamento asincrono nella stessa lingua dell'interfaccia utente. Ad esempio, invece di richiamare una funzione C++ dal thread dell'interfaccia utente nel codice Java tramite JNI, è meglio eseguire un callback tra due thread nel linguaggio di programmazione Java, con uno di questi che effettua una chiamata C++ di blocco e poi invia una notifica al thread dell'UI quando la chiamata di blocco è completata.
  • Riduci al minimo il numero di fili che devono essere toccati o toccati da JNI. Se hai bisogno di utilizzare pool di thread in entrambi i linguaggi Java e C++, prova a mantenere la comunicazione JNI tra i proprietari del pool anziché tra i singoli thread di worker.
  • Mantieni il codice dell'interfaccia in un numero ridotto di posizioni di origine C++ e Java facilmente identificate per facilitare i refactoring futuri. Prendi in considerazione l'utilizzo di una libreria di generazione automatica JNI, a seconda dei casi.

JavaVM e JNIEnv

JNI definisce due strutture di dati chiave: "JavaVM" e "JNIEnv". Entrambi sono essenzialmente puntatori a puntatori alle tabelle delle funzioni. (nella versione C++, sono classi con un puntatore a una tabella di funzioni e una funzione membro per ogni funzione JNI che indirizza tramite la tabella). JavaVM fornisce le funzioni di "interfaccia di chiamata", che consentono di creare ed eliminare una JavaVM. In teoria si possono 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, vedi chiamate native più veloci.

JNIEnv viene utilizzato per l'archiviazione locale nei thread. Per questo motivo, non puoi condividere un file JNIEnv tra thread. Se una porzione di codice non ha altro modo per ottenere la sua JNIEnv, devi condividere la JavaVM e utilizzare GetEnv per scoprire il JNIEnv del thread. Supponendo che ne sia uno, vedi AttachCurrentThread di seguito.

Le dichiarazioni C di JNIEnv e JavaVM sono diverse dalle dichiarazioni C++. Il file di inclusione "jni.h" fornisce definizioni di tipo diverse a seconda che sia incluso in C o C++. Per questo motivo non è una buona idea includere argomenti JNIEnv nei file di intestazione inclusi da entrambi i linguaggi. In altri termini: se il file di intestazione richiede #ifdef __cplusplus, potrebbe essere necessario eseguire ulteriori operazioni se qualcosa nell'intestazione si riferisce a JNIEnv.

Thread

Tutti i thread sono thread Linux, pianificati dal kernel. In genere vengono avviate da un codice gestito (utilizzando Thread.start()), ma possono anche essere create altrove e poi associate a JavaVM. Ad esempio, un thread iniziato con pthread_create() o std::thread può essere collegato utilizzando le funzioni AttachCurrentThread() o AttachCurrentThreadAsDaemon(). Fino a quando un thread non è allegato, non ha JNIEnv e non può effettuare chiamate JNI.

In genere è meglio utilizzare Thread.start() per creare thread che devono richiamare codice Java. In questo modo ti assicurerai di avere spazio sufficiente per lo stack, di essere nel codice ThreadGroup corretto e di utilizzare lo stesso ClassLoader del codice Java. Inoltre, è più facile impostare il nome del thread per il debug in Java anziché 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).

Il collegamento di un thread creato in modo nativo comporta la creazione e l'aggiunta di un oggetto java.lang.Thread al ThreadGroup "principale", il che lo rende visibile al debugger. Chiamare AttachCurrentThread() su un thread già allegato è un'operazione autonoma.

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

I thread collegati tramite JNI devono chiamare DetachCurrentThread() prima di uscire. Se non è possibile scrivere direttamente questo codice, 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 richiamare DetachCurrentThread() da lì. Utilizza questa chiave con pthread_setspecific() per archiviare JNIEnv in thread-local-storage; in questo modo verrà passato al tuo distruttore come argomento.

jclass, jmethodID e jfieldID

Per accedere al campo di un oggetto dal codice nativo:

  • Recupera il riferimento all'oggetto di classe per la classe con FindClass
  • Ottieni l'ID campo per il campo con GetFieldID
  • Recupera i contenuti del campo con un elemento appropriato, come GetIntField

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

Se le prestazioni sono importanti, è utile cercare i valori una volta e memorizzare nella cache i risultati nel codice nativo. Poiché esiste un limite di una JavaVM per processo, è ragionevole archiviare questi dati in una struttura locale statica.

I riferimenti alle classi, gli ID campo e gli ID metodo sono garantiti e validi fino all'unload della classe. Le classi vengono unload solo se tutte le classi associate a un ClassLoader possono essere garbage collection, un'operazione rara, ma non impossibile in Android. Tuttavia, tieni presente che jclass è un riferimento di classe e deve essere protetto con una chiamata a NewGlobalRef (vedi la sezione successiva).

Se vuoi memorizzare gli ID nella cache quando viene caricata una classe e memorizzarli automaticamente nella cache se la classe viene scaricata e ricaricata, il modo corretto per inizializzare gli ID è aggiungere alla classe appropriata una porzione di codice simile alla seguente:

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 esegua le ricerche degli ID. Il codice verrà eseguito una volta, all'inizializzazione della classe. 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 attuale nel thread corrente. Anche se l'oggetto continua a essere attivo dopo il ritorno del metodo nativo, il riferimento non è valido.

Questo si applica a tutte le sottoclassi di jobject, tra cui jclass, jstring e jarray. Quando sono abilitati controlli JNI estesi, il runtime ti avvisa della maggior parte degli utilizzi errati dei riferimenti.

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

Se vuoi conservare un riferimento per un periodo più lungo, devi utilizzare un riferimento "globale". La funzione NewGlobalRef prende il riferimento locale come argomento e restituisce uno globale. La validità del riferimento globale è garantita fino a quando non chiami DeleteGlobalRef.

Questo pattern viene comunemente utilizzato durante la memorizzazione nella cache di una jclass restituita da FindClass, ad esempio:

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

Tutti i metodi JNI accettano come argomenti sia riferimenti locali che globali. I riferimenti allo stesso oggetto possono avere valori diversi. Ad esempio, i valori restituiti dalle chiamate consecutive a NewGlobalRef per lo stesso oggetto potrebbero essere diversi. Per verificare se due riferimenti fanno riferimento 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 univoci nel codice nativo. Il valore che rappresenta un oggetto può essere diverso da una chiamata a 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 in modo eccessivo" riferimenti locali. In pratica, questo significa che se stai creando un numero elevato di riferimenti locali, ad esempio durante l'esecuzione di un array di oggetti, devi liberarli manualmente con DeleteLocalRef, invece di lasciare che sia JNI a farlo per te. L'implementazione è necessaria solo per prenotare slot per 16 riferimenti locali, quindi se te ne occorrono altri devi eliminarli man mano che procedi o utilizzare EnsureLocalCapacity/PushLocalFrame per prenotarne altri.

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

Un caso insolito merita una menzione separata. Se colleghi un thread nativo con AttachCurrentThread, il codice in esecuzione non svincola mai automaticamente riferimenti locali finché il thread non si scollega. I riferimenti locali creati dovranno essere eliminati manualmente. In generale, è probabile che per qualsiasi codice nativo che crei riferimenti locali in un loop debba eseguire un'eliminazione manuale.

Fai attenzione all'utilizzo di riferimenti globali. I riferimenti globali possono essere inevitabili, ma sono difficili da eseguire il debug e possono causare comportamenti 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 la codifica UTF-16. Per praticità, JNI fornisce anche metodi compatibili con Modified UTF-8. La codifica modificata è utile per il codice C perché codifica \u0000 come 0xc0 0x80 anziché 0x00. L'aspetto positivo è che puoi contare su stringhe con terminazione zero in stile C, adatte all'uso con le funzioni delle stringhe libc standard. Il lato negativo è che non puoi passare i dati UTF-8 arbitrari a JNI e aspettarti che funzionino correttamente.

Per ottenere la rappresentazione UTF-16 di un elemento String, utilizza GetStringChars. Tieni presente che le stringhe UTF-16 non hanno terminazione zero e \u0000 è consentito, quindi devi rimanere sulla lunghezza della stringa e sul puntatore jchar.

Non dimenticare di Release le stringhe Get. Le funzioni stringa restituiscono jchar* o jbyte*, che sono puntatori in stile C ai dati primitivi anziché a riferimenti locali. La loro validità è garantita fino alla chiamata di Release, il che significa che non vengono rilasciate quando viene restituito il metodo nativo.

I dati passati a NewStringUTF devono essere in formato UTF-8 modificato. Un errore comune è leggere i dati dei caratteri da un file o uno stream di rete e passarli a NewStringUTF senza filtrarli. A meno che tu non sappia che i dati sono MUTF-8 validi (o ASCII a 7 bit, che è un sottoinsieme compatibile), devi eliminare i caratteri non validi o convertirli nel formato UTF-8 modificato 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 utilizzare 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 String in modo da utilizzare 8 bit per carattere per le stringhe ASCII (per risparmiare memoria) e ha iniziato a utilizzare un garbage collector in movimento. Queste funzionalità riducono notevolmente il numero di casi in cui ART può fornire un puntatore ai dati String senza creare una copia, anche per GetStringCritical. Tuttavia, se la maggior parte delle stringhe elaborate dal codice sono brevi, nella maggior parte dei casi è possibile evitare l'allocazione e la deallocation utilizzando un buffer allo stack e GetStringRegion o GetStringUTFRegion. Ad esempio:

    constexpr size_t kStackBufferSize = 64u;
    jchar stack_buffer[kStackBufferSize];
    std::unique_ptr 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 l'accesso ai contenuti degli oggetti array. Sebbene sia necessario accedere agli array di oggetti una voce alla volta, le matrici di primitive possono essere lette e scritte direttamente come se fossero dichiarate 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 di allocare una parte di memoria e creare una copia. In ogni caso, il puntatore non elaborato restituito sarà 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 durante la 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 o meno passando un puntatore non NULL per l'argomento isCopy. Questo è raramente utile.

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

  • 0
    • Effettivo: l'oggetto array non è bloccato.
    • Copia: i dati vengono copiati. Il buffer con la copia viene liberato.
  • JNI_COMMIT
    • Effettivo: non fa nulla.
    • Copia: i dati vengono copiati. Il buffer con la copia non viene liberato.
  • JNI_ABORT
    • Effettivo: l'oggetto array non è bloccato. Le scritture precedenti non vengono interrotte.
    • Copia: il buffer con la copia viene liberato; le eventuali modifiche 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 cambi ed esegui codice che utilizza i contenuti dell'array, potresti essere in grado di saltare il commit no-op. Un altro possibile motivo per il controllo del flag è la gestione efficiente di JNI_ABORT. Ad esempio, potresti voler ottenere un array, modificarlo, passare parti ad altre funzioni e quindi ignorare le modifiche. Se sai che JNI sta creando una nuova copia per te, non c'è bisogno di crearne un'altra "modificabile". Se JNI ti trasmette l'originale, devi crearne una tua.

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

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

Chiamate regionali

Esiste un'alternativa a chiamate come Get<Type>ArrayElements e GetStringChars che può essere molto utile quando vuoi solo copiare i dati dentro o fuori. Tieni in considerazione:

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

Questa operazione recupera l'array, copia i primi len elementi di byte da quell'array e poi rilascia l'array. A seconda dell'implementazione, la chiamata Get blocca o copia i contenuti dell'array. Il codice copia i dati (ad esempio, una seconda volta), poi chiama Release; in questo caso JNI_ABORT garantisce che non sia possibile ricevere una terza copia.

La stessa cosa può essere eseguita in modo più semplice:

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

Questa opzione offre diversi vantaggi:

  • Richiede una chiamata JNI anziché due, riducendo l'overhead.
  • Non richiede il blocco o copie di dati aggiuntive.
  • Riduce il rischio di errore del programmatore: non rischi di dimenticarti di chiamare Release dopo che l'operazione non va a buon fine.

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

Eccezioni

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

Le uniche funzioni JNI che puoi chiamare mentre un'eccezione è in sospeso 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 offrono un modo più semplice per verificare l'eventuale presenza di errori. Ad esempio, se NewString restituisce un valore non NULL, non è necessario verificare se è presente un'eccezione. Tuttavia, se chiami un metodo (utilizzando una funzione come CallObjectMethod), devi sempre controllare se è presente un'eccezione, perché il valore restituito non è valido se viene generata un'eccezione.

Tieni presente che le eccezioni generate dal codice gestito non sbloccano gli stack frame nativi. (Inoltre, le eccezioni C++, generalmente sconsigliate su Android, non devono superare il confine di transizione JNI dal codice C++ al codice gestito.) Le istruzioni Throw e ThrowNew di JNI impostano semplicemente un puntatore di eccezione nel thread corrente. Quando torni a gestito da codice nativo, l'eccezione verrà annotata e gestita in modo appropriato.

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

Non ci sono 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;", chiamarlo e, se il risultato non è NULL, utilizza GetStringUTFChars per ottenere qualcosa che puoi inviare a printf(3) o equivalente.

Controllo esteso

JNI effettua pochissimi controlli degli errori. In genere gli errori causano un arresto anomalo. Android offre inoltre una modalità chiamata CheckJNI, in cui i puntatori delle tabelle delle funzioni JavaVM e JNIEnv vengono trasferiti in 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 non validi: passare un jarray/jclass/jobject/jstring non valido a una chiamata JNI o passare un puntatore NULL a una chiamata JNI con un argomento non nullable.
  • Nomi di classi: passare qualsiasi cosa tranne lo stile "java/lang/String" del nome della classe a una chiamata JNI.
  • Chiamate critiche: fare una chiamata JNI tra una pubblicità "critica" e la release corrispondente.
  • Bytebus diretti: passaggio di argomenti non validi a NewDirectByteBuffer.
  • Eccezioni: effettuare una chiamata JNI in attesa di un'eccezione.
  • JNIEnv*s: utilizzo di un JNIEnv* dal thread sbagliato.
  • jfieldIDs: utilizzo di un jfieldID NULL o di un jfieldID per impostare un campo su un valore di tipo errato (ad esempio, un tentativo di assegnare un StringBuilder a un campo String) o un jfieldID per un campo statico per impostare un campo di istanza o viceversa oppure utilizzo di un jfieldID di una classe con istanze di un'altra classe.
  • jmethodIDs: utilizzo del tipo sbagliato di jmethodID quando si effettua una chiamata JNI Call*Method: tipo restituito errato, mancata corrispondenza statico/non statico, tipo errato per "this" (per le chiamate non statiche) o classe errata (per le chiamate statiche).
  • Riferimenti: uso di DeleteGlobalRef/DeleteLocalRef nel tipo di riferimento sbagliato.
  • Modalità di rilascio: passaggio di una modalità di rilascio non valida a una chiamata di rilascio (diversa da 0, JNI_ABORT o JNI_COMMIT).
  • Sicurezza dei tipi: restituzione di un tipo incompatibile dal metodo nativo (ad esempio, la restituzione di un StringBuilder da un metodo dichiarato per restituire una stringa).
  • UTF-8: passare una sequenza di byte Modified UTF-8 non valida a una chiamata JNI.

L'accessibilità di metodi e campi non è ancora selezionata: 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 rooted, puoi utilizzare la seguente sequenza di comandi per riavviare il runtime con CheckJNI abilitato:

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

In entrambi i casi, all'avvio del runtime verrà visualizzato qualcosa di simile nell'output di logcat:

D AndroidRuntime: CheckJNI is ON

Se hai un dispositivo normale, puoi utilizzare il seguente comando:

adb shell setprop debug.checkjni 1

Questa operazione non inciderà sulle app già in esecuzione, ma per qualsiasi app avviata da quel momento in poi verrà attivato CheckJNI. Se modifichi la proprietà impostandone un altro o il semplice riavvio disattiverà di nuovo CheckJNI. In questo caso, all'avvio successivo dell'app, nell'output logcat vedrai un codice simile al seguente:

D Late-enabling CheckJNI

Puoi anche impostare l'attributo android:debuggable nel file manifest della tua applicazione per attivare CheckJNI solo per la tua app. Tieni presente che gli strumenti di creazione di Android eseguono questa operazione automaticamente per determinati tipi di build.

Librerie native

Puoi caricare codice nativo da librerie condivise con System.loadLibrary standard.

In pratica, le versioni precedenti di Android presentavano bug in PackageManager che rendevano inaffidabili 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 classi statico. L'argomento è il nome della libreria "non decorato", quindi per caricare libfubar.so passerai in "fubar".

Se hai una sola classe con metodi nativi, ha senso che la chiamata a System.loadLibrary sia in un inizializzatore statico per quella classe. In caso contrario, potresti voler effettuare la chiamata da Application in modo da sapere che la libreria è sempre caricata e sempre caricata in anticipo.

Esistono due modi in cui il runtime può trovare i metodi nativi. Puoi registrarli esplicitamente con RegisterNatives oppure consentire al runtime di cercarli in modo dinamico con dlsym. Il vantaggio di RegisterNatives consiste nel fatto che puoi controllare in anticipo l'esistenza dei simboli e che puoi avere librerie condivise più piccole e più rapide esportando solo JNI_OnLoad. Il vantaggio di consentire al runtime di rilevare le tue funzioni è che riduce leggermente la quantità di codice da scrivere.

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 -fvisibility=hidden in modo che solo il tuo JNI_OnLoad venga esportato dalla tua libreria. Questo produce un codice più rapido e di dimensioni inferiori ed evita potenziali collisioni con altre librerie caricate nella tua app (ma crea analisi dello stack meno utili se l'app ha un arresto anomalo nel codice nativo).

L'inizializzatore statico dovrebbe avere il seguente aspetto:

Kotlin

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

Java

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

Se scritta in C++, la funzione JNI_OnLoad dovrebbe essere simile alla seguente:

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

Se preferisci utilizzare l'"individuazione" dei metodi nativi, devi assegnare loro un nome specifico (per i dettagli, consulta le specifiche JNI). Ciò significa che se la firma di un metodo non è corretta, non ne avrai informazioni a riguardo fino alla prima volta che il metodo viene effettivamente richiamato.

Qualsiasi chiamata a 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 "system". Il caricatore della classe di sistema non conosce le classi dell'applicazione, quindi non potrai cercare le tue classi con FindClass in questo contesto. Questo rende JNI_OnLoad un posto pratico per cercare e memorizzare le classi nella cache: una volta che hai un riferimento globale jclass valido, puoi utilizzarlo da qualsiasi thread allegato.

Chiamate native più veloci con @FastNative e @CriticalNative

I metodi nativi possono essere annotati con @FastNative o @CriticalNative (ma non con entrambi) per velocizzare le transizioni tra codice gestito e codice nativo. Tuttavia, queste annotazioni presentano alcune modifiche di comportamento che devono essere considerate attentamente prima dell'uso. Ricordiamo brevemente queste modifiche di seguito, ma consulta la documentazione per i dettagli.

L'annotazione @CriticalNative può essere applicata solo ai metodi nativi che non utilizzano oggetti gestiti (in parametri o valori restituiti o come this implicito) e questa annotazione cambia 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 operazioni essenziali e potrebbe venire bloccata. Non utilizzare queste annotazioni per i metodi a lunga esecuzione, inclusi quelli generalmente veloci, ma generalmente non limitati. In particolare, il codice non deve eseguire operazioni di I/O significative o acquisire blocchi nativi che possono essere conservati per molto tempo.

Queste annotazioni sono state implementate per l'utilizzo del sistema da Android 8 e sono state testate dal CTS come API pubblica in Android 14. È probabile che queste ottimizzazioni funzionino anche sui dispositivi Android 8-13 (anche se senza le forti garanzie CTS), ma la ricerca dinamica dei metodi nativi è supportata solo su Android 12 e versioni successive. La registrazione esplicita con JNI RegisterNatives è rigorosamente richiesta per l'esecuzione sulle versioni di Android 8-11. Queste annotazioni vengono ignorate su Android 7- e la mancata corrispondenza delle ABI per @CriticalNative causerebbe marshalling di argomenti errati e probabili arresti anomali.

Per i metodi critici per le prestazioni che richiedono queste annotazioni, consigliamo vivamente di registrare esplicitamente i metodi con JNI RegisterNatives anziché fare affidamento sul "rilevamento" dei metodi nativi basato sui nomi. Per ottenere prestazioni ottimali all'avvio dell'app, ti consigliamo di includere i chiamanti dei metodi @FastNative o @CriticalNative nel profilo di riferimento. A partire da Android 12, una chiamata a un metodo nativo @CriticalNative da un metodo gestito compilato è quasi economica di una chiamata non in linea in C/C++, purché 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 suddividere 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 sul 64 bit

Per supportare le architetture che utilizzano puntatori a 64 bit, usa un campo long anziché un 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à di JNI 1.6, con la seguente eccezione:

  • DefineClass non è implementato. Android non utilizza bytecode o file di classe Java, quindi il trasferimento di dati di classi binarie non funziona.

Per la compatibilità con le versioni precedenti di Android, potrebbe essere necessario tenere presente quanto segue:

  • Ricerca dinamica di funzioni native

    Fino ad Android 2.0 (Eclair), il carattere "$" non è stato convertito correttamente in "_00024" durante le ricerche dei nomi dei metodi. Per ovviare a questo problema, è necessario utilizzare una registrazione esplicita o spostare i metodi nativi dalle 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 "Il thread deve essere scollegato prima dell'uscita". (Il runtime utilizza anche una funzione distruttore della chiave pthread, quindi sarebbe preferibile vedere quale viene chiamata per prima).

  • Riferimenti globali deboli

    Fino ad Android 2.2 (Froyo), non sono stati implementati riferimenti globali deboli. Le versioni precedenti rifiutano con decisione qualsiasi tentativo di utilizzo. Puoi utilizzare le costanti di versione della piattaforma Android per testare l'assistenza.

    Fino ad Android 4.0 (Ice Cream Sandwich), i riferimenti globali deboli potevano essere trasmessi solo a NewLocalRef, NewGlobalRef e DeleteWeakGlobalRef. (la specifica incoraggia vivamente i programmatori a creare riferimenti a elementi globali deboli prima di intraprendere qualsiasi azione, 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 i garbage collector migliori, ma questo significa che molti bug JNI non sono rilevabili nelle release precedenti. Per ulteriori dettagli, consulta le modifiche ai riferimenti locali JNI in ICS.

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

  • Determinazione del tipo di riferimento con GetObjectRefType

    Fino ad Android 4.0 (Ice Cream Sandwich), in seguito all'uso dei puntatori diretti (vedi sopra), era impossibile implementare GetObjectRefType correttamente. Abbiamo invece utilizzato un'euristica che esamina la tabella delle globali deboli, gli argomenti, la tabella delle persone del posto e la tabella delle globali in questo ordine. La prima volta che trovava il puntatore diretto, indicava che il riferimento era del tipo che stava esaminando. Ciò significava, ad esempio, che se hai chiamato GetObjectRefType su una jclass globale che corrispondeva alla jclass passata come argomento implicito al tuo metodo nativo statico, avresti ottenuto JNILocalRefType anziché JNIGlobalRefType.

  • @FastNative e @CriticalNative

    Fino ad Android 7, queste annotazioni di ottimizzazione venivano ignorate. La mancata corrispondenza dell'ABI per @CriticalNative porterebbe a un marshalling di argomenti errati e probabili 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 una registrazione esplicita con JNI RegisterNatives potrebbe causare arresti anomali su Android 8-11.

Domande frequenti: perché ricevo UnsatisfiedLinkError?

Quando si lavora sul codice nativo, non è raro vedere un errore come il seguente:

java.lang.UnsatisfiedLinkError: Library foo not found

In alcuni casi significa che la raccolta non è stata trovata. In altri casi, la libreria esiste, ma non è stato possibile aprirla da dlopen(3). I dettagli dell'errore sono disponibili nel messaggio dettagliato dell'eccezione.

Motivi comuni per cui potresti trovare 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 biblioteca non è stata creata con l'NDK. Ciò può causare dipendenze da funzioni o librerie che non esistono sul dispositivo.

Un'altra classe di UnsatisfiedLinkError errori ha il seguente aspetto:

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 di corrispondenza, ma l'operazione non è riuscita. Ecco alcuni motivi comuni:

  • La libreria non viene caricata. Controlla se nell'output di logcat sono presenti messaggi sul caricamento della libreria.
  • Il metodo non è stato trovato a causa di una mancata corrispondenza del nome o della firma. In genere questo problema è causato da:
    • Per la ricerca con metodo lazy, impossibile dichiarare le funzioni C++ con extern "C" e la visibilità appropriata (JNIEXPORT). Tieni presente che prima di Ice Cream Sandwich la macro JNIEXPORT non era corretta, quindi utilizzare una nuova GCC con una jni.h precedente non funzionerà. Puoi utilizzare arm-eabi-nm per vedere i simboli così come appaiono nella libreria; se sembrano 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.
    • In caso di registrazione esplicita, errori minori durante l'inserimento della firma del metodo. Assicurati che ciò che trasmetti alla chiamata di registrazione corrisponda alla firma nel file di log. Ricorda che "B" è byte e "Z" è boolean. I componenti dei nomi delle classi nelle firme iniziano con "L", terminano con ";", usa "/" per separare i nomi di pacchetti/classi e usa "$" per separare i nomi delle classi interne (ad esempio Ljava/util/Map$Entry;).

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

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

La maggior parte di questi consigli si applica anche agli errori di ricerca dei metodi con GetMethodID o GetStaticMethodID o ai campi con GetFieldID o GetStaticFieldID.

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

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

Se il nome del corso è corretto, è possibile che si sia verificato un problema con il caricamento dei corsi. FindClass vuole avviare la ricerca del corso nel caricatore dei 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 più alto è Foo.myfunc. FindClass trova l'oggetto ClassLoader associato alla classe Foo e lo utilizza.

Di solito fa ciò che vuoi. Puoi avere problemi se crei un thread autonomamente (ad esempio chiamando pthread_create e collegandolo con AttachCurrentThread). Ora non ci sono stack frame nella tua applicazione. Se chiami FindClass da questo thread, JavaVM verrà avviato 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 FindClass una volta in JNI_OnLoad e memorizza nella cache i riferimenti delle classi per utilizzarli in seguito. Qualsiasi chiamata a FindClass effettuata durante l'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 sta caricando la libreria, FindClass utilizzerà il caricatore dei corsi corretto.
  • Passa un'istanza della classe nelle funzioni che ne hanno bisogno dichiarando il tuo metodo nativo per prendere un argomento Class e poi passare Foo.class.
  • Memorizza nella cache un riferimento all'oggetto ClassLoader in un punto qualsiasi ed esegui direttamente le chiamate loadClass. Questa operazione richiede un po' di impegno.

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

Potresti trovarti in una situazione in cui devi accedere a un grande buffer di dati non elaborati sia da codice gestito che da codice nativo. Gli esempi più comuni includono la manipolazione di bitmap o campioni audio. Ci sono due approcci di base.

Puoi archiviare i dati in un byte[]. Ciò consente un accesso molto rapido dal codice gestito. Sul lato nativo, tuttavia, non è garantito che tu possa accedere ai dati senza doverli copiare. In alcune implementazioni, GetByteArrayElements e GetPrimitiveArrayCritical restituiranno puntatori effettivi ai dati non elaborati nell'heap gestito, ma in altre allocano un buffer nell'heap nativo e copiano i dati.

L'alternativa è archiviare i dati in un buffer byte diretto. Questi possono essere creati con java.nio.ByteBuffer.allocateDirect o con la funzione NewDirectByteBuffer JNI. A differenza dei normali buffer di byte, lo spazio di archiviazione non è allocato nell'heap gestito ed è sempre accessibile direttamente dal codice nativo (recupera l'indirizzo con GetDirectBufferAddress). A seconda di come viene implementato l'accesso diretto al buffer del 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 passati a un'API di sistema, in quale formato devono essere? Ad esempio, se i dati vengono passati a una funzione che prende un byte[], l'elaborazione in un ByteBuffer diretto potrebbe non essere saggio.

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