Suggerimenti per JNI

JNI è l'interfaccia nativa Java. Definisce un modo per il bytecode compilato da Android a partire 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 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 presenti in questa pagina sia ai linguaggi di programmazione Kotlin che a Java in termini di architettura JNI e relativi costi. Per scoprire di più, consulta Kotlin e Android.

Se non la conosci, leggi le specifiche della Java Native Interface Specification per avere un'idea di come funziona JNI e delle funzionalità disponibili. Alcuni aspetti dell'interfaccia non sono immediatamente evidenti alla prima lettura, pertanto potresti trovare a portata di mano 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

Cerca di ridurre al minimo l'impatto ambientale del livello JNI. In questo caso, ci sono diverse dimensioni da considerare. La tua soluzione JNI deve seguire queste linee guida (elencate di seguito in ordine di importanza, a partire 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 minimizzi la quantità di dati necessari per il marshalling e la frequenza con cui eseguirlo.
  • 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 la tua interfaccia JNI sarà più facile da gestire. In genere, puoi 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++ che blocca e quindi invia una notifica al thread dell'interfaccia utente quando la chiamata di blocco è completata.
  • Riduci al minimo il numero di thread che devono essere toccati o toccati da JNI. Se devi 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. Valuta la possibilità di utilizzare 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 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 una sola.

JNIEnv fornisce la maggior parte delle funzioni JNI. Tutte le tue funzioni native ricevono un JNIEnv come primo argomento, tranne che per i metodi @CriticalNative, vedi chiamate native più veloci.

JNIEnv viene utilizzato per l'archiviazione locale dei thread. Per questo motivo, non puoi condividere un file JNIEnv tra thread. Se una porzione di codice non ha un altro modo per ottenere la sua JNIEnv, devi condividere la JavaVM e usare GetEnv per scoprire la JNIEnv del thread. (Supponendo che ne abbia uno; vedi AttachCurrentThread sotto.)

Le dichiarazioni C di JNIEnv e JavaVM sono diverse dalle dichiarazioni C++. Il file di inclusione "jni.h" fornisce typedef diversi 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 entrambe le lingue. In altri termini, se il file di intestazione richiede #ifdef __cplusplus, potresti dover svolgere un po' di lavoro extra se l'intestazione fa riferimento a JNIEnv.

Thread

Tutti i thread sono thread Linux, pianificati dal kernel. Di solito vengono 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 usare Thread.start() per creare thread che devono effettuare una chiamata al codice Java. In questo modo ti assicuri di avere spazio sufficiente per lo stack, di essere nel file 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 comporta la creazione e l'aggiunta di un oggetto java.lang.Thread al ThreadGroup "principale", rendendolo visibile al debugger. Chiamare AttachCurrentThread() in un thread già allegato è un gioco da ragazzi.

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 volta successiva che effettuerà una chiamata JNI.

I thread collegati tramite JNI devono chiamare DetachCurrentThread() prima di uscire. Se programmare questa procedura direttamente è complicato, 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 questo modo verrà passata al tuo distruttore come argomento.)

jclass, jmethodID e jfieldID

Per accedere al campo di un oggetto dal codice nativo:

  • Recupera il riferimento dell'oggetto di classe per la classe con FindClass
  • Recupera l'ID campo per il campo con GetFieldID
  • Ottieni i contenuti 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 puntatori alle strutture di dati del runtime interno. La ricerca potrebbe richiedere diversi confronti di stringhe, ma una volta ottenuta la chiamata per ottenere il campo o richiamare il metodo è molto veloce.

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 gli ID nella cache al caricamento di una classe e memorizzarli automaticamente nella cache se la classe viene scaricata e ricaricata, il modo corretto per inizializzare gli ID consiste nell'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 di ID. Il codice verrà eseguito una sola volta, quando la classe viene inizializzata. Se la classe viene scaricata e ricaricata, verrà eseguita di nuovo.

Riferimenti locali e globali

Ogni argomento passato a un metodo nativo e quasi tutti gli oggetti restituiti da una funzione JNI sono un "riferimento locale". Ciò significa che è valido per la durata del metodo nativo attuale nel thread corrente. Anche se l'oggetto stesso continua a essere attivo dopo che il metodo nativo è stato restituito, il riferimento non è valido.

