Dicas de JNI

JNI é a sigla para Java Native Interface. Essa interface define um caminho para o bytecode que o Android compila a partir do código gerenciado (escrito nas linguagens de programação Java ou Kotlin) para interagir com o código nativo (escrito em C/C++). A JNI é independente de fornecedores, permite o carregamento de código usando bibliotecas compartilhadas dinâmicas e, embora, às vezes, seja pesada, é razoavelmente eficiente.

Observação: como o Android compila Kotlin para bytecode compatível com ART de uma maneira semelhante à linguagem de programação Java, você pode aplicar as orientações desta página às linguagens de programação Kotlin e Java em termos de arquitetura JNI e custos associados. Para saber mais, consulte Kotlin e Android.

Se você ainda não está familiarizado com ela, leia a Especificação da Java Native Interface (link em inglês) para ter uma noção de como a JNI funciona e quais recursos estão disponíveis. Alguns aspectos da interface não são imediatamente óbvios na primeira leitura, então você pode achar as próximas seções úteis.

Para procurar referências JNI globais e ver onde essas referências são criadas e excluídas, use a visualização de heap JNI no Memory Profiler no Android Studio 3.2 e versões mais recentes.

Dicas gerais

Tente minimizar a pegada da sua camada de JNI. Existem várias dimensões a serem consideradas aqui. Sua solução JNI precisa tentar seguir estas diretrizes (listadas abaixo por ordem de importância, começando com as mais importantes):

  • Minimize o gerenciamento de recursos na camada da JNI. O gerenciamento por meio da camada de JNI tem custos nada triviais. Tente criar uma interface que minimize a quantidade de dados que você precisa para gerenciar e a frequência com que os dados precisam ser gerenciados.
  • Quando possível, evite comunicação assíncrona entre código escrito em uma linguagem de programação gerenciada e código escrito em C++. Isso fará com que sua interface JNI seja mais fácil de manter. Normalmente, é possível simplificar as atualizações assíncronas da IU mantendo a atualização assíncrona na mesma linguagem da IU. Por exemplo, em vez de invocar uma função C++ na linha de execução de IU no código Java via JNI, é melhor fazer um callback entre duas linhas de execução na linguagem de programação Java, com uma delas fazendo uma chamada C++ de bloqueio e notificando a linha de execução de IU quando a chamada de bloqueio for concluída.
  • Minimize o número de linhas de execução que precisam tocar ou ser tocadas pelo JNI. Se você precisar utilizar pools de linhas de execução nas linguagens Java e C++, tente manter a comunicação JNI entre os proprietários do pool, e não entre linhas de execução de workers individuais.
  • Mantenha seu código de interface em um número baixo de locais de origem C++ e Java facilmente identificáveis para facilitar futuras refatorações. Conforme apropriado, considere usar uma biblioteca de geração automática de JNI.

JavaVM e JNIEnv

O JNI define duas estruturas de dados principais: "JavaVM" e "JNIEnv". Ambos são essencialmente ponteiros para tabelas de função. Na versão C++, eles são classes com um ponteiro para uma tabela de função e uma função de membro para cada função JNI que faz referência indireta por meio da tabela. O JavaVM fornece as funções de "interface de invocação", que permitem criar e destruir um JavaVM. Em teoria, você pode ter vários JavaVMs por processo, mas o Android permite apenas um.

O JNIEnv fornece a maioria das funções JNI. Todas as suas funções nativas recebem um JNIEnv como o primeiro argumento, exceto para os métodos @CriticalNative, consulte chamadas nativas mais rápidas.

O JNIEnv é usado para armazenamento local de linhas de execução. Por esse motivo, não é possível compartilhar um JNIEnv entre linhas de execução. Se uma parte do código não tiver outra maneira de conseguir seu JNIEnv, você precisará compartilhar o JavaVM e usar o GetEnv para descobrir o JNIEnv da linha de execução. Supondo que tenha um, veja o AttachCurrentThread abaixo.

As declarações C de JNIEnv e JavaVM são diferentes das declarações C++. O arquivo de inclusão "jni.h" fornece diferentes typedefs, dependendo se ele está incluído em C ou C ++. Por esse motivo, não é uma boa ideia incluir os argumentos JNIEnv em arquivos principais incluídos por ambas as linguagens. Dito de outra forma: se o arquivo principal exige #ifdef __cplusplus, talvez seja preciso fazer algum trabalho extra se algo nesse cabeçalho se referir ao JNIEnv.

Linhas de execução

Todas as linhas de execução são do Linux, agendadas pelo kernel. Elas geralmente são iniciadas a partir do código gerenciado (usando Thread.start()), mas também podem ser criadas em outro lugar e, em seguida, anexadas ao JavaVM. Por exemplo, uma linha de execução iniciada com pthread_create() ou std::thread pode ser anexada usando as funções AttachCurrentThread() ou AttachCurrentThreadAsDaemon(). Até que uma linha de execução seja anexada, ela não possui um JNIEnv e não pode fazer chamadas JNI.

