Sugerencias de JNI

JNI es la interfaz nativa de Java. Define una forma para que el código de bytes que Android compila a partir de código administrado (escrito en los lenguajes de programación Java o Kotlin) interactúe con el código nativo (escrito en C/C++). JNI es neutra para los proveedores; es compatible con la carga de código de bibliotecas dinámicas compartidas; y, aunque a veces puede ser engorrosa, es bastante eficiente.

Nota: Debido a que Android compila Kotlin en un código de bytes compatible con ART de una manera similar al lenguaje de programación Java, puedes aplicar la orientación incluida en esta página a los dos lenguajes de programación, Kotlin y Java, en términos de la arquitectura de JNI y los costos asociados. Para obtener más información, consulta Kotlin y Android.

Si todavía no la conoces, lee la Especificación de la interfaz nativa Java para obtener un vistazo de cómo funciona JNI y cuáles son las funciones disponibles. Algunos aspectos de la interfaz no son obvios de inmediato en la primera lectura, por lo que te pueden resultar útiles las próximas secciones.

Para consultar referencias globales de JNI y conocer dónde se crean y borran, usa la vista JNI heap del Generador de perfiles de memoria en Android Studio 3.2 y versiones posteriores.

Sugerencias generales

Intenta minimizar la superficie de tu capa de JNI. Hay varias dimensiones para tener en cuenta. Tu solución de JNI debería respetar las siguientes pautas (detalladas a continuación según orden de importancia, comenzando por la más importante):

  • Minimiza el ordenamiento de recursos en la capa de JNI. El ordenamiento en las capas de JNI tiene costos importantes. Intenta diseñar una interfaz que minimice la cantidad de datos necesarios para ordenar y la frecuencia con la que debes ordenar datos.
  • Siempre que sea posible, evita la comunicación asíncrona entre código escrito en un lenguaje de programación administrado y código escrito en C++. De este modo, la interfaz JNI será más fácil de mantener. Por lo general, puedes simplificar las actualizaciones asíncronas de la IU si las mantienes en el mismo idioma que la IU. Por ejemplo, en lugar de invocar una función de C++ desde el subproceso de IU en el código Java mediante JNI, es mejor hacer una devolución de llamada entre dos subprocesos en el lenguaje de programación Java, en la que uno de ellos haga una llamada de bloqueo de C++ y, luego, notifique al subproceso de IU cuando se complete la llamada.
  • Minimiza la cantidad de subprocesos que JNI debe tocar o tocar. Si necesitas usar conjuntos de subprocesos en los lenguajes Java y C++, intenta mantener la comunicación de JNI entre los propietarios del grupo en lugar de entre subprocesos de trabajadores individuales.
  • Mantén una baja cantidad de ubicaciones de origen C++ y Java en el código de la interfaz a fin de facilitar las reestructuraciones futuras. Considera usar una biblioteca de generación automática de JNI según corresponda.

JavaVM y JNIEnv

JNI define dos estructuras de datos clave: "JavaVM" y "JNIEnv". En esencia, ambas son punteros a punteros de tablas de funciones. (En la versión de C++, son clases con un puntero a una tabla de funciones y a una función de miembro para cada función de JNI que direcciona de manera indirecta por la tabla). La estructura JavaVM proporciona las funciones de la "interfaz de invocación", que permiten crear y destruir una JavaVM. En teoría, se pueden tener múltiples JavaVM por proceso, pero Android permite solo una.

La estructura JNIEnv proporciona la mayoría de las funciones de JNI. Todas tus funciones nativas reciben una JNIEnv como primer argumento, excepto por los métodos @CriticalNative. Consulta Llamadas nativas más rápidas.

La JNIEnv se usa para almacenamiento local de subprocesos. Por esa razón, no se puede compartir una JNIEnv entre subprocesos. Si un fragmento de código no tiene otra forma de obtener JNIEnv, debes compartir JavaVM y usar GetEnv para descubrir la JNIEnv del subproceso. (Si tiene una, consulta AttachCurrentThread a continuación).

