Использование NDK в своих приложениях

Автор: | 27.02.2018

Приложения на Android, как правило, написаны на языке Java, с его элегантными объектно-ориентированным дизайном. Однако в случае, если необходимо преодолеть ограничения языка Java, такие как управление памятью и производительность, посредством программирования непосредственно в нативный интерфейс Android. Для этих целей, помимо Android SDK, Android предоставляет Native Developmemt Kit (NDK), реализующий поддержку разработки на C/C++.

NDK предоставляет все инструменты (компиляторы, библиотеки и заголовочные файлы) для создания приложений, которые получают доступ к устройству нативно. Нативный код обеспечит высокую производительность там, где Java имеет ограничения. С помощью NDK также можно управлять нативными процессами и физическими компонентами устройства, такими как датчики и сенсорный ввод. Кроме того, NDK может быть полезен в следующих случаях:

  • Извлечение дополнительной производительности из устройства, чтобы добиться низкой задержки или запуска приложений с интенсивными вычислениями, таких как игры или физическое моделирование.
  • Использование своих библиотек или библиотек других разработчиков, написанных на C/C++.

При сборке APK-файла, Gradle компилирует код на C/C++ в нативную библиотеку, после чего добавляет в APK-файл. Затем ваш код на Java сможет обращаться к библиотеке и её методам через инфраструктуру JNI (Java Native Interface).

Начало работы с NDK

Поддержка NDK осуществляется, начиная с версии Android Studio 2.2 и выше. Чтобы использовать NDK в своём приложении, нужно установить его. Для этого в Android Studio нужно открыть Configure и выбрать SDK Manager.

В открывшемся окне на вкладке SDK Tools нужно поставить галочки напротив выделенных элементов. После этого достаточно будет нажать Apply и Android Studio начнёт загрузку и установку.

  • CMake — утилита внешней сборки, которая работает вместе с Gradle для создания нативной библиотеки. Этот компонент не нужен в случае, если используется ndk-build.
  • LLDB — отладчик, который Android Studio использует для отладки нативного кода.
  • NDK — собственно набор инструментов для написания нативного кода на C/C++.

Теперь можно перейти к созданию нового проекта. В Configure your new project поставьте галочку Include C++ Support.

Затем идёт стандартная процедура создания проекта, заполняем все поля так, как хотим, после чего попадаем на экран Customize C++ Support.

В этом окне можно настроить следующие параметры:

  • C++ Standard — в раскрывающемся списке выбирается стандарт C++, который будет использоваться в приложении. Вариант Toolchain Default использует настройки CMake по умолчанию.
  • Exceptions Support — флажок, определяющий, нужно ли включить поддержку обработки исключений C++. Если включить флажок, то Android Studio добавит флаг -fexceptions в cppFlags в файле build.gradle уровня модуля, который Gradle передает в CMake.
  • Runtime Type Information Support — флажок, определяющий, нужно ли включить поддержку RTTI (Runtime Type Information — механизм, который определяет тип переменной или объекта во время выполнения программы). Если включить флажок, Android Studio добавит флаг -frtti в cppFlags в файле build.gradle уровня модуля, который Gradle передает в CMake.

После настройки Android Studio соберёт проект. Перейдя в список файлов, можно увидеть новые файлы, созданные для нативной библиотеки.

В папке cpp можно разместить все исходные файлы, заголовочные файлы и готовые библиотеки, которые нужно добавить в приложение.

Нельзя просто так взять и вызвать какой-либо из нативных методов с помощью Java-кода, для этого нужно реализовать метод, который будет вызывать особым образом. В качестве примера автоматически создаётся файл native-lib.cpp. Он предоставляет метод stringFromJNI(), который возвращает в приложение строку «Hello from C++«.

#include <jni.h>
#include <string>

extern "C"
JNIEXPORT jstring

JNICALL
Java_ru_androidtools_ndktest_MainActivity_stringFromJNI(
        JNIEnv *env,
        jobject /* this */) {
    std::string hello = "Hello from C++";
    return env->NewStringUTF(hello.c_str());
}

Известная проблема: Android Studio в настоящее время показывает только заголовочные файлы, которые соответствуют исходным файлам — даже если вы укажете другие заголовочные файлы в скрипте CMake.