Em geral, é melhor usar Thread.start() para criar qualquer linha de execução que precise chamar o código Java. Isso garantirá que você tenha espaço de pilha suficiente, que esteja no ThreadGroup correto e que esteja usando o mesmo ClassLoader do código Java. Também é mais fácil definir o nome da linha de execução para depuração em Java do que em código nativo. Consulte pthread_setname_np(), se você tiver um pthread_t ou thread_t, e std::thread::native_handle(), se tiver um std::thread e quiser um pthread_t.

Anexar uma linha de execução criada de maneira nativa faz com que um objeto java.lang.Thread seja criado e adicionado ao ThreadGroup "principal", tornando-o visível para o depurador. Chamar AttachCurrentThread() em uma linha de execução já anexada é um ambiente autônomo.

O Android não suspende linhas de execução que executam código nativo. Se a coleta de lixo estiver em andamento ou o depurador tiver emitido uma solicitação de suspensão, o Android pausará a linha de execução na próxima vez que fizer uma chamada JNI.

As linhas de execução anexadas por meio de JNI precisam chamar DetachCurrentThread() antes de sair. Se codificar isso diretamente for estranho, no Android 2.0 (Eclair) e versões mais recentes, use pthread_key_create() para definir uma função de destruição que será chamada antes da saída da linha de execução e chamar DetachCurrentThread() de lá. Use essa chave com pthread_setspecific() para armazenar o JNIEnv no armazenamento local da linha de execução. Dessa forma, ele será passado para seu destrutor como o argumento.

jclass, jmethodID e jfieldID

Se você quiser acessar o campo de um objeto do código nativo, faça o seguinte:

  • Consiga a referência de objeto de classe para a classe com FindClass
  • Conferir o ID do campo com GetFieldID
  • Consiga o conteúdo do campo com algo apropriado, como GetIntField

Da mesma forma, para chamar um método, você precisa primeiro conseguir uma referência de objeto de classe e, em seguida, um ID de método. Os IDs geralmente são apenas ponteiros para estruturas internas de dados de ambiente de execução. Pesquisá-los pode exigir várias comparações de strings, mas uma vez que você os têm, a chamada para conseguir o campo ou invocar o método é muito rápida.

Se o desempenho for importante, recomendamos procurar os valores uma vez e armazenar em cache os resultados no seu código nativo. Como há um limite de um JavaVM por processo, é razoável armazenar esses dados em uma estrutura local estática.

As referências de classe, IDs de campo e IDs de método são válidos até que a classe seja descarregada. As classes só serão descarregadas se todas as classes associadas a um ClassLoader puderem ser coletadas como lixo, o que é raro, mas não será impossível no Android. Observe, entretanto, que jclass é uma referência de classe, e precisa ser protegido com uma chamada para NewGlobalRef (veja a próxima seção).

Se você quiser armazenar os IDs em cache quando uma classe for carregada, e armazená-los novamente em cache de forma automática se a classe for descarregada e recarregada, a maneira correta de inicializar os IDs é adicionar à classe adequada uma parte do código que se pareça com isto:

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

Crie um método nativeClassInit no seu código C/C++ que executa as pesquisas de ID. O código será executado uma vez, quando a classe for inicializada. Se a classe for descarregada e depois recarregada, ela será executada novamente.

Referências locais e globais

Todo argumento transmitido para um método nativo, e quase todo objeto retornado por uma função JNI é uma "referência local". Isso significa que ele é válido pela duração do método nativo atual na linha de execução atual. Mesmo que o próprio objeto continue ativo depois que o método nativo for retornado, a referência não será válida.

Isso se aplica a todas as subclasses de jobject, incluindo jclass, jstring e jarray. O ambiente de execução avisará sobre a maioria dos usos indevidos de referência quando as verificações estendidas da JNI estiverem ativadas.

A única maneira de conseguir referências não locais é por meio das funções NewGlobalRef e NewWeakGlobalRef.

Se você quiser manter uma referência por um período mais longo, use uma referência "global". A função NewGlobalRef usa a referência local como argumento e retorna uma referência global. A referência global é garantida como válida até você chamar DeleteGlobalRef.

Esse padrão costuma ser usado ao armazenar em cache uma jclass retornada de FindClass, por exemplo:

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

Todos os métodos JNI aceitam referências locais e globais como argumentos. É possível que referências ao mesmo objeto tenham valores diferentes. Por exemplo, os valores de retorno de chamadas consecutivas para NewGlobalRef no mesmo objeto podem ser diferentes. Para ver se duas referências se referem ao mesmo objeto, você precisa usar a função IsSameObject. Nunca compare referências com == em código nativo.

Uma consequência disso é que você não poderá presumir que as referências de objeto sejam constantes ou exclusivas no código nativo. O valor que representa um objeto pode ser diferente de uma invocação de um método para a próxima, e é possível que dois objetos diferentes tenham o mesmo valor em chamadas consecutivas. Não use valores jobject como chaves.