Questo vale per tutte le sottoclassi di jobject, tra cui jclass, jstring e jarray. (Il runtime ti avvisa della maggior parte degli usi impropri dei riferimenti quando sono abilitati i 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 restituisce uno globale. La validità del riferimento globale è garantita fino a quando non chiami DeleteGlobalRef.

Questo pattern viene comunemente usato per 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. È possibile che i riferimenti allo stesso oggetto abbiano valori diversi. Ad esempio, i valori restituiti di 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 è che non devi presumere che i riferimenti agli oggetti siano costanti o univoci nel codice nativo. Il valore che rappresenta un oggetto può essere diverso da una chiamata di un metodo a quella successiva ed è possibile che due oggetti diversi possano avere lo stesso valore in chiamate consecutive. Non utilizzare i valori jobject come chiavi.

I programmatori sono tenuti a "non allocare in modo eccessivo" i riferimenti locali. In termini pratici, questo significa che se stai creando un numero elevato di riferimenti locali, magari mentre scorri un array di oggetti, dovresti 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; pertanto, se te ne servono più, devi eliminarli man mano che procedi o utilizzare EnsureLocalCapacity/PushLocalFrame per prenotarne altri.

Tieni presente che gli elementi jfieldID e jmethodID sono di tipi opachi, non riferimenti a oggetti, e non devono essere passati 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 release corrispondente.

Un caso insolito merita una menzione separata. Se colleghi un thread nativo con AttachCurrentThread, il codice in esecuzione non svincolerà mai automaticamente i riferimenti locali finché il thread non si scollega. Eventuali riferimenti locali creati dovranno essere eliminati manualmente. In generale, è probabile che qualsiasi codice nativo che crei riferimenti locali in un loop debba eseguire l'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 scorretti della memoria difficili da diagnosticare. A parità di altri fattori, probabilmente una soluzione con meno riferimenti globali è la soluzione 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 stringa 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 String, utilizza GetStringChars. Tieni presente che le stringhe UTF-16 non hanno terminazione zero e \u0000 è consentito, quindi devi dipendere dalla lunghezza della stringa e dal puntatore jchar.

Non dimenticare di Release le stringhe che Get. Le funzioni stringa restituiscono jchar* o jbyte*, che sono puntatori di stile C ai dati primitivi anziché a 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 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 appropriato. In caso contrario, è probabile che la conversione UTF-16 generi risultati imprevisti. CheckJNI, attivo per impostazione predefinita per gli emulatori, scansiona le stringhe e interrompe la VM se riceve input non validi.

Prima di Android 8, di solito era più veloce usare 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 è breve, 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 le 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 limitare l'implementazione della VM, la famiglia di chiamate Get<PrimitiveType>ArrayElements consente al runtime di restituire un puntatore agli elementi effettivi oppure di allocare una parte di memoria e creare una copia. In ogni caso, la validità del puntatore non elaborato restituito è garantita fino all'emissione della chiamata Release corrispondente, il che implica che, se i dati non sono stati copiati, l'oggetto dell'array verrà bloccato e non potrà essere spostato come parte 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 o meno passando un puntatore non NULL per l'argomento isCopy. Questo è raramente utile.

La chiamata Release richiede un argomento mode che può avere uno di tre valori. Le azioni eseguite dal runtime dipendono dalla restituzione di un puntatore ai dati effettivi o a 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; se si alternano 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 è per 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 "modificabile". Se JNI passa l'originale, devi crearne una copia personalizzata.

È un errore comune (ripetuto nel codice di esempio) presumere che tu possa saltare la chiamata Release se *isCopy è falso. Non è così. Se non è stato allocato alcun buffer della 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 a livello di regione

Esiste un'alternativa a chiamate come Get<Type>ArrayElements e GetStringChars, che può essere molto utile quando vuoi soltanto copiare i 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, copia i primi len elementi di byte dall'interno e rilascia l'array. A seconda dell'implementazione, la chiamata Get blocca o copia i contenuti dell'array. Il codice copia i dati (forse per la seconda volta) e poi chiama Release; in questo caso JNI_ABORT garantisce che non venga inviata una terza copia.

Puoi eseguire la stessa operazione in modo più semplice:

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

Questo comporta diversi vantaggi:

  • Richiede una chiamata JNI invece di 2, riducendo l'overhead.
  • Non richiede blocco o copie dei dati aggiuntive.
  • Riduce il rischio di errore del programmatore, senza rischiare di dimenticare di chiamare Release in caso di errore.

Analogamente, puoi utilizzare la chiamata Set<Type>ArrayRegion per copiare 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 c'è un'eccezione in sospeso. Il codice dovrebbe rilevare l'eccezione (tramite il valore restituito della funzione ExceptionCheck o ExceptionOccurred) e restituire oppure cancellare l'eccezione e gestirla.

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

Tieni presente che le eccezioni generate dal codice gestito non annullano i frame dello stack nativi. (Inoltre, le eccezioni C++, generalmente sconsigliate su Android, non devono essere limitate al limite di transizione JNI dal codice C++ al codice gestito.) Le istruzioni Throw e ThrowNew per JNI impostano un puntatore di eccezione nel thread corrente. Quando torni a gestito dal codice nativo, l'eccezione viene annotata e gestita in modo appropriato.

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

Non ci sono funzioni integrate per manipolare l'oggetto Throwable in sé, 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, usa GetStringUTFChars per ottenere qualcosa che puoi inviare a printf(3) o equivalente.

Verifica estesa

JNI esegue un controllo degli errori minimo. Generalmente gli errori causano un arresto anomalo. Android offre anche una modalità chiamata CheckJNI, in cui i puntatori della tabella delle funzioni JavaVM e JNIEnv vengono spostati 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 errati: 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: effettuare una chiamata JNI tra un get "critico" e una release corrispondente.
  • ByteBuffers diretto: passaggio di argomenti non validi a NewDirectByteBuffer.
  • Eccezioni: effettuare una chiamata JNI mentre è in sospeso un'eccezione.
  • JNIEnv*s: utilizzo di un JNIEnv* dal thread sbagliato.
  • jfieldID: utilizzo di un jfieldID NULL o di un jfieldID per impostare un campo su un valore del tipo sbagliato (ad esempio per tentare di assegnare un StringBuilder a un campo String) o utilizzo di 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 statica/non statica, tipo errato per "questo" (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 valida a una chiamata di rilascio (a un valore diverso da 0, JNI_ABORT o JNI_COMMIT).
  • Sicurezza del tipo: restituisce un tipo incompatibile dal tuo metodo nativo (restituendo un StringBuilder da un metodo dichiarato per restituire una stringa, ad esempio).
  • UTF-8: passare una sequenza di byte Modified UTF-8 non valida a una chiamata JNI.

L'accessibilità di metodi e campi non è ancora stata selezionata: le limitazioni di accesso non vengono applicate al codice nativo.

Esistono diversi modi per abilitare 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 al seguente:

D AndroidRuntime: CheckJNI is ON

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

adb shell setprop debug.checkjni 1

Questa operazione non interessa le app già in esecuzione, ma per tutte le app avviate da quel momento in poi CheckJNI sarà abilitato. Modifica la proprietà impostandola su un altro valore o il semplice riavvio disabiliterà di nuovo CheckJNI.) In questo caso, all'avvio successivo di un'app, nell'output logcat verrà visualizzato un risultato 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 build Android eseguono questa operazione automaticamente per determinati tipi di build.

Librerie native

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

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 per altri problemi di caricamento delle librerie native.

Richiama System.loadLibrary (o ReLinker.loadLibrary) da un inizializzatore di classi statico. L'argomento è il nome della libreria "undecorated", quindi per caricare libfubar.so devi passare 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 è che puoi verificare fin dall'inizio l'esistenza dei simboli. In più, puoi disporre di librerie condivise più piccole e veloci perché puoi esportare solo JNI_OnLoad. Il vantaggio di far scoprire le tue funzioni al runtime è che la quantità di codice da scrivere è leggermente inferiore.

Per usare RegisterNatives:

  • Specifica 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 per esportare solo il tuo JNI_OnLoad dalla raccolta. Questo produce codice più veloce e più piccolo ed evita potenziali collisioni con altre librerie caricate nella tua app (ma crea analisi dello stack meno utili se la tua app ha un arresto anomalo nel codice nativo).

L'inizializzatore statico dovrebbe essere simile al seguente:

Kotlin

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

Java

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

Se scritta in C++, la funzione JNI_OnLoad dovrebbe avere il seguente aspetto:

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 invece vuoi utilizzare la "scoperta" 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 è errata, non ne sarai 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 in cima allo stack di 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 conosce le classi della tua applicazione, quindi non potrai cercare le tue classi con FindClass in quel contesto. Questo rende JNI_OnLoad un posto pratico 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ù veloci con @FastNative e @CriticalNative

Puoi annotare i metodi nativi con @FastNative o @CriticalNative (ma non con entrambi) per velocizzare le transizioni tra codice gestito e nativo. Tuttavia, queste annotazioni comportano alcuni cambiamenti di comportamento che devono essere considerati attentamente prima dell'uso. Anche se di seguito menzioniamo brevemente queste modifiche, ti invitiamo a fare riferimento alla 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 oppure come this implicito) e questa annotazione modifica l'ABI di transizione JNI. L'implementazione nativa deve escludere i parametri JNIEnv e jclass dalla sua firma della funzione.

Durante l'esecuzione di un metodo @FastNative o @CriticalNative, la garbage collection non può sospendere il thread per attività essenziali e potrebbe essere bloccata. Non utilizzare queste annotazioni per i metodi a lunga esecuzione, 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'uso del 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 solide garanzie CTS), ma la ricerca dinamica dei metodi nativi è supportata solo su Android 12 e versioni successive. È severamente richiesta la registrazione esplicita con JNI RegisterNatives per l'esecuzione su Android 8-11. Queste annotazioni vengono ignorate su Android 7. La mancata corrispondenza delle ABI per @CriticalNative porterebbe a un marshalling di argomenti sbagliato e a probabili arresti anomali.