Las declaraciones C de JNIEnv y JavaVM son diferentes de las declaraciones C++. El archivo de inclusión de "jni.h" proporciona diferentes typedefs según se incluya en C o C++. Por esta razón, es una mala idea incorporar argumentos de JNIEnv en los archivos de encabezado incluidos en ambos idiomas. (En otras palabras, si el archivo de encabezado requiere #ifdef __cplusplus, es posible que debas realizar trabajos adicionales si alguna parte del encabezado hace referencia a JNIEnv).

Subprocesos

Todos los subprocesos son Linux, programados por el kernel. Por lo general, se inician desde el código administrado (mediante Thread.start()), pero también pueden crearse en otro lugar y, luego, conectarse a JavaVM. Por ejemplo, se puede conectar un subproceso iniciado con pthread_create() o std::thread usando las funciones AttachCurrentThread() o AttachCurrentThreadAsDaemon(). Un subproceso no tiene JNIEnv ni puede realizar llamadas de JNI hasta que se conecta.

En general, es mejor usar Thread.start() para crear cualquier subproceso que necesite llamar al código Java. Esto te garantizará suficiente espacio de pila, estar en el ThreadGroup correcto y usar el mismo ClassLoader que tu código Java. También es más fácil establecer el nombre del subproceso para depurar en Java que en código nativo (consulta pthread_setname_np() si tienes un pthread_t o thread_t, y std::thread::native_handle() si tienes un std::thread y quieres un pthread_t).

Cuando se conecta un subproceso creado de forma nativa, se construye un objeto java.lang.Thread y se agrega al elemento "principal" ThreadGroup, lo que lo hará visible para el depurador. Llamar a AttachCurrentThread() en un subproceso ya conectado constituye una no-op.

Android no suspende subprocesos que ejecuten código nativo. Si está en curso la recolección de elementos no utilizados o el depurador emitió una solicitud de suspensión, Android pausará el subproceso la próxima vez que haga una llamada de JNI.

Los subprocesos conectados a través de JNI deben llamar a DetachCurrentThread() antes de salir. Si la codificación resulta incómoda de manera directa, en Android 2.0 (Eclair) y versiones posteriores, puedes usar pthread_key_create() para definir una función destructora que se llamará antes de que salga el subproceso, y llamar a DetachCurrentThread() desde allí. (Usa esa clave con pthread_setspecific() para almacenar la JNIEnv en thread-local-storage; de esa manera, se pasará a tu destructor como argumento).

jclass, jmethodID y jfieldID

Si deseas acceder al campo del objeto desde código nativo, deberías hacer lo siguiente:

  • Obtén la referencia del objeto de clase para la clase con FindClass.
  • Obtén el ID de campo para el campo con GetFieldID.
  • Obtén los contenidos del campo con algo adecuado, como GetIntField.

De forma similar, para llamar un método, primero deberías obtener una referencia de objeto de clase y, luego, un ID de método. Los ID suelen ser punteros a estructuras internas de datos de tiempo de ejecución. Para buscarlos, es posible que se requieran varias comparaciones de strings. Sin embargo, una vez que los tienes, la llamada real para obtener el campo o invocar el método es muy rápida.

Si el rendimiento es importante, te resultará útil buscar los valores una vez y almacenar en caché los resultados en tu código nativo. Como hay un límite de una JavaVM por proceso, es razonable almacenar esos datos en una estructura local estática.

Las referencias de clase, los ID de campo y los ID de método tienen una validez garantizada hasta que se descarga la clase. Las clases solo se descargan si todas las clases asociadas con un ClassLoader pueden recolectarse como elementos no utilizados, lo que en Android es poco común, pero no imposible. No obstante, ten en cuenta que jclass es una referencia de clase y debe protegerse con una llamada a NewGlobalRef (consulta la siguiente sección).

Si deseas almacenar en caché los ID cuando se carga una clase y volver a almacenarlos en caché de manera automática si alguna vez se descarga la clase y se vuelve a cargar, la forma adecuada de inicializar los ID es agregar un fragmento de código como el siguiente en la clase adecuada:

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 método nativeClassInit en tu código C/C++ que realice las búsquedas de ID. El código se ejecutará una vez, cuando se inicialice la clase. Si alguna vez se descarga la clase y se vuelve a cargar, volverá a ejecutarse.

Referencias globales y locales

Todos los argumentos que se pasan a un método nativo y casi todos los objetos que muestra una función de JNI se consideran una "referencia local". Esto quiere decir que es válida mientras dure el método nativo actual en el subproceso actual. Incluso si el objeto en sí sigue activo después de que vuelve el método nativo, la referencia no es válida.

Esto se aplica a todas las subclases de jobject, incluidas jclass, jstring y jarray. (El tiempo de ejecución sirve de aviso sobre la mayoría de los usos incorrectos de referencias cuando están habilitadas las verificaciones de JNI extendidas).

La única forma de obtener referencias no locales es mediante las funciones NewGlobalRef y NewWeakGlobalRef.

Si quieres conservar una referencia por un período más prolongado, debes usar una referencia "global". La función NewGlobalRef toma la referencia local como un argumento y muestra una global. La validez de la referencia global está garantizada hasta que llamas a DeleteGlobalRef.

Este patrón se usa comúnmente cuando se almacena en caché una jclass que FindClass muestra, p. ej.:

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

Todos los métodos de JNI aceptan referencias locales y globales como argumentos. Es posible que las referencias del mismo objeto tengan valores diferentes. Por ejemplo, los valores que se muestran de llamadas consecutivas a NewGlobalRef en el mismo objeto pueden ser diferentes. Para ver si dos referencias remiten al mismo objeto, debes usar la función IsSameObject. Nunca compares referencias con == en código nativo.

Una consecuencia de eso es que no debes suponer que las referencias de objetos son constantes o únicas en código nativo. El valor que representa un objeto puede ser diferente de una invocación de un método a otra, y es posible que dos objetos diferentes tengan el mismo valor en llamadas consecutivas. No uses valores jobject como claves.

Los programadores están obligados a "no asignar de manera excesiva" referencias locales. En la práctica, esto significa que, si creas grandes cantidades de referencias locales, quizás mientras ejecutas un arreglo de objetos, debes liberarlas manualmente con DeleteLocalRef en lugar de dejar que JNI lo haga por ti. Solo se requiere la implementación a fin de reservar ranuras para 16 referencias locales, por lo que, si necesitas más, debes borrarlas a medida que avanzas o usar EnsureLocalCapacity/PushLocalFrame para reservar más.

Ten en cuenta que jfieldID y jmethodID son tipos opacos, no referencias a objetos, y no se deben pasar a NewGlobalRef. Los punteros de datos sin procesar que se muestran con funciones como GetStringUTFChars y GetByteArrayElements tampoco son objetos. (Se pueden pasar entre subprocesos y son válidos hasta que se realiza la llamada de liberación coincidente).

Vale la pena mencionar un caso inusual. Si conectas un subproceso nativo con AttachCurrentThread, el código que ejecutes nunca liberará las referencias locales automáticamente hasta que se desconecte el subproceso. Deberás borrar de forma manual todas las referencias locales que crees. En general, es probable que debas borrar manualmente cualquier código nativo que cree referencias locales en un bucle.

Ten cuidado cuando uses referencias globales. Estas pueden ser inevitables, pero son difíciles de depurar y pueden provocar (malos) comportamientos de la memoria difíciles de diagnosticar. A igualdad de condiciones, es probable que sea mejor una solución con menos referencias globales.

Strings UTF-8 y UTF-16

El lenguaje de programación Java usa UTF-16. Para una mayor practicidad, JNI proporciona métodos que funcionan también con UTF-8 modificado. La codificación modificada es útil para el código C porque codifica \u0000 como 0xc0 0x80 en lugar de 0x00. La ventaja es que puedes contar con strings de estilo C terminadas en cero, compatibles para usar con funciones de strings libc estándares. La desventaja es que no puedes pasar datos UTF-8 arbitrarios a JNI y esperar que funcione correctamente.

Para obtener la representación en UTF-16 de un String, usa GetStringChars. Ten en cuenta que las strings UTF-16 no terminan en cero y \u0000 está permitido, por lo que debes conservar la longitud de la string y el puntero jchar.

No olvides ejecutar Release para las strings obtenidas con Get. Las funciones de string muestran jchar* o jbyte*, que son punteros de estilo C para datos primitivos en lugar de referencias locales. Se garantizan como válidas hasta que se llama a Release, lo que significa que no se liberan cuando se muestra el método nativo.

Los datos pasados a NewStringUTF deben tener formato UTF-8 modificado. Un error común es leer datos de caracteres de un archivo o una transmisión de red y entregarlos a NewStringUTF sin filtrarlos. A menos que sepas que los datos son MUTF-8 válidos (o ASCII de 7 bits, que es compatible con el subconjunto), debes quitar los caracteres no válidos o convertirlos a un formato adecuado UTF-8 modificado. De lo contrario, es probable que la conversión a UTF-16 muestre resultados inesperados. CheckJNI, que está activado de forma predeterminada para emuladores, analiza strings y anula la VM si recibe entradas no válidas.

Antes de Android 8, solía ser más rápido operar con strings UTF-16, ya que Android no requería una copia en GetStringChars, mientras que GetStringUTFChars requería una asignación y una conversión a UTF-8. Android 8 cambió la representación String a fin de usar 8 bits por carácter para las strings ASCII (a fin de ahorrar memoria) y comenzó a usar un recolector de elementos no utilizados en movimiento. Estas funciones reducen en gran medida la cantidad de casos en los que ART puede proporcionar un puntero a los datos de String sin hacer una copia, incluso para GetStringCritical. Sin embargo, si la mayoría de las strings que procesa el código son cortas, es posible evitar la asignación y la desasignación en la mayoría de los casos con un búfer de pila asignada y GetStringRegion o GetStringUTFRegion. Por ejemplo:

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

Arreglos primitivos

JNI proporciona funciones para acceder a los contenidos de objetos de arreglos. Si bien se debe acceder a los arreglos de objetos de a una entrada por vez, los arreglos de primitivos pueden leerse y escribirse directamente como si estuvieran declarados en C.

Para que la interfaz sea lo más eficiente posible sin limitar la implementación de VM, la familia de llamadas Get<PrimitiveType>ArrayElements permite que el tiempo de ejecución muestre un puntero para los elementos reales o asigne un poco de memoria y cree una copia. De cualquier manera, se garantiza que el puntero sin procesar que se muestra sea válido hasta que se emita la llamada Release correspondiente (lo que implica que, si no se copiaron los datos, se fijará el objeto del arreglo y no podrá reubicarse durante la compactación de la pila). Debes ejecutar Release para cada arreglo que obtengas con Get. Además, si la llamada Get falla, debes asegurarte de que el código no intente ejecutar Release para un puntero NULL más adelante.

Puedes determinar si los datos se copiaron pasando un puntero que no sea NULL para el argumento isCopy, aunque esto no suele ser muy útil.

La llamada Release toma un argumento mode que puede tener uno de tres valores. Las acciones realizadas por el tiempo de ejecución dependen de si devolvió un puntero a los datos reales o una copia de él:

  • 0
    • Real: El objeto de arreglos no está fijado.
    • Copia: Los datos se vuelven a copiar. Se libera el búfer con la copia.
  • JNI_COMMIT
    • Real: No pasa nada.
    • Copia: Los datos se vuelven a copiar. No se libera el búfer con la copia.
  • JNI_ABORT
    • Real: El objeto de arreglos no está fijado. No se anulan las escrituras anteriores.
    • Copia: El búfer con la copia se libera. Todos los cambios hechos allí se pierden.

Una razón para verificar la marca isCopy es saber si necesitas llamar a Release con JNI_COMMIT después de hacer cambios en un arreglo; si alternas entre hacer cambios y ejecutar código que use el contenido del arreglo, es posible que puedas omitir la confirmación no operativa. Otra posible razón para verificar la marca es tener un manejo eficiente de JNI_ABORT. Por ejemplo, es posible que desees obtener un arreglo; modificarlo en su lugar, pasar fragmentos a otras funciones; y, después, descartar los cambios. Si sabes que JNI está haciendo una copia nueva para ti, no es necesario crear otra copia "editable". Si JNI te pasa el original, entonces sí es necesario que hagas tu propia copia.

Es un error común (repetido en el código de ejemplo) suponer que puedes omitir la llamada a Release si *isCopy es falso. Este no es el caso. Si no se asignó un búfer de copia, se debe fijar la memoria original y el recolector de elementos no utilizados no puede moverla.

Además, ten en cuenta que la marca JNI_COMMIT no libera el arreglo y, llegado el momento, deberás volver a llamar a Release con una marca diferente.

Llamadas de región

Existe una alternativa a llamadas como Get<Type>ArrayElements y GetStringChars que puede ser muy útil cuando solo deseas copiar datos. Ten en cuenta lo siguiente:

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

En este caso, se toma el arreglo, se copian los primeros elementos de bytes de len y luego se libera el arreglo. Según la implementación, la llamada a Get fijará o copiará el contenido del arreglo. El código copia los datos (quizá por segunda vez) y, luego, llama a Release. En este caso, JNI_ABORT garantiza que no haya una tercera copia.

Es posible obtener el mismo resultado de una manera más sencilla:

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

Este cambio tiene varias ventajas, por ejemplo:

  • Requiere una llamada de JNI en lugar de 2, lo que reduce la sobrecarga.
  • No requiere fijaciones ni copias adicionales de datos.
  • Reduce el riesgo de error del programador (no hay riesgo de olvidarse de llamar a Release después de que falla algo).

Del mismo modo, puedes usar la llamada Set<Type>ArrayRegion para copiar datos en un array y GetStringRegion o GetStringUTFRegion para copiar caracteres de un String.

Excepciones

No debes llamar a casi ninguna de las funciones de JNI mientras haya una excepción pendiente. Se espera que el código detecte la excepción (a través del valor de retorno de la función, ExceptionCheck o ExceptionOccurred) y la muestre, o borre la excepción y la maneje.

Las únicas funciones de JNI que puedes llamar cuando hay una excepción pendiente son las siguientes:

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

Muchas llamadas de JNI pueden generar una excepción, pero suelen proporcionar una forma más sencilla de verificar si hay fallas. Por ejemplo, si NewString muestra un valor que no es NULL, no es necesario comprobar si hay una excepción. Sin embargo, si llamas a un método (con una función como CallObjectMethod), siempre deberás verificar si hay una excepción, ya que el valor que se muestre no será válido si se genera una excepción.

Ten en cuenta que las excepciones que arroja el código administrado no desenrollan los marcos de pilas nativas. (Y las excepciones de C++, que no se aconseja en general en Android, no deben arrojarse al otro lado del límite de transición de JNI del código C++ al código administrado). Las instrucciones de Throw y ThrowNew de JNI solo establecen un puntero de excepción en el subproceso actual. Cuando regreses del código nativo al administrado, se anotará y manejará la excepción de forma adecuada.

El código nativo puede "atrapar" una excepción llamando a ExceptionCheck o ExceptionOccurred, y borrarla con ExceptionClear. Como siempre, descartar excepciones sin manejarlas puede ocasionar problemas.

No hay funciones integradas para manipular el objeto de Throwable en sí, por lo que, si quieres (por ejemplo) obtener la string de excepción, deberás encontrar la clase de Throwable; buscar el ID del método para getMessage "()Ljava/lang/String;"; invocarlo; y, si el resultado no es NULL, usar GetStringUTFChars para obtener algo que puedas entregar a printf(3) o el equivalente.

Verificación extendida

En JNI, hay muy poca comprobación de errores. Los errores suelen provocar una falla. Android también ofrece un modo llamado CheckJNI, en el que los punteros de la tabla de funciones JavaVM y JNIEnv se pasan a tablas de funciones que realizan una serie extendida de verificaciones antes de llamar a la implementación estándar.

Las comprobaciones adicionales incluyen lo siguiente:

  • Arreglos: Se intenta asignar un arreglo con tamaño negativo.
  • Punteros erróneos: Se pasa un jarray/jclass/jobject/jstring erróneo a una llamada de JNI o se pasa un puntero NULL a una llamada de JNI con un argumento que no admite valores NULL.
  • Nombres de clase: Se pasa algo que no es el estilo "java/lang/String" de nombre de clase a una llamada de JNI.
  • Llamadas críticas: Se realiza una llamada de JNI entre una obtención "crítica" y su correspondiente liberación.
  • ByteBuffers directos: se pasan argumentos incorrectos a NewDirectByteBuffer.
  • Excepciones: Se hace una llamada de JNI mientras hay una excepción pendiente.
  • JNIEnv*: Se usa un JNIEnv* del subproceso incorrecto.
  • jfieldIDs: Se usa un jfieldID NULL o un jfieldID para establecer un campo en un valor del tipo incorrecto (intentar asignar un StringBuilder a un campo String, por ejemplo), o se usa un jfieldID para un campo estático con el fin de establecer un campo de instancia o viceversa, o se usa un jfieldID de una clase con instancias de otra clase.
  • jmethodIDs: Se usa un tipo incorrecto de jmethodID cuando se realiza una llamada de JNI con Call*Method: tipo incorrecto de datos que se muestra, falta de coincidencia estática/no estática, tipo incorrecto para "esto" (llamadas no estáticas) o clase incorrecta (para llamadas estáticas).
  • Referencias: Se usa DeleteGlobalRef o DeleteLocalRef en el tipo de referencia incorrecto.
  • Modos de liberación: Se pasa un modo de liberación erróneo a una llamada de liberación (algo distinto de 0, JNI_ABORT o JNI_COMMIT).
  • Seguridad de tipos: Se devuelve un tipo incompatible desde tu método nativo (se devuelve un StringBuilder desde un método que declara que devuelve una String, por ejemplo).
  • UTF-8: Se pasa una secuencia de bytes UTF-8 modificados no válida a una llamada de JNI.

(Todavía no se verifica la accesibilidad de los métodos y campos; las restricciones de acceso no se aplican al código nativo).

Hay varias formas de habilitar CheckJNI.

Si estás utilizando el emulador, CheckJNI está activado de manera predeterminada.

Si tienes un dispositivo con derechos de administrador, puedes usar la siguiente secuencia de comandos para reiniciar el tiempo de ejecución con CheckJNI habilitado:

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

En cualquiera de estos casos, verás algo como esto en el resultado del logcat cuando se inicie el tiempo de ejecución:

D AndroidRuntime: CheckJNI is ON

Si tienes un dispositivo normal, puedes usar el siguiente comando:

adb shell setprop debug.checkjni 1

Esto no afectará a las apps que ya se estén ejecutando, pero cualquier app iniciada a partir de ese momento tendrá CheckJNI habilitado. (Cambiar la propiedad a cualquier otro valor o reiniciarla volverá a inhabilitar CheckJNI). En este caso, la próxima vez que se inicie una app, verás en el resultado del logcat algo similar a lo que se muestra a continuación:

D Late-enabling CheckJNI

También puedes configurar el atributo android:debuggable en el manifiesto de tu aplicación para activar CheckJNI solo para tu app. Ten en cuenta que las herramientas de compilación de Android harán esto automáticamente para ciertos tipos de compilación.

Bibliotecas nativas

Puedes cargar código nativo de bibliotecas compartidas con la biblioteca System.loadLibrary estándar.

En la práctica, las versiones anteriores de Android tenían errores en PackageManager que hacían que la instalación y la actualización de bibliotecas nativas no fueran procesos confiables. El proyecto ReLinker ofrece soluciones alternativas para ese y otros problemas de carga de bibliotecas nativas.

Llama a System.loadLibrary (o ReLinker.loadLibrary) desde un inicializador de clase estático. El argumento es el nombre de la biblioteca "sin adornar", por lo que, para cargar libfubar.so, pasarías en "fubar".

Si solo tienes una clase con métodos nativos, lo lógico es que la llamada a System.loadLibrary esté en un inicializador estático para esa clase. De lo contrario, tal vez te convenga realizar la llamada desde Application para asegurarte de que siempre se cargue la biblioteca y de que se cargue antes.

Hay dos formas de que el tiempo de ejecución encuentre los métodos nativos. Puedes registrarlos de forma explícita con RegisterNatives o puedes permitir que el tiempo de ejecución los busque de forma dinámica con dlsym. Las ventajas de RegisterNatives son que verificas por adelantado que los símbolos existan y, además, puedes tener bibliotecas compartidas más pequeñas y rápidas si no exportas nada más que JNI_OnLoad. La ventaja de permitir que el tiempo de ejecución descubra tus funciones es que hay menos código para escribir.

Para usar RegisterNatives, haz lo siguiente:

  • Proporciona una función JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void* reserved).
  • En tu JNI_OnLoad, registra todos los métodos nativos con RegisterNatives.
  • Compila con -fvisibility=hidden para que solo se exporte JNI_OnLoad de tu biblioteca. De esta manera, se produce un código más rápido y más pequeño, y se evitan posibles colisiones con otras bibliotecas cargadas en tu app (pero se crean seguimientos de pila menos útiles si esta falla en el código nativo).