É necessário que os programadores "não aloquem excessivamente" referências locais. Em termos práticos, isso significa que, se você estiver criando um grande número de referências locais, talvez durante a execução de uma matriz de objetos, será preciso liberá-los de forma manual com DeleteLocalRef em vez de permitir que a JNI faça isso por você. Como a implementação só é necessária para reservar slots para 16 referências locais, se precisar de mais do que isso, exclua conforme avança ou utilize EnsureLocalCapacity/PushLocalFrame para reservar mais.

Observe que jfieldIDs e jmethodIDs são tipos opacos, não referências a objetos, e não precisam ser transmitidos para NewGlobalRef. Os pontos de dados brutos retornados por funções como GetStringUTFChars e GetByteArrayElements também não são objetos. Eles podem ser transmitidos entre as linhas de execução e são válidos até a chamada de liberação correspondente.

Um caso incomum merece menção separada. Se você anexar uma linha de execução nativa com AttachCurrentThread, o código que você está executando nunca liberará automaticamente as referências locais até que a linha de execução seja desconectada. Todas as referências locais criadas por você precisarão ser excluídas manualmente. No geral, qualquer código nativo que crie referências locais em um loop provavelmente precisará fazer alguma exclusão manual.

Tenha cuidado ao usar referências globais. Elas podem ser inevitáveis, mas têm depuração árdua e podem causar comportamentos de memória difíceis de diagnosticar. Se todo o restante for igual, uma solução com menos referências globais provavelmente será melhor.

Strings UTF-8 e UTF-16

A linguagem de programação Java usa o UTF-16. Por conveniência, o JNI também fornece métodos que funcionam com UTF-8 modificado. A codificação modificada é útil para código C porque codifica \u0000 como 0xc0 0x80 em vez de 0x00. O bom disso é que você pode contar com strings com terminação zero no estilo C, adequadas para uso com funções de string da libc padrão. O lado negativo é que não é possível transmitir dados arbitrários UTF-8 para o JNI e esperar que ele funcione corretamente.

Para acessar a representação UTF-16 de um String, use GetStringChars. As strings UTF-16 não são terminadas com zero, e \u0000 é permitido. Portanto, é necessário manter o tamanho da string e o ponteiro jchar.

Não esqueça de Release as strings que você Get. As funções de string retornam jchar* ou jbyte*, que são ponteiros no estilo C para dados primitivos em vez de referências locais. Eles são garantidos como válidos até Release ser chamado, o que significa que eles não são liberados quando o método nativo é retornado.

Os dados passados para o NewStringUTF precisam estar no formato UTF-8 modificado. Um erro comum é ler dados de caracteres de um arquivo ou fluxo de rede e entregá-los a NewStringUTF sem filtrá-los. A menos que você saiba que os dados são MUTF-8 válidos (ou ASCII de 7 bits, que é um subconjunto compatível), é necessário remover os caracteres inválidos ou convertê-los no formato UTF-8 modificado. Caso contrário, a conversão UTF-16 provavelmente fornecerá resultados inesperados. CheckJNI, que está ativado por padrão para emuladores, verifica strings e cancela a VM se receber uma entrada inválida.

Antes do Android 8, era mais rápido operar com strings UTF-16, já que o Android não exigia uma cópia em GetStringChars, enquanto GetStringUTFChars exigia uma alocação e uma conversão para UTF-8. O Android 8 mudou a representação String para usar 8 bits por caractere em strings ASCII (para economizar memória) e começou a usar um coletor de lixo móvel. Esses recursos reduzem bastante o número de casos em que o ART pode fornecer um ponteiro para os dados String sem fazer uma cópia, mesmo para GetStringCritical. No entanto, se a maioria das strings processadas pelo código for curta, é possível evitar a alocação e a desalocação na maioria dos casos usando um buffer alocado por pilha e GetStringRegion ou GetStringUTFRegion. Por exemplo:

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

Matrizes primitivas

O JNI oferece funções para acessar o conteúdo de objetos de matriz. Enquanto matrizes de objetos precisam ser acessadas uma entrada por vez, matrizes de primitivos podem ser lidas e escritas diretamente como se fossem declaradas em C.

Para tornar a interface o mais eficiente possível sem restringir a implementação da VM, a família de chamadas Get<PrimitiveType>ArrayElements permite que o ambiente de execução retorne um ponteiro para os elementos reais ou aloque um pouco de memória e faça uma cópia. De qualquer forma, o ponteiro bruto retornado tem a garantia de ser válido até a chamada de Release correspondente ser emitida (o que implica que, se os dados não forem copiados, o objeto de matriz será fixado e não poderá ser realocado como parte da compactação do heap). Você precisa Release a cada matriz que Get. Além disso, se a chamada Get falhar, você precisa garantir que seu código não tentará liberar (Release) um ponteiro NULL mais tarde.

