Suggerimenti per JNI

JNI è l'interfaccia nativa Java. Definisce un modo per il bytecode compilato da Android a partire da codice gestito (scritto nei linguaggi di programmazione Java o Kotlin) di interagire con il codice nativo (scritto in C/C++). JNI non ha alcun fornitore, supporta il caricamento di codice da librerie condivise dinamiche e, anche se 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 ai linguaggi di programmazione Kotlin e Java in termini di architettura JNI e relativi costi. Per scoprire di più, visita Kotlin e Android.

Se non lo conosci già, leggi le specifiche di Java Native Interface per capire come funziona JNI e quali funzionalità sono 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 nel Memory Profiler in Android Studio 3.2 e versioni successive.

Suggerimenti generali

Cerca di ridurre al minimo l'impatto del livello JNI. Ci sono diverse dimensioni da considerare in questa sede. La tua soluzione JNI deve seguire queste linee guida (elencate di seguito in ordine di importanza, partendo dal 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 riduca al minimo la quantità di dati da sottoporre a marshall e la frequenza con cui eseguire il marshalling dei dati.
  • Evita la comunicazione asincrona tra codice scritto in un linguaggio di programmazione gestito e codice scritto in C++, se possibile. In questo modo, l'interfaccia JNI sarà più facile da gestire. 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 dei quali effettua una chiamata a C++ di blocco e notifica al thread dell'interfaccia utente quando la chiamata di blocco è completa.
  • Riduci al minimo il numero di thread 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.
  • Conserva 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 dei puntatori a puntatori alle tabelle di funzione. (Nella versione C++, sono classi con un puntatore a una tabella di funzione 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 JNIEnv tra thread. Se una porzione di codice non ha altro modo per ottenere la sua JNIEnv, devi condividere la JavaVM e usare GetEnv per scoprire JNIEnv del thread. (presupponendo 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 valori di tipo diversi a seconda che sia incluso in C o C++. Per questo motivo non è consigliabile includere gli argomenti JNIEnv nei file di intestazione inclusi da entrambe le lingue. In altri termini, se il file di intestazione richiede #ifdef __cplusplus, potresti dover svolgere del lavoro in più se nell'intestazione ci sono riferimenti a JNIEnv.

Thread

Tutti i thread sono thread Linux, pianificati dal kernel. In genere sono iniziate da 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(). Finché un thread non è allegato, non ha JNIEnv e non può effettuare chiamate JNI.

In genere è preferibile utilizzare Thread.start() per creare thread che devono chiamare codice Java. In questo modo ti assicurerai di avere spazio sufficiente sullo stack, di avere il 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é nel 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 causa la creazione e l'aggiunta di un oggetto java.lang.Thread al ThreadGroup "principale", rendendolo visibile al debugger. Chiamare AttachCurrentThread() su un thread già collegato è un'operazione autonoma.

Android non sospende i thread che eseguono codice nativo. Se la garbage collection è in corso o 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 la programmazione è complessa, in Android 2.0 (Eclair) e versioni successive puoi usare 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 questo modo verrà passato al tuo distruttore come argomento.

jclass, jmethodID e jfieldID

Per accedere al campo di un oggetto dal codice nativo, procedi nel seguente modo:

  • Ottieni il riferimento all'oggetto di classe per la classe con FindClass
  • Ottieni l'ID campo per il campo con GetFieldID
  • Ottieni il contenuto del campo con qualcosa di appropriato, come GetIntField

Allo stesso modo, per chiamare un metodo, ottieni prima un riferimento all'oggetto di classe e poi un ID metodo. Gli ID sono spesso solo indicatori di strutture di dati di runtime interne. La 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 alla classe, gli ID campo e gli ID metodo sono garantiti e validi fino all'unload della classe. L'unload delle classi viene eseguito solo se tutte le classi associate a un ClassLoader possono essere garbage collection, il che è raro, ma non sarà 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 nella cache gli ID quando viene caricata una classe e memorizzarli automaticamente se la classe viene scaricata e ricaricata, il modo corretto per inizializzare gli ID consiste nell'aggiungere nella classe appropriata una porzione di codice simile a questa:

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 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 essere attivo dopo che il metodo nativo viene restituito, il riferimento non è valido.

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

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 ne 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 i 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 vedere se due riferimenti fanno riferimento allo stesso oggetto, devi usare 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 termini pratici, ciò significa che se stai creando un numero elevato di riferimenti locali, ad esempio mentre esegui l'analisi 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 di tipo opaco, non riferimenti a oggetti, e non devono essere trasmessi a NewGlobalRef. Anche i puntatori di dati non elaborati restituiti da funzioni come GetStringUTFChars e GetByteArrayElements non sono oggetti. Possono essere trasmessi da un thread all'altro e sono validi fino alla chiamata di rilascio corrispondente.

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

Fai attenzione quando utilizzi i riferimenti globali. I riferimenti globali sono inevitabili, ma sono difficili da eseguire il debug e possono causare comportamenti impropri della memoria. 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 metodi che funzionano anche 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. L'aspetto 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 terminazioni zero e \u0000 è consentito, quindi devi rimanere a lungo alla lunghezza della stringa e al puntatore jchar.

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

I dati passati a NewStringUTF devono essere nel formato UTF-8 modificato. Un errore comune è leggere i dati sui 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 appropriato. In caso contrario, è probabile che la conversione UTF-16 generi 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, di solito era più veloce utilizzare le stringhe UTF-16 poiché 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 di 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. Ecco alcuni esempi:

    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 accedere ai contenuti degli oggetti array. Mentre è necessario accedere agli array di oggetti una voce alla volta, gli array di primitivi 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 di allocare una parte di memoria e creare una copia. In ogni caso, la validità del puntatore non elaborata restituito è garantita 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 riposizionato 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 provi a 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 di tre valori. Le azioni eseguite dal runtime dipendono dal fatto che sia stato restituito un puntatore ai dati effettivi o una copia dei dati:

  • 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; tutte le 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. In caso di alternanza tra modifiche ed esecuzione di codice che utilizza i contenuti dell'array, potresti essere in grado di saltare il commit no-op. Un altro motivo possibile per controllare il flag è una 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. 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 regione

Esiste un'alternativa alle 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);
    }

In questo modo recupera l'array, copia i primi elementi da len byte e rilascia l'array. A seconda dell'implementazione, la chiamata Get bloccherà o copierà i contenuti dell'array. Il codice copia i dati (forse una seconda volta) e poi chiama Release; in questo caso JNI_ABORT garantisce che non si rischia una terza copia.

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

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

Ciò offre diversi vantaggi:

  • Richiede una chiamata JNI anziché due, riducendo l'overhead.
  • Non richiede il blocco o l'aggiunta di copie dei dati aggiuntive.
  • Riduce il rischio di errore del programmatore, senza rischiare di dimenticare di chiamare Release dopo che qualcosa non funziona.

Allo stesso modo, puoi utilizzare la chiamata Set<Type>ArrayRegion per copiare i dati in un array e GetStringRegion o GetStringUTFRegion per copiare caratteri da un 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 restituirla, oppure cancellare l'eccezione e gestirla.

Le uniche funzioni JNI che puoi chiamare mentre un'eccezione è in attesa 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 gli errori. Ad esempio, se NewString restituisce un valore non NULL, non è necessario controllare se è presente un'eccezione. Tuttavia, se chiami un metodo (utilizzando una funzione come CallObjectMethod), devi sempre controllare se è presente un'eccezione, poiché 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 limite di transizione JNI dal codice C++ al codice gestito). Le istruzioni Throw e ThrowNew di JNI impostano solo un puntatore di eccezione nel thread corrente. Quando torni alla modalità gestita da codice nativo, l'eccezione viene annotata e gestita in modo appropriato.

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

Non ci sono funzioni integrate per modificare 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, utilizza GetStringUTFChars per ottenere qualcosa che puoi passare a printf(3) o equivalente.

Controllo esteso

JNI esegue pochissimi controlli di errori. In genere gli errori causano un arresto anomalo. Android offre anche una modalità chiamata CheckJNI, in cui i puntatori delle tabelle delle funzioni JavaVM e JNIEnv vengono commutati 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: passaggio di un jarray/jclass/jobject/jstring errato a una chiamata JNI o un puntatore NULL a una chiamata JNI con un argomento che non supporta valori null.
  • Nomi di classi: passaggio a una chiamata JNI di qualsiasi cosa tranne lo stile "java/lang/String" del nome della classe.
  • Chiamate critiche: effettuare una chiamata JNI tra una richiesta "critica" e una release corrispondente.
  • ByteBuffers diretti: trasmissione di argomenti non validi a NewDirectByteBuffer.
  • Eccezioni: effettuare una chiamata JNI in attesa di un'eccezione.
  • JNIEnv*s: utilizzo di JNIEnv* dal thread sbagliato.
  • jfieldIDs: utilizzo di un jfieldID NULL o jfieldID per impostare un campo su un valore di tipo errato (ad esempio per assegnare un StringBuilder a un campo String), oppure 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 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: utilizzo di DeleteGlobalRef/DeleteLocalRef con il tipo di riferimento sbagliato.
  • Modalità di rilascio: passaggio di una modalità di rilascio non corretta a una chiamata di rilascio (diversa da 0, JNI_ABORT o JNI_COMMIT).
  • Sicurezza dei tipi: restituzione di un tipo incompatibile dal metodo nativo (restituzione di un StringBuilder da un metodo dichiarato per restituire una stringa, ad esempio).
  • UTF-8: passaggio di una sequenza di byte Modified UTF-8 non valida a una chiamata JNI.

L'accessibilità dei metodi e dei campi non è ancora selezionata: le limitazioni di accesso non vengono applicate 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, nell'output logcat verrà visualizzato qualcosa di simile:

D AndroidRuntime: CheckJNI is ON

Se disponi di un dispositivo standard, puoi utilizzare il seguente comando:

adb shell setprop debug.checkjni 1

Questa operazione non inciderà sulle app già in esecuzione, ma per tutte le app avviate da quel momento in poi sarà abilitato CheckJNI. Modifica la proprietà impostandola su un altro valore o il semplice riavvio disattiverà di nuovo CheckJNI. In questo caso, all'avvio successivo dell'app verrà visualizzato qualcosa di simile nell'output logcat:

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 creazione di Android eseguono questa operazione automaticamente per determinati tipi di build.

Librerie native

Puoi caricare codice nativo da librerie condivise con il metodo 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 "undecorated", 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. Altrimenti, potresti voler effettuare la chiamata da Application, in modo da sapere che la libreria è sempre caricata e sempre caricata in anticipo.

Il runtime può trovare i metodi nativi in due modi. Puoi registrarli esplicitamente con RegisterNatives oppure lasciare che il runtime le cerchi in modo dinamico con dlsym. Il vantaggio di RegisterNatives consiste nel fatto che puoi verificare in anticipo l'esistenza dei simboli e poter disporre di librerie condivise più piccole e più veloci, senza l'esportazione di elementi diversi da JNI_OnLoad. Il vantaggio di permettere al runtime di scoprire le tue funzioni è che c'è poco meno 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 usando RegisterNatives.
  • Crea con -fvisibility=hidden per esportare solo il tuo JNI_OnLoad dalla libreria. Questo produce codice più veloce e di dimensioni inferiori ed evita potenziali collisioni con altre librerie caricate nell'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;
}