El inicializador estático debe tener la siguiente apariencia:

Kotlin

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

Java

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

La función JNI_OnLoad debería verse de la siguiente manera si está escrita en 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 en su lugar el "descubrimiento" de métodos nativos, debes nombrarlos de una manera específica (consulta las especificaciones de JNI para obtener más detalles). Eso significa que, si la firma de un método es incorrecta, no lo sabrás hasta la primera vez que se invoque el método.

Cualquier llamada a FindClass realizada desde JNI_OnLoad resolverá las clases en el contexto del cargador de clases utilizado para cargar la biblioteca compartida. Cuando se llama desde otros contextos, FindClass usa el cargador de clases asociado con el método en la parte superior de la pila de Java; si no hay uno (porque la llamada proviene de un subproceso nativo que acaba de conectarse), utiliza el cargador de la clase "system" (sistema). El cargador de clases del sistema no conoce las clases de tu aplicación, por lo que no podrás buscar tus propias clases con FindClass en ese contexto. Esto hace que JNI_OnLoad sea un lugar conveniente para buscar y almacenar clases en caché: una vez que tengas una referencia global válida de jclass, podrás usarla desde cualquier subproceso conectado.

Llamadas nativas más rápidas con @FastNative y @CriticalNative

Los métodos nativos pueden tener anotaciones con @FastNative o @CriticalNative (pero no con ambos) para acelerar las transiciones entre el código administrado y el nativo. Sin embargo, estas anotaciones vienen con ciertos cambios en el comportamiento que deben considerarse cuidadosamente antes de usarlas. Si bien mencionamos de manera breve estos cambios a continuación, consulta la documentación para conocer los detalles.