Per i metodi critici per le prestazioni che richiedono queste annotazioni, è vivamente consigliato 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, 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 non riuscito 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 sui 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

Tutte le funzionalità di JNI 1.6 sono supportate, con la seguente eccezione:

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

Per la compatibilità con le versioni precedenti di release di Android, potresti dover conoscere:

  • Ricerca dinamica di funzioni native

    Fino ad Android 2.0 (Eclair), il carattere "$" non è stato convertito correttamente in "_00024" durante la ricerca dei nomi dei metodi. Per risolvere 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 di distruzione della chiave pthread, quindi sarebbe una gara per vedere chi viene chiamato per primo.

  • Riferimenti globali deboli

    Fino ad Android 2.2 (Froyo), i riferimenti globali deboli non sono stati implementati. Le versioni precedenti rifiuteranno 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 a livello globale deboli prima di fare qualsiasi cosa, quindi questo non dovrebbe essere affatto limitato.)

    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 maggiori dettagli, consulta Modifiche ai riferimenti locali di 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.

  • Determinazione del tipo di riferimento con GetObjectRefType

    Fino ad Android 4.0 (Ice Cream Sandwich), in seguito all'utilizzo dei puntatori diretti (vedi sopra), era impossibile implementare correttamente GetObjectRefType. Abbiamo invece utilizzato un'euristica che ha esaminato la tabella dei globali deboli, gli argomenti, la tabella dei dati locali e la tabella dei globali in questo ordine. La prima volta che trovava il puntatore diretto, veniva indicato 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 ricevuto JNILocalRefType anziché JNIGlobalRefType.

  • @FastNative e @CriticalNative

    Fino ad Android 7, queste annotazioni di ottimizzazione venivano ignorate. La mancata corrispondenza dell'ABI per @CriticalNative causerebbe il 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.

  • 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 riflessione Class.forName(name).

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 quello che dice: la raccolta non è stata trovata. In altri casi, la libreria esiste, ma dlopen(3) non può aprirla. I dettagli dell'errore sono disponibili nel messaggio dettagliato dell'eccezione.