В External Build Files можно увидеть скрипт сборки CMake или ndk-build. Аналогично тому, как build.gradle сообщает Gradle о том, как собрать приложение, CMake и ndk-build требуют, чтобы скрипт сборки знал, как создать свою нативную библиотеку. Для новых проектов Android Studio создаёт файл CMakeList.txt и помещает его в корневой каталог модуля. В данном случае, по умолчанию он выглядит следующим образом:

# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html

# Sets the minimum version of CMake required to build the native library.

cmake_minimum_required(VERSION 3.4.1)

# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.

add_library( # Sets the name of the library.
             native-lib

             # Sets the library as a shared library.
             SHARED

             # Provides a relative path to your source file(s).
             src/main/cpp/native-lib.cpp )

# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.

find_library( # Sets the name of the path variable.
              log-lib

              # Specifies the name of the NDK library that
              # you want CMake to locate.
              log )

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.

target_link_libraries( # Specifies the target library.
                       native-lib

                       # Links the target library to the log library
                       # included in the NDK.
                       ${log-lib} )

Чтобы к нативному коду можно было обращаться из приложения, в MainActivity.java нужно выполнить несколько операций, перечисленных ниже.

public class MainActivity extends AppCompatActivity {
  // 1
  static {
    System.loadLibrary("native-lib");
  }

  // 2
  public native String stringFromJNI();

  @Override protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    // 3
    TextView tv = findViewById(R.id.sample_text);
    tv.setText(stringFromJNI());
  }
}
  1. С помощью метода System.loadLibrary() мы загружаем библиотеку из папки cpp.
  2. Объявляем нативный метод загруженной библиотеки.
  3. Вызываем нативный метод библиотеки.

Запустим приложение и увидим строчку из нативного кода в TextView.

Вот так выглядит процесс запуска приложения с нативным кодом:

  1. Gradle вызывает внешний скрипт сборки CMakeLists.txt.
  2. CMake следует командам из скрипта сборки, чтобы скомпилировать исходный файл native-lib.cpp в общую библиотеку, и называет полученную библиотеку libnative-lib.so, который Gradle затем упаковывает в APK.
  3. Во время выполнения MainActivity приложения загружает нативную библиотеку, используя System.loadLibrary(). После этого метод библиотеки stringFromJNI() становится доступным для использования.
  4. MainActivity.onCreate() вызывает stringFromJNI(), который возвращает строку «Hello from C++» в TextView.

Примечание: Instant Run несовместим с компонентами проекта, написанными на нативном коде.

Анализ APK-файла

Чтобы убедиться, что нативная библиотека была добавлена в APK, можно этот APK проанализировать с помощью утилиты APK Analyzer. Для этого в Android Studio выберем в меню Build — Build APK(s).

После того, как APK будет собран, появится уведомление с предложением открыть папку, в которой он находится, либо проанализировать его. Выбираем analyze. Либо можно в меню выбрать Build — Analyze APK и указать путь до созданного APK (app/build/outputs/apk).

В Android Studio откроется результат анализа APK. Здесь можно увидеть, какой размер имеет собранный APK, сколько классов и методов включает в себя, список ресурсов и прочее.

Здесь можно увидеть, что собранный APK имеет следующие характеристики:

Характеристика Значение
Размер APK в несжатом виде 1.7 MB
Размер APK в сжатом виде 1.4 MB
Количество классов 1269
Количество методов 10359

Однако нас интересует наличие нативной библиотеки. Если раскрыть папку lib, занимающую 18,7% от размера APK, то можно увидеть там файл libnative-lib.so под разные архитектуры. Это и есть скомплиированная нативная библиотека.

Сравним, насколько меняется размер APK-файла по сравнению с приложением без использования NDK. Создадим пустой проект с надписью «Hello, world!» на экране. Соберём APK и посмотрим на его характеристики.

Характеристика Значение
Размер APK в несжатом виде 1.4 MB
Размер APK в сжатом виде 1.2 MB
Количество классов 1269
Количество методов 10357

В результате сравнения получаем, что размер APK за счет добавления нативной библиотеки изменяется незначительно.

Рассмотрим, как можно добавить свой нативный код в приложение.