La anotación @CriticalNative solo se puede aplicar a métodos nativos que no usan objetos administrados (en parámetros o valores que se muestran, o como un this implícito). Esta anotación cambia la ABI de transición de JNI. La implementación nativa debe excluir los parámetros JNIEnv y jclass de la firma de su función.

Mientras se ejecuta un método @FastNative o @CriticalNative, la recolección de elementos no utilizados no puede suspender el subproceso por tareas esenciales y es posible que se bloquee. No uses estas anotaciones para métodos de larga duración, incluidos los que suelen ser rápidos, pero generalmente no delimitados. En particular, el código no debe realizar operaciones de E/S significativas ni adquirir bloqueos nativos que puedan retenerse durante mucho tiempo.

Estas anotaciones se implementaron para el uso del sistema desde Android 8 y se convirtieron en la API pública probada por CTS en Android 14. Es probable que estas optimizaciones también funcionen también en dispositivos con Android 8 a 13 (aunque sin las sólidas garantías de CTS), pero la búsqueda dinámica de métodos nativos solo se admite en Android 12 y versiones posteriores. El registro explícito con JNI RegisterNatives es estrictamente necesario para ejecutarse en las versiones 8 a 11 de Android. Estas anotaciones se ignoran en Android 7. La falta de coincidencia de ABI para @CriticalNative generaría una ordenación de argumentos incorrecta y probables fallas.