É possível determinar se os dados foram ou não copiados transmitindo um ponteiro não NULL para o argumento isCopy. Isso raramente é útil.

A chamada Release usa um argumento mode que pode ter um dos três valores. As ações executadas pelo ambiente de execução dependem se ele retornou um ponteiro para os dados reais ou uma cópia dele:

  • 0
    • Real: o objeto da matriz é liberado.
    • Cópia: os dados são copiados de volta. O buffer com a cópia é liberado.
  • JNI_COMMIT
    • Real: não faz nada.
    • Cópia: os dados são copiados de volta. O buffer com a cópia não é liberado.
  • JNI_ABORT
    • Real: o objeto da matriz é liberado. As gravações anteriores não são canceladas.
    • Cópia: o buffer com a cópia é liberado, e todas as alterações nele são perdidas.

Um motivo para verificar a sinalização isCopy é saber se você precisa chamar Release com JNI_COMMIT após modificar uma matriz. Se estiver alternando entre modificar e executar o código que usa o conteúdo da matriz, talvez seja possível ignorar a confirmação do ambiente autônomo. Outro motivo possível para verificar a sinalização é o uso eficiente de JNI_ABORT. Por exemplo, você pode querer conseguir uma matriz, modificá-la no lugar, transmitir as peças para outras funções e descartar as modificações. Se você sabe que a JNI está fazendo uma nova cópia para você, não é necessário criar outra cópia "editável". Se a JNI estiver transmitindo o original, você precisará fazer sua própria cópia.

É um erro comum (repetido no código de exemplo) presumir que você pode ignorar a chamada Release se *isCopy for falso. Esse não é o caso. Se nenhum buffer de cópia foi alocado, então a memória original precisa ser fixada, e não pode ser movida pelo coletor de lixo.

Observe também que a sinalização JNI_COMMIT não libera a matriz, e você precisará chamar Release novamente com outra sinalização.

Chamadas de região

Há uma alternativa para chamadas como Get<Type>ArrayElements e GetStringChars que podem ser muito úteis quando você só quer copiar ou remover dados. Considere o seguinte:

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

Isso pega a matriz, copia os primeiros elementos de byte len e, em seguida, libera a matriz. Dependendo da implementação, a chamada Get fixará ou copiará o conteúdo da matriz. O código copia os dados (talvez uma segunda vez) e, em seguida, chama Release. Nesse caso, o JNI_ABORT garante que não haverá uma terceira cópia.

O mesmo resultado pode ser alcançado de uma maneira mais simples:

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

Isso tem muitas vantagens:

  • Requer uma chamada JNI em vez de duas, reduzindo a sobrecarga.
  • Não requer fixação ou cópias de dados extras.
  • Reduz o risco de erro do programador. Não há risco de esquecer de chamar Release depois que algo falhar.

Da mesma forma, use a chamada Set<Type>ArrayRegion para copiar dados para uma matriz e GetStringRegion ou GetStringUTFRegion para copiar caracteres de um String.

Exceções

Você não precisa chamar a maioria das funções JNI enquanto uma exceção estiver pendente. Espera-se que seu código observe a exceção (por meio do valor de retorno da função, ExceptionCheck ou ExceptionOccurred) e retorne ou limpe a exceção e processe-a.

As únicas funções JNI que você pode chamar enquanto uma exceção está pendente são:

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

Muitas chamadas JNI podem acionar uma exceção, mas geralmente oferecem uma maneira mais simples de verificar falhas. Por exemplo, se NewString retornar um valor não NULL, você não precisará verificar uma exceção. No entanto, se você chamar um método (usando uma função como CallObjectMethod), sempre será necessário verificar uma exceção, porque o valor de retorno não será válido se uma exceção tiver sido gerada.

As exceções geradas pelo código gerenciado não liberam frames de pilha nativos. Além disso, as exceções C++, geralmente não recomendadas no Android, não podem ser geradas no limite de transição JNI do código C++ para o código gerenciado. As instruções JNI Throw e ThrowNew só definem um ponteiro de exceção na linha de execução atual. Ao retornar para gerenciada a partir do código nativo, a exceção será anotada e processada adequadamente.

O código nativo pode "capturar" uma exceção chamando ExceptionCheck ou ExceptionOccurred e eliminá-la com ExceptionClear. Como de costume, descartar exceções sem processá-las pode causar problemas.

Não há funções integradas para manipular o objeto Throwable em si. Portanto, se você quiser (digamos) receber a string de exceção, precisará encontrar a classe Throwable, procurar a código do método para getMessage "()Ljava/lang/String;", invocá-la e, se o resultado não for NULL, usar GetStringUTFChars para conseguir algo que possa entregar a printf(3) ou equivalente.

Verificação estendida

O JNI faz muito pouca verificação de erros. Os erros geralmente resultam em uma falha. O Android também oferece um modo chamado CheckJNI, em que os ponteiros da tabela de funções JavaVM e JNIEnv são trocados por tabelas de funções que executam uma série estendida de verificações antes de chamar a implementação padrão.

As verificações extras incluem:

  • Matrizes: tentar alocar uma matriz de tamanho negativo.
  • Ponteiros incorretos: passar um jarray/jclass/jobject/jstring incorreto para uma chamada JNI ou passar um ponteiro NULL para uma chamada JNI com um argumento não anulável.
  • Nomes de classe: passar tudo, exceto o estilo “java/lang/String” do nome da classe para uma chamada JNI.
  • Chamadas críticas: fazer uma chamada JNI entre um get "crítico" e o release correspondente.
  • ByteBuffers diretos: transmitir argumentos incorretos para NewDirectByteBuffer.
  • Exceções: fazer uma chamada JNI enquanto houver uma exceção pendente.
  • JNIEnv*s: usar um JNIEnv* da linha de execução errada.
  • jfieldIDs: usar um jfieldID NULL, usar um jfieldID para definir um campo para um valor do tipo errado (tentando atribuir um StringBuilder a um campo String, digamos), usar um jfieldID para um campo estático para definir um campo de instância ou vice-versa, ou usar um jfieldID de uma classe com instâncias de outra classe.
  • jmethodIDs: usar o tipo errado de jmethodID ao fazer uma chamada JNI Call*Method: tipo de retorno incorreto, incompatibilidade estática/não estática, tipo errado para "this" (para chamadas não estáticas) ou classe errada (para chamadas estáticas).
  • Referências: usar DeleteGlobalRef/DeleteLocalRef no tipo errado de referência.
  • Modos de liberação: transmitir um modo de liberação incorreto para uma chamada de liberação (algo diferente de 0, JNI_ABORT ou JNI_COMMIT).
  • Segurança de tipo: retornar um tipo incompatível do seu método nativo (retornando um StringBuilder de um método declarado para retornar uma string, por exemplo).
  • UTF-8: passar uma sequência de byte UTF-8 modificado inválida para uma chamada JNI.

A acessibilidade de métodos e campos ainda não é verificada: as restrições de acesso não se aplicam ao código nativo.

Existem várias maneiras de ativar o CheckJNI.

Se você estiver usando o emulador, o CheckJNI estará ativado por padrão.

Se você tiver um dispositivo com acesso root, poderá usar a seguinte sequência de comandos para reiniciar o ambiente de execução com o CheckJNI ativado:

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

Em qualquer um desses casos, você verá algo assim na sua saída do logcat quando o ambiente de execução for iniciado:

D AndroidRuntime: CheckJNI is ON

Se você tiver um dispositivo normal, poderá usar o seguinte comando:

adb shell setprop debug.checkjni 1

Isso não afetará os apps já em execução, mas todo app iniciado a partir desse ponto terá o CheckJNI ativado. Mudar a propriedade para qualquer outro valor ou simplesmente reinicializar desativará o CheckJNI novamente. Nesse caso, você verá algo assim na sua saída do logcat na próxima vez em que um app for iniciado:

D Late-enabling CheckJNI

Você também pode definir o atributo android:debuggable no manifesto do app para ativar o CheckJNI apenas para seu app. As ferramentas de build do Android vão fazer isso automaticamente para determinados tipos de build.

Bibliotecas nativas

Você pode carregar o código nativo a partir de bibliotecas compartilhadas com o System.loadLibrary padrão.

Na prática, versões mais antigas do Android tinham erros no PackageManager que faziam com que a instalação e a atualização de bibliotecas nativas não fossem confiáveis. O projeto ReLinker oferece soluções alternativas para esse e outros problemas de carregamento de bibliotecas nativas.

Chame System.loadLibrary (ou ReLinker.loadLibrary) a partir de um inicializador de classe estática. O argumento é o nome da biblioteca "não decorado". Então, para carregar libfubar.so, transmita "fubar".

Se você tem apenas uma classe com métodos nativos, convém que a chamada para System.loadLibrary esteja em um inicializador estático nessa classe. Caso contrário, é interessante fazer a chamada do Application, assim você saberá que a biblioteca está sempre carregada e que isso será feito antecipadamente.

Existem duas maneiras para o ambiente de execução encontrar seus métodos nativos. Você pode registrá-los explicitamente com RegisterNatives ou pode permitir que o ambiente de execução procure-os dinamicamente com dlsym. As vantagens do RegisterNatives são que você verifica de forma antecipada se os símbolos existem, além de poder ter bibliotecas compartilhadas menores e mais rápidas sem exportar nada além de JNI_OnLoad. A vantagem de deixar o ambiente de execução descobrir suas funções é que há um pouco menos de código para escrever.

Para usar RegisterNatives:

  • Forneça uma função JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved).
  • No JNI_OnLoad, registre todos os métodos nativos usando RegisterNatives.
  • Crie com -fvisibility=hidden para que apenas o JNI_OnLoad seja exportado da sua biblioteca. Isso produz um código mais rápido e menor, além de evitar possíveis colisões com outras bibliotecas carregadas no app, mas cria stack traces menos úteis se o app falhar em código nativo.

O inicializador estático será como este:

Kotlin

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

Java

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

A função JNI_OnLoad será semelhante a esta se estiver escrita em C++:

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

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

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

    return JNI_VERSION_1_6;
}

Para usar a "descoberta" de métodos nativos, você precisa nomeá-los de uma maneira específica. Consulte a especificação JNI (link em inglês) para ver detalhes. Isso significa que se uma assinatura de método estiver errada, você não saberá até a primeira vez em que o método for invocado.

Todas as chamadas FindClass feitas de JNI_OnLoad resolverão as classes no contexto do carregador de classes que foi usado para carregar a biblioteca compartilhada. Quando chamada de outros contextos, a FindClass usa o carregador de classes associado ao método na parte superior da pilha Java ou, se não existir um (porque a chamada é de uma linha de execução nativa que acabou de ser anexada), ela usará o carregador de classes "system". Como o carregador de classes do sistema não conhece as classes do seu app, não será possível procurar suas próprias classes com FindClass nesse contexto. Isso torna o JNI_OnLoad um lugar conveniente para procurar e armazenar em cache classes: depois de ter uma referência global válida de jclass, você pode usá-la de qualquer linha de execução anexada.

Chamadas nativas mais rápidas com @FastNative e @CriticalNative

Os métodos nativos podem ser anotados com @FastNative ou @CriticalNative (mas não ambos) para acelerar as transições entre o código gerenciado e nativo. No entanto, essas anotações vem com certas mudanças de comportamento que precisam ser cuidadosamente consideradas antes do uso. Embora essas mudanças sejam breves, consulte a documentação para mais detalhes.

A anotação @CriticalNative pode ser aplicada somente a métodos nativos que não usam objetos gerenciados (em parâmetros ou valores de retorno, ou como uma this implícita), e essa anotação muda a ABI de transição de JNI. A implementação nativa precisa excluir os parâmetros JNIEnv e jclass da assinatura da função.

Ao executar um método @FastNative ou @CriticalNative, a coleta de lixo não pode suspender a linha de execução para um trabalho essencial e pode ser bloqueada. Não use essas anotações para métodos de longa duração, incluindo métodos geralmente rápidos, mas geralmente ilimitados. Em especial, o código não pode executar operações de E/S significativas nem adquirir bloqueios nativos que podem ser mantidos por muito tempo.

Essas anotações foram implementadas para uso do sistema desde o Android 8 e se tornaram uma API pública testada pelo CTS no Android 14. É provável que essas otimizações também funcionem em dispositivos com o Android 8 a 13, embora sem as fortes garantias de CTS, mas a pesquisa dinâmica de métodos nativos tem suporte apenas no Android 12 ou mais recente. O registro explícito com JNI RegisterNatives é estritamente necessário para execução nas versões 8 a 11 do Android. Essas anotações são ignoradas no Android 7. A incompatibilidade da ABI com @CriticalNative levaria a um empacotamento incorreto de argumentos e provavelmente falhas.

Para métodos críticos de desempenho que precisam dessas anotações, é altamente recomendável registrar explicitamente os métodos com JNI RegisterNatives, em vez de confiar na "descoberta" de métodos nativos baseada em nome. Para ter a performance ideal de inicialização do app, recomendamos incluir os autores das chamadas dos métodos @FastNative ou @CriticalNative no perfil de referência. Desde o Android 12, uma chamada para um método nativo @CriticalNative usando um método gerenciado compilado é quase tão barato quanto uma chamada não inline em C/C++, desde que todos os argumentos caibam nos registros. Por exemplo, até 8 argumentos integrais e até oito argumentos de ponto flutuante em arm64.

Às vezes, pode ser preferível dividir um método nativo em dois, um muito rápido que pode falhar e outro que lida com casos lentos. Por exemplo:

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

Considerações quanto a 64 bits

Para compatibilidade com arquiteturas que usam ponteiros de 64 bits, use um campo long em vez de um int ao armazenar um ponteiro para uma estrutura nativa em um campo Java.

Recursos não compatíveis/compatibilidade com versões anteriores

Todos os recursos da JNI 1.6 são compatíveis, exceto o seguinte:

  • DefineClass não foi implementado. O Android não usa bytecodes ou arquivos de classe do Java; portanto, a transmissão de dados da classe binária não funciona.

Para compatibilidade com versões mais antigas do Android, você precisa conhecer:

  • Pesquisa dinâmica de funções nativas

    Até o Android 2.0 (Eclair), o caractere "$" não era convertido corretamente para "_00024" durante as pesquisas por nomes de métodos. Contornar isso requer o uso de registro explícito ou a remoção dos métodos nativos de classes internas.

  • Linhas de execução removidas

    Até o Android 2.0 (Eclair), não era possível usar uma função destruidora pthread_key_create para evitar a verificação "a linha de execução precisa ser desconectada antes de sair". O ambiente de execução também usa uma função destruidora de chave pthread, o que resultaria em uma corrida para ver qual é chamado primeiro.

  • Referências globais fracas

    Até o Android 2.2 (Froyo), as referências globais fracas não tinham sido implementadas. Versões mais antigas rejeitarão vigorosamente as tentativas de usá-las. Você pode usar as constantes de versão da plataforma Android para testar a compatibilidade.

    Até o Android 4.0 (Ice Cream Sandwich), referências globais fracas só podiam ser passadas para NewLocalRef, NewGlobalRef e DeleteWeakGlobalRef. Como a especificação incentiva fortemente os programadores a criar referências concretas para globais fracos antes de fazer qualquer coisa com eles, isso não será algo limitador.

    A partir do Android 4.0 (Ice Cream Sandwich), referências globais fracas podem ser usadas como qualquer outra referência JNI.

  • Referências locais

    Até o Android 4.0 (Ice Cream Sandwich), as referências locais eram, na verdade, ponteiros diretos. O Ice Cream Sandwich adicionou a indireção necessária para aceitar coletores de lixo melhores, mas isso significa que muitos erros JNI são indetectáveis em versões mais antigas. Consulte Mudanças na referência local de JNI em ICS (link em inglês) para ver mais detalhes.

    Nas versões do Android anteriores ao Android 8.0, o número de referências locais era restrito a um limite específico da versão. A partir da versão 8.0, o Android passou a ser compatível com referências locais ilimitadas.

  • Como determinar o tipo de referência com GetObjectRefType

    Até o Android 4.0 (Ice Cream Sandwich), como consequência do uso de ponteiros diretos (veja acima), era impossível implementar GetObjectRefType corretamente. Em vez disso, usávamos uma heurística que examinava a tabela de globais fracos, os argumentos, a tabela de locais e a tabela de globais, nessa ordem. Ao encontrar o ponteiro direto pela primeira vez, ela relatava que sua referência era do tipo que estava sendo examinado. Isso significava, por exemplo, que se você chamasse GetObjectRefType em uma jclass global que fosse a mesma que a jclass transmitida como um argumento implícito para seu método nativo estático, você teria JNILocalRefType em vez de JNIGlobalRefType.

  • @FastNative e @CriticalNative

    Até o Android 7, essas anotações de otimização eram ignoradas. A incompatibilidade da ABI com @CriticalNative levaria ao gerenciamento incorreto de argumentos e, provavelmente, a falhas.

    A pesquisa dinâmica de funções nativas para os métodos @FastNative e @CriticalNative não foi implementada no Android 8-10 e contém bugs conhecidos no Android 11. O uso dessas otimizações sem registro explícito com o JNI RegisterNatives provavelmente levará a falhas no Android 8 e 11.

Perguntas frequentes: por que recebo UnsatisfiedLinkError?

Ao trabalhar com código nativo, não é incomum ver uma falha como esta:

java.lang.UnsatisfiedLinkError: Library foo not found

Em alguns casos, isso significa o que é dito: a biblioteca não foi encontrada. Em outros, a biblioteca existe, mas não pôde ser aberta por dlopen(3), e os detalhes da falha podem ser encontrados na mensagem detalhada da exceção.

Razões comuns pelas quais você pode encontrar exceções de "biblioteca não encontrada":

  • A biblioteca não existe ou não pode ser acessada pelo app. Use adb shell ls -l <path> para verificar a presença e as permissões dela.
  • A biblioteca não foi criada com o NDK. Isso pode resultar em dependências de funções ou bibliotecas que não existem no dispositivo.

Outra classe de falhas UnsatisfiedLinkError tem esta aparência:

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

No logcat, você verá:

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

Isso significa que o ambiente de execução tentou localizar um método correspondente, mas não conseguiu. Algumas razões comuns para isso são as seguintes:

  • A biblioteca não está sendo carregada. Verifique a saída do logcat para receber mensagens sobre o carregamento da biblioteca.
  • O método não está sendo encontrado devido a uma incompatibilidade de nome ou assinatura. Isso é comumente causado por estes fatores:
    • Por pesquisa preguiçosa de método, ao falhar na declaração de funções de C++ com extern "C" e visibilidade adequada (JNIEXPORT). Observe que, antes do Ice Cream Sandwich, a macro JNIEXPORT estava incorreta. Portanto, usar um novo GCC com uma jni.h antiga não funcionará. Você pode usar arm-eabi-nm para ver os símbolos como aparecem na biblioteca. Se eles parecerem corrompidos (algo como _Z15Java_Foo_myfuncP7_JNIEnvP7_jclass em vez de Java_Foo_myfunc) ou se o tipo de símbolo for "t" em vez de "T", você precisará ajustar a declaração.
    • Para registro explícito, pequenos erros ao inserir a assinatura do método. Verifique se o que você está transmitindo para a chamada de registro corresponde à assinatura no arquivo de registros. Lembre-se de que "B" é byte e "Z" é boolean. Os componentes de nome de classe nas assinaturas começam com "L", terminam com ";", usam "/" para separar nomes de pacotes/classes e usam "$" para separar nomes de classes internas (Ljava/util/Map$Entry;, por exemplo).

