Sugerencias de JNI

JNI es la interfaz nativa de Java. Define una forma para que el código de byte que Android compila del 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 neutral respecto a los proveedores, admite código de carga de bibliotecas dinámicas compartidas y, aunque a veces puede ser engorrosa, es bastante eficiente.

Nota: Debido a que Android compila Kotlin en código de byte 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 características 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 de JNI globales y conocer dónde se crean y borran, usa la vista JNI heap en el 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.
  • Evita la comunicación asíncrona entre código escrito en un lenguaje de programación administrado y código escrito en C++ cuando sea posible. De este modo, la interfaz JNI será más fácil de mantener. En general, para simplificar las actualizaciones de IU asíncronas, se mantiene la actualización de IU asíncrona en el mismo lenguaje que la IU. Por ejemplo, en lugar de invocar una función C++ del procesamiento de IU en código Java mediante JNI, conviene hacer una devolución de llamada entre dos procesamientos en el lenguaje de programación Java, con uno que hace una llamada de bloqueo C++ y después notifica al procesamiento de IU cuando la llamada de bloqueo se completa.
  • Minimiza la cantidad de procesamientos que tienen que tocar o ser tocados por JNI. Si tienes que usar conjuntos de subprocesos en los lenguajes Java y C++, intenta que la comunicación de JNI sea entre los propietarios de los conjuntos en lugar de entre subprocesos de trabajadores individuales.
  • Mantén el código de la interfaz en una baja cantidad de ubicaciones de origen C++ y Java para 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 el primer argumento.

La JNIEnv se usa para almacenamiento local de subprocesos. Por esa razón, no es posible compartir una JNIEnv entre subprocesos. Si un fragmento de código no tiene otra manera de obtener su JNIEnv, deberías compartir la JavaVM y usar GetEnv para descubrir la JNIEnv del subproceso. (Si es que tiene una; consulta AttachCurrentThread a continuación).