En el caso de los métodos críticos para el rendimiento que necesitan estas anotaciones, te recomendamos registrar de manera explícita los métodos con RegisterNatives de JNI, en lugar de depender del "descubrimiento" de métodos nativos basado en nombres. Para obtener un rendimiento óptimo del inicio de la app, se recomienda incluir llamadores de los métodos @FastNative o @CriticalNative en el perfil de Baseline. A partir de Android 12, una llamada a un método nativo @CriticalNative desde un método administrado compilado es casi tan económica como una llamada no intercalada en C/C++, siempre que todos los argumentos se ajusten a los registros (por ejemplo, hasta 8 argumentos integrales y hasta 8 de punto flotante en arm64).

A veces, puede ser preferible dividir un método nativo en dos: uno muy rápido que puede fallar y otro que maneja los casos lentos. Por ejemplo:

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

Consideraciones para 64 bits

Para admitir arquitecturas con punteros de 64 bits, usa un campo long en lugar de un int cuando almacenes un puntero en una estructura nativa de un campo Java.

Funciones no compatibles y retrocompatibilidad

Se admiten todas las funciones de JNI 1.6, salvo las siguientes excepciones:

  • DefineClass no está implementado. Android no usa archivos de clase ni códigos de byte Java, por lo que el pasaje en datos de clase binaria no funciona.

En cuanto a la retrocompatibilidad de Android, es posible que debas tener en cuenta lo siguiente:

  • Búsqueda dinámica de funciones nativas

    Hasta Android 2.0 (Eclair), el carácter "$" no se había convertido de manera correcta a "_00024" durante las búsquedas de nombres de métodos. Para solucionar este problema, es necesario utilizar un registro explícito o sacar los métodos nativos de las clases internas.

  • Separa subprocesos

    Hasta Android 2.0 (Eclair), no era posible usar una función destructora pthread_key_create para evitar la verificación "thread should be detached before exit" (el subproceso debe estar desconectado antes de salir). (El tiempo de ejecución también usa una función destructora pthread key, por lo que sería una carrera para ver a quién se llama primero).

  • Referencias globales no seguras

    Hasta Android 2.2 (Froyo), no se habían implementado las referencias globales no seguras. Las versiones anteriores no permiten que se usen de ninguna manera. Puedes usar las constantes de la versión de la plataforma Android para probar la compatibilidad.

    Hasta Android 4.0 (Ice Cream Sandwich), las referencias globales poco seguras solo podían pasarse a NewLocalRef, NewGlobalRef y DeleteWeakGlobalRef. (La especificación recomienda encarecidamente a los programadores que creen referencias definitivas para las no seguras globales antes de hacer cualquier cosa con ellas, por lo que esto no debería ser un impedimento para nada).

    A partir de Android 4.0 (Ice Cream Sandwich), se pueden usar las referencias globales no seguras como cualquier otra de las referencias de JNI.

  • Referencias locales

    Hasta Android 4.0 (Ice Cream Sandwich), las referencias locales eran, en realidad, punteros directos. Ice Cream Sandwich agregó el direccionamiento indirecto necesario para admitir una mejor recolección de elementos no utilizados, pero eso quiere decir que muchos de los errores de JNI no se pueden detectar en versiones anteriores. Consulta Cambios de referencia local de JNI en ICS para obtener más información.

    En las versiones de Android anteriores a Android 8.0, la cantidad de referencias locales tenía un límite específico de la versión. A partir de Android 8.0, el sistema admite referencias locales ilimitadas.

  • Determinación del tipo de referencia con GetObjectRefType

    Hasta Android 4.0 (Ice Cream Sandwich), como consecuencia del uso de punteros directos (ver arriba), era imposible implementar correctamente GetObjectRefType. En su lugar, utilizábamos una heurística que analizaba la tabla de las referencias globales no seguras, los argumentos, la tabla de las referencias locales y la tabla de las referencias globales (en ese orden). La primera vez que encontraba tu puntero directo, informaba que la referencia era del tipo que estaba examinando en ese momento. Esto significaba, por ejemplo, que, si llamabas a GetObjectRefType en una jclass global igual a la jclass pasada como un argumento implícito para el método nativo estático, obtenías JNILocalRefType en lugar de JNIGlobalRefType.

  • @FastNative y @CriticalNative

    Hasta Android 7, se ignoraban estas anotaciones de optimización. La incompatibilidad de la ABI para @CriticalNative generaría un ordenamiento de argumentos incorrecto y probables fallas.

    La búsqueda dinámica de funciones nativas para los métodos @FastNative y @CriticalNative no se implementó en Android 8-10 y contiene errores conocidos en Android 11. Es probable que el uso de estas optimizaciones sin un registro explícito con JNI RegisterNatives provoque fallas en Android 8 a 11.

Preguntas frecuentes: ¿Por qué se muestra UnsatisfiedLinkError?

Cuando se trabaja con código nativo, no es raro ver un error como este:

java.lang.UnsatisfiedLinkError: Library foo not found

En algunos casos, significa lo que dice: no se encontró la biblioteca. En otros casos, la biblioteca existe, pero dlopen(3) no pudo abrirla, y los detalles del error se pueden encontrar en el mensaje de detalles de la excepción.

Razones comunes por las que puedes encontrar excepciones de biblioteca no encontrada:

  • La biblioteca no existe o no es accesible para la app. Usa adb shell ls -l <path> para verificar su presencia y permisos.
  • La biblioteca no se construyó con el NDK. Esto puede provocar dependencias en funciones o bibliotecas que no existen en el dispositivo.

Otra clase de errores de UnsatisfiedLinkError se ve así:

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

En logcat, verás:

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

Esto significa que el tiempo de ejecución trató de encontrar un método coincidente, pero no lo logró. A continuación, se indican algunas razones comunes para eso:

  • No se carga la biblioteca. Consulta el resultado del logcat para ver si hay mensajes sobre la carga de la biblioteca.
  • No se encuentra el método debido a una falta de coincidencia de nombre o firma. Las causas más comunes de eso pueden ser las siguientes:
    • Para la búsqueda diferida de métodos, no declarar funciones de C++ con extern "C" y no contar con visibilidad adecuada (JNIEXPORT). Ten en cuenta que, antes de Ice Cream Sandwich, la macro JNIEXPORT era incorrecta, por lo que usar una GCC nueva con un jni.h anterior no funcionará. Puedes usar arm-eabi-nm para ver los símbolos a medida que aparecen en la biblioteca. Si se ven alterados (algo así como _Z15Java_Foo_myfuncP7_JNIEnvP7_jclass en lugar de Java_Foo_myfunc), o si el tipo de símbolo es una "t" minúscula en lugar de una "T" mayúscula, debes ajustar la declaración.
    • Para registros explícitos, errores menores al ingresar la firma del método. Asegúrate de que lo que estés pasando a la llamada de registro coincida con la firma en el archivo de registro. Recuerda que "B" es byte y "Z" es boolean. Los componentes de nombre de clase en las firmas comienzan con "L", terminan con ";" y usan "/" para separar nombres de paquete/clase y "$" para separar nombres de clase interna (Ljava/util/Map$Entry;, por ejemplo).