Добавление исходных файлов

Чтобы добавить в cpp свои файлы, нужно нажать правой кнопкой мыши на папку cpp и выбрать C/C++ Source File.

В появившемся окне нужно написать имя исходного файла, а также выбрать расширение файла.

В раскрывающемся списке Type можно выбрать стандартные расширения файла, либо создать свои. Для этого нужно нажать на кнопку справа от раскрывающегося списка, после чего откроется окно настроек, где будет предложено создать тип и выбрать для него расширение исходного файла и заголовочного.

Если есть необходимость в создании заголовочного файла, можно поставить флажок на Create an associated header.

Применение нативного кода не ограничивается возвратом захардкоженных строк. Например, можно написать нативный код, который принимает от Java объект, работает с ним и возвращает результат работы.

Создадим Java-класс MeshData, который будет служить для хранения данных объекта.

public class MeshData {
  private int _facetCount;
  public float[] VertexCoords;

  public MeshData(int facetCount) {
    _facetCount = facetCount;
    VertexCoords = new float[facetCount];
    // заполняем массив значениями
    for (int i = 0; i < facetCount; ++i) {
      VertexCoords[i] = 10.0f * i;
    }
  }

  public int getFacetCount() {
    return _facetCount;
  }
}

Добавим созданный исходный файл test-lib.cpp в метод System.loadLibrary() активности и объявим метод getMemberFieldFromNative(), с помощью которого мы будем передавать объект из Java в нативный код.

public class MainActivity extends AppCompatActivity {
  static {
    System.loadLibrary("native-lib");
  }

  public native String stringFromJNI();

  public native float getMemberFieldFromNative(MeshData obj);