Las declaraciones C de JNIEnv y JavaVM son diferentes de las declaraciones C++. El archivo incluido "jni.h" proporciona diferentes typedefs en función de si se incluye en C o C++. Por esa razón, no es una buena idea incluir argumentos JNIEnv en archivos de encabezado incluidos en los dos lenguajes. (En otras palabras, si tu archivo de encabezado requiere #ifdef __cplusplus, quizás debas hacer trabajos adicionales si algo en ese encabezado se refiere a JNIEnv).

Subprocesos

Todos los subprocesos son Linux, programados por el kernel. Suelen iniciarse de código administrado (usando Thread.start), pero también pueden crearse en otro lado y luego adjuntarse a la JavaVM. Por ejemplo, un subproceso que comienza con pthread_create puede adjuntarse con las funciones de JNI AttachCurrentThread o AttachCurrentThreadAsDaemon. Un subproceso sin adjuntar, no tiene JNIEnv ni puede realizar llamadas de JNI.

Adjuntar un subproceso creado de manea nativa hace que se construya un objeto java.lang.Thread y se agregue al ThreadGroup principal, lo que hace que sea visible para el depurador. Llamar a AttachCurrentThread en un subproceso que ya se adjuntó es una no-op.

Android no suspende subprocesos que ejecuten código nativo. Si está en proceso 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 adjuntados mediante JNI deben llamar a DetachCurrentThread antes de salir. Si codificar eso directamente es raro, 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 exista el subproceso y llamará DetachCurrentThread desde allí. (Usa esa clave con pthread_setspecific para almacenar la JNIEnv en almacenamiento local de subprocesos; de esa manera, se pasará al destructor como el 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, que en Android es poco común, pero no imposible. Sin embargo, ten en cuenta que jclass es una referencia de clase y debe protegerse con una llamada a NewGlobalRef (consulta la próxima 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 ejecute las búsquedas de ID. El código se ejecutará una vez, cuando se inicialice la clase. Si alguna vez la clase se descarga 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 devueltos por una función de JNI son una "referencia local". Eso 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.

Eso se aplica a todas las subclases de jobject, incluidos 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 devuelve una referencia global. La validez de la referencia global está garantizada hasta que llamas a DeleteGlobalRef.

Este patrón suele usarse para almacenar en caché una jclass devuelta de FindClass, por ejemplo:

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, es posible que los valores devueltos de llamadas consecutivas a NewGlobalRef en el mismo objeto sean diferentes. Para ver si dos referencias se refieren 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 de 32 bits que representa un objeto puede ser distinto de una invocación de un método a otra. Además, es posible que dos objetos diferentes tengan el mismo valor de 32 bits en llamadas consecutivas. No uses valores jobject como claves.

Los programadores están obligados a "no asignar de manera excesiva" referencias locales. En términos prácticos, eso quiere decir que, si estás creando una gran cantidad de referencias locales, quizás mientras ejecutas varios objetos, deberías liberarlas de forma manual con DeleteLocalRef en lugar de dejar que JNI lo haga por ti. La implementación solo está obligada a reservar ranuras para 16 referencias locales. Si necesitas más, deberías 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 de objeto, y no deberían pasarse a NewGlobalRef. Los punteros de datos sin procesar que devuelven funciones como GetStringUTFChars y GetByteArrayElements tampoco son objetos. (Pueden pasarse entre subprocesos y son válidos hasta que se realice la llamada de liberación coincidente).

Vale la pena mencionar un caso inusual. Si adjuntas un subproceso nativo con AttachCurrentThread, el código que estás ejecutando nunca liberará referencias locales de manera automática hasta que no se separe el subproceso. Cualquier referencia local que crees deberá borrarse de forma manual. En general, es probable que cualquier código nativo que cree referencias locales en un bucle requiera algunas eliminaciones manuales.

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

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 de eso es que puedes contar con tener strings de estilo de 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 salga bien.

Si es posible, suele ser más rápido operar con strings UTF-16. En la actualidad, Android no requiere una copia en GetStringChars. En cambio, GetStringUTFChars requiere una asignación y conversión a UTF-8. 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, además del puntero jchar.

No olvides liberar (Release) las strings que obtienes (Get). Las funciones de las strings devuelven jchar* o jbyte*, que son punteros de estilo de C a datos primitivos, en lugar de referencias locales. Tienen validez garantizada hasta que se llama a Release, lo que significa que no se liberan cuando vuelve 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 una secuencia de red o archivo y entregarlos a NewStringUTF sin filtrarlos. A menos que sepas que los datos son MUTF-8 válidos (o 7-bit ASCII, 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 arroje resultados inesperados. CheckJNI, que está activado de manera predeterminada para emuladores, analiza strings y aborta la VM si recibe entradas no válidas.

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 a la vez, los arreglos de primitivos pueden leerse y escribirse directamente como si estuvieran declarados en C.

Para hacer que la interfaz sea lo más eficiente posible sin limitar la implementación de la VM, la familia de llamadas Get<PrimitiveType>ArrayElements permite que el tiempo de ejecución devuelva un puntero a los elementos reales o asigne algo de memoria y haga una copia. De cualquier manera, se garantiza la validez del puntero sin procesar hasta que se emita la llamada a Release correspondiente (lo que implica que, si no se copiaron los datos, se fijará el objeto de arreglo y no podrá reubicarse como parte de la compactación del montón). Debes liberar (Release) todos los arreglos que obtienes (Get). Además, si falla la llamada Get, debes asegurarte de que tu código no intente liberar (Release) un puntero NULL más tarde.

Puedes determinar si los datos se copiaron pasando un puntero no NULL para el argumento isCopy. Eso 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. El búfer con la copia no se libera.
  • JNI_ABORT
    • Real: El objeto de arreglos no está fijado. Las escrituras anteriores no se abortan.
    • Copia: El búfer con la copia se libera. Todos los cambios hechos allí se pierden.

Una razón para comprobar la marca isCopy es para saber si necesitas llamar a Release con JNI_COMMIT después de hacer cambios en un arreglo; si no alternas entre hacer cambios y ejecutar código que usa los contenidos del arreglo, es posible que puedas omitir la confirmación de no-op. Otra posible razón para verificar la marca es para 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 (se repite en el código de ejemplo) asumir 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, debe fijarse 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 que, en algún momento, deberás volver a llamar a Release con una marca diferente.

Llamadas de región

Existe una alternativa a las llamadas como Get<Type>ArrayElements y GetStringChars que puede ser muy útil cuando todo lo que deseas hacer es copiar datos hacia adentro o fuera. Ten en cuenta lo siguiente:

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

Eso toma el arreglo, copia los primeros elementos de bytes len para sacarlos y luego libera el arreglo. Según la implementación, la llamada a Get fijará o copiará los contenidos del arreglo. El código copia los datos (quizás por segunda vez) y, luego, llama a Release. En este caso, JNI_ABORT garantiza que no hay posibilidad de 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 utilizar la llamada a Set<Type>ArrayRegion para copiar datos en un arreglo y GetStringRegion o GetStringUTFRegion para copiar caracteres y quitarlos de una String.

Excepciones

No debes llamar a casi ninguna de las funciones de JNI mientras haya una excepción pendiente. Se espera que tu código note la excepción (a través del valor de retorno de la función, ExceptionCheck o ExceptionOccurred) y devuelva 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, a menudo, proporcionan una forma más sencilla de verificar si hay fallas. Por ejemplo, si NewString devuelve un valor que no sea NULL, no necesitas verificar una excepción. Sin embargo, si llamas un método (usando una función como CallObjectMethod), siempre debes verificar si hay una excepción, ya que el valor de retorno no será válido si se lanzó una excepción.

Ten en cuenta que las excepciones arrojadas por código interpretado no deshacen los marcos de pilas nativas y Android todavía no es compatible con excepciones C++. Las instrucciones Throw y ThrowNew de JNI solo establecen un puntero de excepción en el subproceso actual. Al regresar del código nativo al administrado, la excepción se anotará y manejará de manera apropiada.

Para "atrapar" una excepción, el código nativo puede llamar a ExceptionCheck o ExceptionOccurred y borrarla con ExceptionClear. Como siempre, descartar excepciones sin manejarlas puede provocar problemas.

No hay funciones integradas para manipular el objeto Throwable en sí, por lo tanto, si quieres, por ejemplo, obtener la cadena de excepción, deberás encontrar la clase Throwable, buscar el ID del método de getMessage "()Ljava/lang/String;", invocarlo y, si el resultado es no NULL, usar GetStringUTFChars para obtener algo que puedas entregarle a printf(3) o su 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 erróneos 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 el tipo incorrecto de jmethodID cuando se hace una llamada Call*Method de JNI; tipo de retorno incorrecto, desajuste estático/no estático, tipo incorrecto para "this" (para llamadas no estáticas) o clase incorrecta (para llamadas estáticas).
  • Referencias: Se usa DeleteGlobalRef/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, verás algo como se muestra a continuación en el resultado del logcat la próxima vez que se inicie una app:

D Late-enabling CheckJNI

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

Bibliotecas nativas

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

En la práctica, las versiones anteriores de Android tenían errores en PackageManager que provocaban que la instalación y 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 "no representativo" de la biblioteca. Por eso, para cargar libfubar.so, deberías pasar "fubar".

Si solo tienes una clase con métodos nativos, tiene sentido que la llamada a System.loadLibrary esté en un inicializador estático para esa clase. De lo contrario, es posible que quieras realizar la llamada desde Application de manera que sepas que la biblioteca siempre está cargada y siempre se carga de manera anticipada.

Hay dos formas de que el tiempo de ejecución encuentre los métodos nativos. Puedes registrarlos de manera explícita con RegisterNatives o puedes dejar que el tiempo de ejecución los busque de manera dinámica con dlsym. Las ventajas de RegisterNatives son que obtienes una verificación por adelantado de que los símbolos existen, además puedes contar con bibliotecas compartidas más pequeñas y rápidas, ya que solo exportas 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 tus métodos nativos usando RegisterNatives.
  • Desarrolla con-fvisibility=hidden para que solo se exporte JNI_OnLoad de tu biblioteca. Esto produce código más rápido y más pequeño, y evita posibles colisiones con otras bibliotecas cargadas en tu app (pero crea seguimientos de pila menos útiles si la app se bloquea 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 debe tener una apariencia similar a la siguiente 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(nativeFoo)},
            {"nativeBar", "(Ljava/lang/String;I)Z", reinterpret_cast(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 que se utilizó para cargar la biblioteca compartida. Cuando se realiza la llamada 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 se realiza desde un subproceso que se acaba de adjuntar), usa el cargador de clases del "sistema". Este no conoce las clases de tu app, de manera que no podrá buscar tus clases con FindClass en ese contexto. Por lo tanto, JNI_OnLoad es un lugar conveniente para buscar y almacenar clases en caché: una vez que tengas un objeto jclass válido, puedes usarlo desde cualquier subproceso adjunto.

Consideraciones para 64 bits

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

Características no compatibles y compatibilidad con modelos anteriores

Se admiten todas las características de JNI 1.6, salvo las siguientes excepciones:

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

Para la compatibilidad con versiones anteriores 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.

  • Separación de subprocesos

    Hasta Android 2.0 (Eclair), no era posible usar una función destructora pthread_key_create para evitar la verificación "el subproceso debe estar separado antes de salir". (El tiempo de ejecución también usa una función destructora pthread key, por lo que sería una carrera 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 no seguras solo podían pasarse a NewLocalRef, NewGlobalRef y DeleteWeakGlobalRef. (La especificación alienta mucho a los programadores a crear referencias completas para las no seguras globales antes de hacer cualquier cosa con ellas, por lo que esto no debería ser para nada un impedimento).

    A partir de Android 4.0 (Ice Cream Sandwich) en adelante, las referencias globales no seguras pueden usarse 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 tiene 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 (consultar sección anterior), era imposible implementar GetObjectRefType de manera correcta. En su lugar, utilizamos una heurística que analizó 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 encontró tu puntero directo, informaba que la referencia era del tipo que estaba examinando en ese momento. Eso quería decir, por ejemplo, que si llamabas a GetObjectRefType en una jclass global que coincidía de casualidad con la jclass pasada como un argumento implícito al método nativo estático, tendrías JNILocalRefType en lugar de JNIGlobalRefType.

Preguntas frecuentes: ¿Por qué obtengo el error 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, la biblioteca existe, pero dlopen(3) no la pudo abrir 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 la app no pudo acceder a ella. Usa adb shell ls -l <path> para verificar la 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.

Otros tipos de errores UnsatisfiedLinkError tienen la siguiente apariencia:

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, un error al declarar funciones de C++ con extern "C" y visibilidad adecuada (JNIEXPORT). Ten en cuenta que antes de Ice Cream Sandwich, la macro JNIEXPORT era incorrecta, por lo que usar un GCC nuevo con un jni.h antiguo no funcionará. Puedes usar arm-eabi-nm para ver los símbolos como aparecen en la biblioteca; si se ven alterados (por ejemplo, _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 modificar 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 ";", usa "/" para separar los nombres de paquetes/clases, y usa '$' para separar los nombres de las clases internas (Ljava/util/Map$Entry;, por ejemplo).

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

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

(La mayoría de estos consejos también se aplican a errores para 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 clase de JNI comienzan con el nombre del paquete y se separan con barras, como java/lang/String. Si buscas una clase de arreglos, debes comenzar con el número apropiado de corchetes y también debes encapsular la clase con 'L' y ';'. Por ejemplo, un arreglo unidimensional de String sería [Ljava/lang/String;. Si buscas una clase interna, usa "$" en lugar de ".". En general, usar javap en el archivo .class es una buena forma de averiguar el nombre interno de tu clase.

Si usas ProGuard, asegúrate de que ProGuard no haya quitado tu clase. Eso puede pasar si tu clase/método/campo solo se usa desde JNI.

Si el nombre de clase se ve bien, es posible que el problema sea 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 será similar a la siguiente:

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

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

Eso suele funcionar bien. Puedes meterte en problemas si creas un subproceso (quizás llamando a pthread_create y luego adjuntándolo con AttachCurrentThread). Ahora, no hay marcos de pilas de tu app. Si llamas a FindClass desde este subproceso, la JavaVM se iniciará en el cargador de clases del "sistema", en lugar de en el que está asociado con tu app, por lo que fallarán los intentos de encontrar clases específicas de apps.

Hay algunas maneras de solucionar eso, por ejemplo:

  • Haz las búsquedas FindClass una vez, en JNI_OnLoad, y almacena en caché las referencias de clase para usar después. Cualquier llamada a FindClass realizada como parte de la ejecución de JNI_OnLoad usará el cargador de clases asociado con la función que llamó a System.loadLibrary (esta es una regla especial, que se proporciona para que la inicialización de la biblioteca sea más conveniente). 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 pase Foo.class a fin de pasar una instancia de la clase a las funciones que la necesitan.
  • Almacena en caché una referencia al objeto ClassLoader para que esté a mano y emite llamadas a loadClass de manera directa. 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 a un búfer grande de datos sin procesar tanto desde código administrado como nativo. 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 devolverán los punteros reales a los datos sin procesar en el montón administrado, pero en otras asignarán un búfer en el montón nativo y copiarán los datos.

La alternativa es almacenar los datos en un buffer de byte directo. Esos búferes se pueden crear con java.nio.ByteBuffer.allocateDirect o la función NewDirectByteBuffer de JNI. A diferencia de los búferes de bytes comunes, el almacenamiento no se asigna en el montón administrado y siempre puede accederse de manera directa desde el código nativo (obtén la dirección con GetDirectBufferAddress). Según cómo se implementa el acceso directo al búfer de bytes, el acceso a los datos desde el 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 de datos se realizarán desde el código escrito en Java o en C/C++?
  2. Si los datos finalmente se pasan a una API del sistema, ¿en qué formato deben estar? (Por ejemplo, si los datos finalmente se pasan a una función que toma un byte[], realizar el procesamiento en un ByteBuffer directo puede ser imprudente).

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