Per utilizzare invece la "scoperta" dei metodi nativi, devi assegnare loro un nome specifico (consulta la specifica JNI per maggiori dettagli). Ciò significa che se la firma di un metodo non è corretta, non potrai esserne a conoscenza fino alla prima volta in cui 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 delle classi di sistema non è a conoscenza delle classi dell'applicazione, quindi non potrai cercare le tue classi con FindClass in quel contesto. In questo modo JNI_OnLoad è una soluzione pratica per cercare e memorizzare nella cache le classi: una volta che hai un riferimento globale jclass valido, puoi utilizzarlo da qualsiasi thread allegato.

Chiamate native più rapide con @FastNative e @CriticalNative

I metodi nativi possono essere annotati con @FastNative o @CriticalNative (ma non con entrambi) per velocizzare le transizioni tra il codice gestito e quello nativo. Tuttavia, queste annotazioni comportano alcuni cambiamenti di comportamento che devono essere considerati attentamente prima dell'uso. Di seguito vengono menzionate brevemente queste modifiche, ma per informazioni dettagliate consulta la documentazione.

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 lavori essenziali e potrebbe essere bloccata. Non utilizzare queste annotazioni per metodi di lunga durata, inclusi quelli solitamente veloci, ma generalmente illimitati. 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 nel sistema da Android 8 e sono diventate API pubbliche testate da CTS in Android 14. È probabile che queste ottimizzazioni funzionino anche sui dispositivi Android 8-13 (sebbene 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 è strettamente necessaria per l'esecuzione su Android 8-11. Queste annotazioni vengono ignorate su Android 7-; la mancata corrispondenza dell'ABI per @CriticalNative porterebbe a marshalling di argomenti errati e probabili arresti anomali.

Per i metodi critici per le prestazioni che richiedono queste annotazioni, ti consigliamo vivamente di registrare esplicitamente i metodi con JNI RegisterNatives anziché fare affidamento sulla "scoperta" dei metodi nativi basata sui nomi. Per ottenere prestazioni ottimali all'avvio dell'app, è consigliabile 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 è economica quasi quanto 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, uno molto veloce che può non riuscire e un altro che gestisce i casi lenti. Ecco alcuni esempi:

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 formato 64 bit

Per supportare 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 passaggio di dati di classi binarie non funziona.

Per la compatibilità con le versioni precedenti di Android, potresti dover 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 di nomi di 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 è stato possibile utilizzare una funzione di distruzione 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 una sfida 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 i tentativi 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 rigidi a 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'indiretto necessario per supportare i garbage collector migliori, ma questo significa che molti bug JNI non sono rilevabili nelle release precedenti. Per maggiori dettagli, consulta le modifiche al riferimento locale di 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 dalla versione 8.0 di Android, 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. Al contrario, abbiamo utilizzato un'euristica che esaminava la tabella dei valori globali deboli, gli argomenti, la tabella delle risorse locali e la tabella dei valori globali in questo ordine. La prima volta che trovava il tuo puntatore diretto, veniva segnalato che il riferimento era del tipo che stava esaminando. Ciò significa che, ad esempio, 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 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 lavori 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 riscontrare eccezioni di tipo "libreria non trovata":

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

Un'altra classe di errori UnsatisfiedLinkError è simile al seguente:

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 non è riuscito. Ecco alcuni motivi comuni:

  • La libreria non viene caricata. Controlla l'output del logcat per verificare se ci sono messaggi sul caricamento della libreria.
  • Impossibile trovare il metodo 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 era errata, pertanto l'utilizzo di una nuova GCC con una versione precedente di jni.h non funzionerà. Puoi usare arm-eabi-nm per vedere i simboli così come appaiono nella libreria; se sembrano maneggiati (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 passi 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ò aiutarti a evitare alcuni problemi.

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

La maggior parte di questi consigli si applica anche a 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 array, devi iniziare con il numero appropriato di parentesi quadre. Inoltre, devi racchiudere la classe tra "L" e ";", quindi un array unidimensionale di String è [Ljava/lang/String;. Se cerchi una classe interna, utilizza "$" invece di ".". In generale, utilizzare javap nel file .class è un buon modo per trovare il nome interno della tua classe.

Se attivi 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 della classe è corretto, è possibile che si sia verificato un problema con il caricamento delle classi. FindClass vuole avviare la ricerca del corso nel caricatore dei corsi associato al tuo codice. Esamina lo stack di chiamate, che sarà simile al seguente:

    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.

In genere questa è la tua preferenza. Puoi riscontrare problemi se crei un thread autonomamente (ad esempio chiamando pthread_create e collegandolo con AttachCurrentThread). Ora la tua applicazione non contiene stack frame. Se chiami FindClass da questo thread, 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 riusciranno.

Esistono alcuni modi per ovviare a questo problema:

  • Esegui le tue ricerche FindClass una volta, in JNI_OnLoad e memorizza nella cache i riferimenti di classe per utilizzarli in seguito. Qualsiasi chiamata 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 di classi corretto.
  • Passa un'istanza della classe nelle funzioni che ne hanno bisogno, dichiarando il tuo metodo nativo in modo che acquisisca un argomento Class e poi passi Foo.class.
  • Memorizza nella cache un riferimento all'oggetto ClassLoader in un punto pratico ed esegui direttamente chiamate loadClass. Ciò richiede un po' di impegno.

Domande frequenti: come faccio a condividere 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 comuni includono la manipolazione di bitmap o campioni audio. Esistono due approcci di base.

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

L'alternativa è archiviare i dati in un buffer di byte diretti. Queste possono essere create con java.nio.ByteBuffer.allocateDirect o 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 (ottieni l'indirizzo con GetDirectBufferAddress). A seconda di come viene implementato l'accesso diretto al 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 passati a un'API di sistema, quale formato devono essere? Ad esempio, se i dati vengono passati a una funzione che richiede 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 loro supporto è integrato direttamente in JNI e le prestazioni dovrebbero migliorare nelle release future.