  ...

Откроем test-lib.cpp и добавим в него JNI-метод, который будет принимать объект MeshData и получать доступ к полю VertexCoords.

JNIEXPORT
jfloat
JNICALL
Java_ru_androidtools_ndktest_MainActivity_getMemberFieldFromNative(
        JNIEnv *env,
        jobject callingObject,
        jobject obj) //obj это экземпляр MeshData, который мы передали из Java
{
    float result = 0.0f;

    // получаем класс объекта
    jclass cls = env->GetObjectClass(obj);

    // получаем поле, [F это массив float
    jfieldID fieldId = env->GetFieldID(cls, "VertexCoords", "[F");

    // получаем поле объекта, возвращает JObject (потому что это массив)
    jobject objArray = env->GetObjectField(obj, fieldId);

    // Преобразовываем объект в jfloatarray
    jfloatArray *fArray = reinterpret_cast<jfloatArray *>(&objArray);

    jsize len = env->GetArrayLength(*fArray);

    // получаем элементы массива
    float *data = env->GetFloatArrayElements(*fArray, 0);

    for (int i = 0; i < len; ++i) {
        result += data[i];
    }

    // этот метод очень важен, поскольку копирует измененный массив обратно и помогает избежать утечек памяти
    env->ReleaseFloatArrayElements(*fArray, data, 0);

    return result;
}

Не забудьте в начале файла подключить библиотеку jni.h.

#include <jni.h>

Следующий JNI-метод возвращает значение.

int getFacetCount(JNIEnv *env, jobject obj) {
    jclass cls = env->GetObjectClass(obj);
    jmethodID methodId = env->GetMethodID(cls, "getFacetCount", "()I");
    int result = env->CallIntMethod(obj, methodId);

    return result;
}

JNIEXPORT
jint
JNICALL Java_ru_androidtools_ndktest_MainActivity_invokeMemberFuncFromNative(
        JNIEnv *env,
        jobject callingObject,
        jobject obj) {
    int facetCount = getFacetCount(env, obj);

    // возвращаем результат (в int)
    return facetCount;
}

После этого создаем объект Java в следующем JNI-методе чтобы вернуть его.

JNIEXPORT
jobject
JNICALL
Java_ru_androidtools_ndktest_MainActivity_createObjectFromNative(
        JNIEnv *env,
        jobject callingObject,
        jint param) {
    jclass cls = env->FindClass("ru/androidtools/ndktest/MeshData");
    jmethodID methodId = env->GetMethodID(cls, "<init>", "(I)V");
    jobject obj = env->NewObject(cls, methodId, param);

    return obj;
}

Поскольку мы не можем передавать список или объекты в нативный код, мы должны передавать в него массив. Для этого объявим в коде активности метод processObjectArrayFromNative().

public native int processObjectArrayFromNative(MeshData[] objArray);

Добавим JNI-метод, который будет считывать этот массив.

JNIEXPORT
jint
JNICALL
Java_ru_androidtools_ndktest_MainActivity_processObjectArrayFromNative(
        JNIEnv *env,
        jobject callingObject,
        jobjectArray objArray) {
    int resultSum = 0;

    int len = env->GetArrayLength(objArray);

    // получаем все объекты в массиве
    for (int i = 0; i < len; ++i) {
        jobject obj = (jobject) env->GetObjectArrayElement(objArray, i);
        resultSum += getFacetCount(env, obj);
    }

    return resultSum;
}

Примечание: Поскольку мы используем C++, в код нужно добавить extern «C». Это ключевое слово необходимо для того, чтобы сообщать компилятору о том, чтобы он не преобразовывал имена функций, а оставлял их такими, как в реализации. В противном случае во время работы приложения можно столкнуться с исключением «No implementation found».

В результате код test-lib.cpp выглядит следующим образом:

#include <jni.h>

extern "C"
{
int getFacetCount(JNIEnv *env, jobject obj) {
    jclass cls = env->GetObjectClass(obj);
    jmethodID methodId = env->GetMethodID(cls, "getFacetCount", "()I");
    int result = env->CallIntMethod(obj, methodId);

    return result;
}

JNIEXPORT
jfloat
JNICALL
Java_ru_androidtools_ndktest_MainActivity_getMemberFieldFromNative(
        JNIEnv *env,
        jobject callingObject,
        jobject obj)
{
    float result = 0.0f;

    jclass cls = env->GetObjectClass(obj);

    jfieldID fieldId = env->GetFieldID(cls, "VertexCoords", "[F");

    jobject objArray = env->GetObjectField(obj, fieldId);

    jfloatArray *fArray = reinterpret_cast<jfloatArray *>(&objArray);

    jsize len = env->GetArrayLength(*fArray);

    float *data = env->GetFloatArrayElements(*fArray, 0);

    for (int i = 0; i < len; ++i) {
        result += data[i];
    }

    env->ReleaseFloatArrayElements(*fArray, data, 0);

    return result;
}

JNIEXPORT
jint
JNICALL Java_ru_androidtools_ndktest_MainActivity_invokeMemberFuncFromNative(
        JNIEnv *env,
        jobject callingObject,
        jobject obj) {
    int facetCount = getFacetCount(env, obj);

    return facetCount;
}

JNIEXPORT
jobject
JNICALL
Java_ru_androidtools_ndktest_MainActivity_createObjectFromNative(
        JNIEnv *env,
        jobject callingObject,
        jint param) {
    jclass cls = env->FindClass("ru/androidtools/ndktest/MeshData");
    jmethodID methodId = env->GetMethodID(cls, "<init>", "(I)V");
    jobject obj = env->NewObject(cls, methodId, param);

    return obj;
}

JNIEXPORT
jint
JNICALL
Java_ru_androidtools_ndktest_MainActivity_processObjectArrayFromNative(
        JNIEnv *env,
        jobject callingObject,
        jobjectArray objArray) {
    int resultSum = 0;

    int len = env->GetArrayLength(objArray);

    for (int i = 0; i < len; ++i) {
        jobject obj = (jobject) env->GetObjectArrayElement(objArray, i);
        resultSum += getFacetCount(env, obj);
    }

    return resultSum;
}
}

Теперь в коде активности объявим оставшиеся методы и вызовем их. Результат работы выведем в TextView.

public class MainActivity extends AppCompatActivity {
  static {
    System.loadLibrary("native-lib");
  }

  public native String stringFromJNI();

  public native float getMemberFieldFromNative(MeshData obj);

  public native int invokeMemberFuncFromNative(MeshData obj);

  public native MeshData createObjectFromNative(int param);

  public native int processObjectArrayFromNative(MeshData[] objArray);

  @Override protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    // выводим сообщение из примера исходного файла
    String msg = stringFromJNI();