Usar javah para generar automáticamente encabezados de JNI puede ayudar a evitar algunos problemas.

Preguntas frecuentes: ¿Por qué FindClass no encontró mi clase?

(Esta recomendación se puede aplicar cuando no se pueden encontrar métodos con GetMethodID o GetStaticMethodID, o campos con GetFieldID o GetStaticFieldID).

Asegúrate de que la string del nombre de clase tenga el formato correcto. Los nombres de las clases JNI comienzan con el nombre del paquete y se separan con barras diagonales, como java/lang/String. Si buscas una clase de arreglo, debes comenzar con la cantidad adecuada de corchetes y también debes unir la clase con "L" y ";". Por lo tanto, un arreglo unidimensional de String sería [Ljava/lang/String;. Si buscas una clase interna, usa "$" en lugar de ".". Usar javap en el archivo .class suele ser una buena manera de averiguar el nombre interno de la clase.

Si habilitas la reducción de código, asegúrate de configurar el código que se va a conservar. Es importante configurar reglas de conservación adecuadas, ya que, de lo contrario, el reductor de código podría quitar clases, métodos o campos que solo se usan desde JNI.

Si el nombre de clase se ve bien, es posible que el problema esté relacionado con el cargador de clases. FindClass desea iniciar la búsqueda de clases en el cargador de clases asociado con tu código. Examina la pila de llamadas, que se verá de la siguiente manera:

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

El mejor método es Foo.myfunc. FindClass encuentra el objeto ClassLoader asociado con la clase Foo y lo usa.

Eso suele funcionar bien. Puedes tener problemas si creas un hilo tú mismo (quizá si llamas a pthread_create y lo conectas con AttachCurrentThread). Así, no habrá marcos de pilas de tu aplicación. Si llamas a FindClass desde este subproceso, JavaVM se iniciará en el cargador de clases "system" en lugar de en el que esté asociado con tu aplicación, por lo que los intentos de encontrar clases específicas de la app fallarán.

Hay algunas maneras de solucionar eso, por ejemplo:

  • Realiza las búsquedas de FindClass una vez, en JNI_OnLoad, y almacena en caché las referencias de clase para uso posterior. Todas las llamadas de FindClass realizadas como parte de la ejecución de JNI_OnLoad usarán el cargador de clases asociado con la función que llamó a System.loadLibrary (esta es una regla especial proporcionada para hacer más conveniente la inicialización de bibliotecas). Si el código de tu app carga la biblioteca, FindClass usará el cargador de clases correcto.
  • Declara que tu método nativo tome un argumento de clase y, luego, pasa Foo.class para pasar una instancia de la clase a las funciones que la necesitan.
  • Almacena en caché una referencia al objeto ClassLoader en alguna ubicación conveniente y emite directamente llamadas a loadClass. Esto requiere un poco de esfuerzo.

Preguntas frecuentes: ¿Cómo comparto datos sin procesar con código nativo?

Es posible que te encuentres en una situación en la que necesites acceder tanto desde código administrado como desde código nativo a un búfer grande de datos sin procesar. Los ejemplos comunes incluyen la manipulación de mapas de bits o muestras de sonido. Hay dos enfoques básicos.

Puedes almacenar los datos en un byte[]. Eso permite un acceso muy rápido desde el código administrado. Sin embargo, en lo que respecta al código nativo, no se garantiza que puedas acceder a los datos sin tener que copiarlos. En algunas implementaciones, GetByteArrayElements y GetPrimitiveArrayCritical mostrarán punteros reales para los datos sin procesar en la pila administrada, pero, en otras, se asignará un búfer a la pila nativa y se copiarán los datos.

La alternativa es almacenar los datos en un búfer de bytes directos. Estos se pueden crear con la función java.nio.ByteBuffer.allocateDirect o la función NewDirectByteBuffer de JNI. A diferencia de los búferes de bytes regulares, el almacenamiento no se asigna a la pila administrada, y siempre se puede acceder directamente a este desde el código nativo (obtén la dirección con GetDirectBufferAddress). En función de cómo se implemente el acceso directo del búfer de bytes, el acceso a los datos de código administrado puede ser muy lento.

La elección de cuál usar depende de dos factores:

  1. ¿La mayoría de los accesos a los datos se realizarán desde el código escrito en Java o en C/C++?
  2. Si, finalmente, los datos se pasan a una API del sistema, ¿en qué formato deben estar? (Por ejemplo, si los datos se pasan a una función que toma un byte [], probablemente no sea aconsejable hacer el procesamiento en un ByteBuffer directo).

Si no hay una mejor opción, usa un búfer de bytes directos. La compatibilidad con ellos está integrada de manera directa en JNI y el rendimiento debería mejorar en futuras versiones.