Motivi comuni per cui potresti riscontrare eccezioni "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ò determinare dipendenze per funzioni o librerie che non esistono sul dispositivo.

Un'altra classe di errori UnsatisfiedLinkError 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 l'output di logcat per verificare la presenza di 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, pertanto l'utilizzo di un nuovo GCC con una versione precedente di jni.h non funzionerà. Puoi usare arm-eabi-nm per vedere i simboli 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 i dati che passi alla chiamata di registrazione corrispondano alla firma nel file di log. Ricorda che "B" corrisponde a byte e "Z" corrisponde a 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 individuazione 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 e includere anche "L" e ";" per la classe; di conseguenza, un array unidimensionale di String sarà [Ljava/lang/String;. Se stai cercando una classe interna, usa "$" invece di ".". In generale, l'uso di javap nel file .class è un buon modo per scoprire 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 corrette è importante perché lo strumento di riduzione del codice potrebbe altrimenti rimuovere classi, metodi o campi utilizzati solo da JNI.

Se il nome della classe è corretto, è possibile che si sia verificato un problema con il caricatore di classi. FindClass vuole avviare la ricerca dei corsi nel caricatore dei corsi associato al tuo codice. Esamina lo stack di chiamate, che sarà simile a questo:

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

Il metodo più in alto è Foo.myfunc. FindClass trova l'oggetto ClassLoader associato alla classe Foo e lo utilizza.

Di solito fa quello che vuoi. Potresti avere problemi se crei un thread autonomamente (ad esempio chiamando pthread_create e collegandolo poi con AttachCurrentThread). Ora la tua applicazione non contiene stack frame. 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 riusciranno.

Esistono alcuni modi per risolvere il problema:

  • Esegui una ricerca 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 semplificare l'inizializzazione della libreria). Se il codice dell'app sta caricando la raccolta, FindClass utilizzerà il caricatore di 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 pratico ed esegui direttamente le chiamate loadClass. Questa operazione richiede un certo impegno.

Domande frequenti: come faccio a condividere dati non elaborati con 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 hai la garanzia di poter 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 allocheranno un buffer nell'heap nativo e copiano i dati.

L'alternativa è memorizzare i dati in un buffer di byte diretto. 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 e puoi sempre accedervi direttamente dal codice nativo (ricevi 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ò richiedere molto tempo.

La scelta di quale usare 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 richiede un byte[], l'elaborazione in un ByteBuffer diretto potrebbe non funzionare.

Se il risultato è evidente, utilizza un buffer di byte diretto. Il supporto è integrato direttamente in JNI e le prestazioni dovrebbero migliorare nelle release future.