O uso de javah para gerar automaticamente cabeçalhos JNI pode ajudar a evitar alguns problemas.

Perguntas frequentes: por que o FindClass não encontrou minha classe?

A maioria dessas recomendações se aplica igualmente a falhas em encontrar métodos com GetMethodID ou GetStaticMethodID ou campos com GetFieldID ou GetStaticFieldID.

Verifique se a string do nome da classe tem o formato correto. Os nomes das classes JNI começam com o nome do pacote e são separados por barras, como java/lang/String. Se você estiver procurando uma classe de matriz, será necessário começar com o número apropriado de colchetes e também precisará colocar a classe entre "L" e ";". Assim, uma matriz unidimensional de String seria [Ljava/lang/String;. Se você estiver procurando uma classe interna, use "$" em vez de ".". Em geral, o uso de javap no arquivo .class é uma boa maneira de descobrir o nome interno da classe.

Se você ativar a redução de código, configure qual código manter. A configuração de regras de manutenção adequadas é importante porque o redutor de código pode remover classes, métodos ou campos usados apenas pela JNI.

Se o nome da classe parece estar certo, pode estar ocorrendo um problema de carregador de classe. FindClass quer iniciar a pesquisa de classe no carregador de classes associado ao seu código. Ele examina a pilha de chamadas, que terá esta aparência:

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

O método mais acima é Foo.myfunc. FindClass encontra o objeto ClassLoader associado à classe Foo e o usa.

Isso geralmente faz o que você quer. Você pode ter problemas se criar uma linha de execução (talvez chamando pthread_create e anexando-a com AttachCurrentThread). Agora não há frames de pilha do seu app. Se você chamar FindClass a partir dessa linha de execução, o JavaVM iniciará no carregador de classes "system" em vez daquele associado ao seu app. Portanto, as tentativas de localizar classes específicas do app falharão.

Existem algumas maneiras de contornar isso:

  • Faça suas pesquisas de FindClass uma vez, em JNI_OnLoad, e armazene em cache as referências de classe para uso posterior. Toda chamada FindClass feita como parte da execução de JNI_OnLoad usará o carregador de classes associado à função que chamou System.loadLibrary. Essa é uma regra especial, fornecida para tornar a inicialização da biblioteca mais conveniente. Se o código do app estiver carregando a biblioteca, o FindClass usará o carregador de classes correto.
  • Transmita uma instância da classe para as funções que precisam dela, declarando seu método nativo para receber um argumento Class e, em seguida, transmitindo Foo.class.
  • Armazene em cache uma referência ao objeto ClassLoader em algum lugar prático e emita chamadas loadClass de forma direta. Isso requer certo esforço.

Perguntas frequentes: como eu compartilho dados brutos com código nativo?

Você pode se deparar com uma situação em que precisa acessar um grande buffer de dados brutos do código gerenciado e nativo. Exemplos comuns incluem a manipulação de bitmaps ou amostras de som. Existem duas abordagens básicas.

Você pode armazenar os dados em um byte[]. Isso permite acesso muito rápido a partir do código gerenciado. No entanto, no lado nativo, você não tem a garantia de poder acessar os dados sem precisar copiá-los. Em algumas implementações, GetByteArrayElements e GetPrimitiveArrayCritical retornarão ponteiros reais para os dados brutos no heap gerenciado. Em outras, eles alocarão um buffer no heap nativo e copiarão os dados.

A alternativa é armazenar os dados em um buffer de bytes direto. Eles podem ser criados com java.nio.ByteBuffer.allocateDirect ou com a função JNI NewDirectByteBuffer. Ao contrário dos buffers de byte regulares, o armazenamento não é alocado no heap gerenciado e sempre pode ser acessado diretamente do código nativo (consiga o endereço com GetDirectBufferAddress). Dependendo de como o acesso direto ao buffer de bytes é implementado, o acesso aos dados do código gerenciado pode ser muito lento.

A escolha de qual deles usar depende de dois fatores:

  1. A maioria dos acessos a dados acontecerá a partir do código escrito em Java ou em C/C++?
  2. Se os dados estão sendo transmitidos para uma API do sistema, em qual formato eles precisam estar? Por exemplo, se os dados forem transmitidos para uma função que toma um byte[], fazer o processamento em um ByteBuffer direto pode não ser uma boa ideia.

Se não houver um vencedor claro, use um buffer de bytes direto. A compatibilidade com eles é integrada diretamente ao JNI, e o desempenho será melhor em versões futuras.