    // инициализируем объект MeshData в Java-коде и передаем его в методы
    MeshData obj = new MeshData(3);
    msg += "\n\nResult getMemberFieldFromNative: " + getMemberFieldFromNative(obj);
    msg += "\nResult invokeMemberFuncFromNative: " + invokeMemberFuncFromNative(obj);

    // инициализируем объект MeshData в нативном коде и возвращаем его в Java
    MeshData obj2 = createObjectFromNative(18);
    msg += "\n\nResult createObjectFromNative: " + obj2.getFacetCount();

    // обрабатываем массив объектов в нативном коде и возвращаем его в Java
    MeshData[] objArray = new MeshData[] {
        new MeshData(10), new MeshData(20)
    };

    int arrayRes = processObjectArrayFromNative(objArray);
    msg += "\n\nResult processObjectArrayFromNative: " + arrayRes;

    TextView tv = findViewById(R.id.sample_text);
    tv.setText(msg);
  }
}

Однако написать код исходного файла недостаточно. Если посмотреть на вкладку Project, то можно обнаружить, что там нет файла test-lib.cpp. Исправить это можно, сообщив CMake о его наличии.

Настройка CMake

Теперь, когда мы написали нативный код, нужно добавить test-lib.cpp в CMakeLists.txt, чтобы CMake скомпилировал его в библиотеку. По факту, в файле из примера уже есть весь нужный код, однако там нужно добавить созданный выше исходный файл.

Откроем CMakeLists.txt и найдем там команду add_library().

add_library( # Sets the name of the library.
             native-lib

             # Sets the library as a shared library.
             SHARED

             # Provides a relative path to your source file(s).
             src/main/cpp/native-lib.cpp
             )

Здесь в параметры передаётся название, которое будет дано скомпилированной библиотеке. После этого нужно указать тип создаваемой библиотеки (STATIC, SHARED или MODULE). Затем идет перечисление файлов, которые нужно скомпилировать.

В списке файлов нужно добавим путь до test-lib.cpp и синхронизируем проект, после чего CMake скомпилирует указанные файлы в библиотеку.

add_library( # Sets the name of the library.
             native-lib

             # Sets the library as a shared library.
             SHARED

             # Provides a relative path to your source file(s).
             src/main/cpp/native-lib.cpp
             src/main/cpp/test-lib.cpp
             )

В библиотеку можно скомпилировать столько файлов, сколько может потребоваться для работы.

В результате запускаем приложение и видим, что наша нативная библиотека работает.

Настройка Gradle

В случае, если вы добавляете NDK в проект вручную, в build.gradle модуля приложения нужно прописать некоторые команды.

Откроем build.gradle. В defaultConfig нужно добавить externalNativeBuild и указать внутри него флаги следующим образом.

android {
    compileSdkVersion 27
    defaultConfig {
        applicationId "ru.androidtools.ndktest"
        minSdkVersion 16
        targetSdkVersion 27
        versionCode 1
        versionName "1.0"
        externalNativeBuild {
            cmake {
                cppFlags ""
            }
        }
    }
    ...
}

Здесь перечисляются флаги, о которых мы говорили в начале статьи (поддержка исключений, RTTI). Если никакие флаги не нужны, просто оставляем поле пустым.

Затем нужно передать в Gradle скрипт, по которому CMake будет собирать нативную библиотеку. Для этого внутри android нужно добавить externalNativeBuild и передать в него имя файла скрипта сборки.

android {
    ...
    externalNativeBuild {
        cmake {
            path "CMakeLists.txt"
        }
    }
}

На этом конфигурация Gradle завершается и можно собирать приложение.

Использование NDK в своих приложениях: 4 комментария

  1. Тим

    Коллеги, что вы можете сказать про новшество в NDK 17?

    Убрана поддeржка armeabi, из-за чего получаем ошибку:

    ABIs [armeabi] are not supported for platform. Supported ABIs are [armeabi-v7a, arm64-v8a, x86, x86_64].

    И что странно, из-за этого перестало поддреживаться множество современных девайсов, среди которых Moto G5, которому всего год

    Для чего убрали поддержку armeabi и есть ли смысл возвращаться на предыдущий NDK?

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *