Как собрать нативную библиотеку для Android

от автора

Собрать и заставить работать приложение с небольшим количеством нативного кода несложно. Если же вы хотите использовать нативную библиотеку, в которой много файлов, становится труднее. Сложность в том, что нативные библиотеки распространяются в виде исходного кода, который нужно компилировать под нужную архитектуру процессора. На примере аудиокодека Opus я покажу, как это сделать.

image

В своей предыдущей статье я на примере аудиокодека Opus показал, как решить задачу сборки нативной библиотеки для Android, но в проект был добавлен весь исходный код Opus, а так делать не хочется. По-хорошему нужно иметь в проекте уже собранные .so или .a, и используя их, писать свои врапперы на C/C++ и собирать приложение. Об этом и пойдёт речь в статье. На примере того же аудиокодека Opus я покажу, как с помощью NDK получить скомпилированные .so и .a, чтобы далее использовать их в Android-проекте.

Чтобы использовать любую нативную библиотеку, нам нужно написать враппер на C/C++, в котором мы будем вызывать методы самой библиотеки. Мы скомпилируем и соберём библиотеку Opus как статическую (.a).

Почему статическая

Мы вызываем функцию в нашем C файле, которая описана в другой библиотеке. Для этого в начале файла пишем #include <fileWithHeaders.h>. Далее на этапе компиляции C файла, если библиотека статическая(.a), то вместо #include <fileWithHeaders.h> компилятор подставит весь код этой функции. Если динамическая(.so), то подставит только ссылку на функцию. Таким образом, при использовании статической библиотеки у нас есть весь код, который нам необходим из другой библиотеки, а при использовании динамической код будет подгружаться динамически.

Использование .so в зависимостях может уменьшить размер нашей итоговой библиотеки. Это было бы так, если бы Opus был стандартной библиотекой, но так как её нет в Android, мы должны её предоставить. Поэтому итоговый размер нашего libjniopus.so будет одинаковым, что при использовании libopus.a, что при использовании libopus.so.

Для компиляции нативных библиотек из исходников в NDK c версии 19 есть готовые удобные инструменты «из коробки». В документации описано, как их использовать. Описание довольно короткое, поэтому человек, не имеющий опыта в сборке нативных библиотек, неизбежно столкнётся со сложностями. Как их решить, я разберу ниже.

Собираем Opus из исходников

Сначала скачиваем исходники: либо из репозитория (git clone), либо архивом. У Opus есть Autoconf — утилита, которая автоматически создаёт конфигурационные скрипты. В проекте с Autoconf можно задать toolchain для компиляции, используя ENV-переменные. Autoconf, как правило, работает только на Unix подобных системах. Поэтому если у вас Windows, то вам, скорее всего, придётся использовать средства эмуляции.

В итоге мы хотим получить 4 файла библиотеки с расширением .a по одной на каждую архитектуру процессора: armeabi-v7a, arm64-v8a, x86, x86-64.

Для каждой архитектуры нужно задать свои ENV-переменные. Общими будут только NDK, HOST_TAG и TOOLCHAIN. В Linux, ENV-переменную для текущей сессии терминала можно задать, используя команду export:

export NDK=/home/vital/Android/Sdk/ndk/20.1.5948944 export HOST_TAG=linux-x86_64 export TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/$HOST_TAG

Однако заданные ENV-переменные будут существовать, пока активна текущая сессия терминала, и только в ней. Если вы откроете другую сессию, то там этих переменных не будет. Чтобы каждый раз не прописывать ENV-переменные заново, можно добавить их в конец файла .bashrc, который находится в домашнем каталоге (~). Перед запуском каждой сессии терминала запускается .bashrc. Поэтому переменные, объявленные в нём, будут существовать во всех сессиях терминала. Вот ENV-переменные, которые отличаются в зависимости от архитектуры:

# arm64-v8a export AR=$TOOLCHAIN/bin/aarch64-linux-android-ar export AS=$TOOLCHAIN/bin/aarch64-linux-android-as export CC=$TOOLCHAIN/bin/aarch64-linux-android21-clang export CXX=$TOOLCHAIN/bin/aarch64-linux-android21-clang++ export LD=$TOOLCHAIN/bin/aarch64-linux-android-ld export RANLIB=$TOOLCHAIN/bin/aarch64-linux-android-ranlib export STRIP=$TOOLCHAIN/bin/aarch64-linux-android-strip  # armeabi-v7a export AR=$TOOLCHAIN/bin/arm-linux-androideabi-ar export AS=$TOOLCHAIN/bin/arm-linux-androideabi-as export CC=$TOOLCHAIN/bin/armv7a-linux-androideabi21-clang export CXX=$TOOLCHAIN/bin/armv7a-linux-androideabi21-clang++ export LD=$TOOLCHAIN/bin/arm-linux-androideabi-ld export RANLIB=$TOOLCHAIN/bin/arm-linux-androideabi-ranlib export STRIP=$TOOLCHAIN/bin/arm-linux-androideabi-strip  # x86 export AR=$TOOLCHAIN/bin/i686-linux-android-ar export AS=$TOOLCHAIN/bin/i686-linux-android-as export CC=$TOOLCHAIN/bin/i686-linux-android21-clang export CXX=$TOOLCHAIN/bin/i686-linux-android21-clang++ export LD=$TOOLCHAIN/bin/i686-linux-android-ld export RANLIB=$TOOLCHAIN/bin/i686-linux-android-ranlib export STRIP=$TOOLCHAIN/bin/i686-linux-android-strip  # x86-64 export AR=$TOOLCHAIN/bin/x86_64-linux-android-ar export AS=$TOOLCHAIN/bin/x86_64-linux-android-as export CC=$TOOLCHAIN/bin/x86_64-linux-android21-clang export CXX=$TOOLCHAIN/bin/x86_64-linux-android21-clang++ export LD=$TOOLCHAIN/bin/x86_64-linux-android-ld export RANLIB=$TOOLCHAIN/bin/x86_64-linux-android-ranlib export STRIP=$TOOLCHAIN/bin/x86_64-linux-android-strip

Сначала скомпилируем под arm64-v8a, поэтому закомментируем в .bashrc объявление ENV-переменных для остальных архитектур. Стоит отметить, что в переменных CC и CXX есть цифра 21, которая очень похожа на Android API level. Так и есть, а если быть точнее, то это ваша minSdkVersion. В нашем примере это 21, но если у вас другие потребности, можете смело поменять. В NDK доступны версии с 16 по 29 для 32-разрядных ABI (armeabi-v7a и x86) и с 21 по 29 для 64-разрядных ABI (arm64-v8a и x86-64).

Итак, мы объявили ENV-переменные, которые определяют toolchain для компиляции под выбранную архитектуру, и выкачали репозиторий с исходниками Opus. Теперь открываем Readme в папке с исходниками Opus и видим, что для компиляции библиотеки надо всего-навсего запустить:

$ ./autogen.sh $ ./configure $ make

Но не всё так просто. Таким образом мы скомпилируем библиотеку под архитектуру нашей рабочей машины, а нам надо под 4 архитектуры мобильных девайсов.

В NDK с версии r19 «из коробки» идут toolchains. Как и показано в примере на странице, нужно выставить ENV-переменные (выставили их выше в .bashrc), соответствующие архитектуре, под которую компилируется библиотека. Затем нужно запустить команду configure с соответствующим аргументом host. Для arm64-v8a получается такое:

$ source ~/.bashrc $ ./autogen.sh $ ./configure --host aarch64-linux-android $ make

source ~/.bashrc необходимо запускать, чтобы изменения переменных среды для сборки «подхватывались» текущей сессией терминала без перезапуска.

После выполнения всех вышеописанных команд, в папке с исходниками Opus появится папка .libs. В ней будут находиться все артефакты компиляции, в том числе и нужный нам libopus.a.

Далее в .bashrc комментим/раскомменчиваем, чтобы активными были ENV-переменные для armeabi-v7a, и прописываем команды уже с другим аргументом host:

$ source ~/.bashrc $ ./configure --host armv7a-linux-androideabi $ make

./autogen.sh не выполняем, так как он нужен был только для первоначальной генерации конфигурационных файлов и того самого configure, который мы запускаем.

В этот раз команда make завершится ошибкой. Я долго не понимал, из-за чего так происходит. Поискав в интернете похожие проблемы, понял, что файлы и тесты, которые создаются для определённой архитектуры, во время компиляции не удаляются автоматически после переконфигурации на другую архитектуру (запуск configure с другим host). А так как в папке с исходниками, в которой появляются все артефакты сборки, и так много файлов, понять, какие из них надо удалять потом, очень сложно.

Решение оказалось довольно простым. Можно запускать все команды для конфигурации и компиляции, не находясь в папке с исходниками Opus. Тогда можно создать свою отдельную папку для каждой архитектуры, где будут все артефакты сборки и временные файлы для этой архитектуры.

Теперь всего-то нужно создать 4 папки и последовательно сделать одни и те же действия по выставлению ENV-переменных и прописыванию команд с нужным аргументом. Это слишком долго и скучно, поэтому есть отличная возможность написать bash-скрипт, который всё это сделает за нас. Вот такой небольшой скрипт получился:

#!/bin/bash export NDK=/home/vital/Android/Sdk/ndk/20.1.5948944 export HOST_TAG=linux-x86_64 export TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/$HOST_TAG  if [[ $# -ne 3 && $# -ne 2 ]]  then   echo "You should specify at least minSdkVersion and path to Opus directory"   echo "Example ./buildOpus.sh 21 <pathToOpusDir> <pathToBuildDir>"   exit fi  # minSdkVersion that will used for complinig Opus source minSdk=$1  # Path to directory where Opus sources are located opusPath=$2  # Path to directory where to place build artifacts # By default it creates dir where this script is located if [[ $# -eq 3 ]] then   buildPath=$3 else   buildPath=./opus_android_build   if [[ -d $buildPath ]]; then     echo "opus_android_build directory already existed..."     echo "clearing opus_android_build directory..."     rm -rfv $buildPath && mkdir $buildPath   else     echo "creating opus_android_build directory..."     mkdir opus_android_build   fi fi  echo "Executing autogen.sh" $opusPath/autogen.sh  if [[ $minSdk -lt 21 ]] then   triples=("armv7a-linux-androideabi" "i686-linux-android")   abis=("armeabi-v7a" "x86") else   triples=("aarch64-linux-android" "armv7a-linux-androideabi" "i686-linux-android" "x86_64-linux-android")   abis=("arm64-v8a" "armeabi-v7a" "x86" "x86-64")   fi  BIN=$TOOLCHAIN/bin cd $buildPath   for i in ${!triples[@]} do   triple=${triples[$i]}   abi=${abis[$i]}   echo "Building $abi..."    if [[ $triple == "armv7a-linux-androideabi" ]]   then     export AR=$BIN/arm-linux-androideabi-ar     export AS=$BIN/arm-linux-androideabi-as     export CC=$BIN/$triple$minSdk-clang     export CXX=$BIN/$triple$minSdk-clang++     export LD=$BIN/arm-linux-androideabi-ld     export RANLIB=$BIN/arm-linux-androideabi-ranlib     export STRIP=$BIN/arm-linux-androideabi-strip   else     export AR=$BIN/$triple-ar     export AS=$BIN/$triple-as     export CC=$BIN/$triple$minSdk-clang     export CXX=$BIN/$triple$minSdk-clang++     export LD=$BIN/$triple-ld     export RANLIB=$BIN/$triple-ranlib     export STRIP=$BIN/$triple-strip   fi    mkdir $abi && cd $abi    $opusPath/configure --host $triple   make   cd ..   done  echo "Artifacts successfully built for minSdkVersion=$minSdk and ABIs:"  printf '%s ' "${abis[@]}" echo "" echo "Artifacts are located in .libs directory"

Если использовать скрипт, то объявлять ENV-переменные в .bashrc нет необходимости, так как они объявлены в скрипте.

Чтобы использовать скрипт, нужно сделать его исполняемым:

$ sudo chmod +x buildOpus.sh

Далее необходимо прописать путь к NDK на вашей машине и поменять HOST_TAG, если вы не на Linux (в скрипте значение linux-x86_64):

  • 32-bit Windows: windows
  • 64-bit Windows: windows-x86_64
  • macOS: darwin-x86_64

Затем запускаем скрипт таким образом:

$ ./buildOpus.sh 21 <pathToOpusDir> <pathToBuildDir>

Ему нужно передать minSdkVersion и путь к папке с исходниками Opus. Опционально можно передать путь к папке, куда поместим артефакты сборки. По умолчанию создаётся папка opus_android_build в папке, где расположен скрипт buildOpus.sh.

Выполнение скрипта займёт некоторое время. Потом в папке opus_android_build или в той, которую вы передали, будут располагаться папки с названиями ABI, под которые была скомпилирована библиотека. Соответственно, внутри каждой папки будет уже знакомая нам папка .libs, в которой лежат все артефакты сборки.

Добавляем собранные библиотеки в проект

Отлично, у нас есть 4 файла libopus.a под разные архитектуры, самое сложное позади. Осталось добавить их в наш Android-проект и поправить CmakeLists.txt, чтобы к итоговой .so линковалась скомпилированная нами статическая библиотека Opus.

У нас есть папка app/src/main/cpp, в которой лежит наш C-враппер (jniopus.c), к которому идут external вызовы из Kotlin. В ней создаём папку includes. Затем в неё копируем содержимое папки include из исходников Opus. Там находятся файлы хедеров (.h). Они нам нужны для использования функции и структуры Opus в нашем C-враппере. Файлы хедеров содержат прототипы функций и являются своего рода контрактом, который определяет, какие аргументы может принимать и возвращать та или иная функция.

После этого в той же в папке app/src/main/cpp создадим папку libopus, а внутри неё 4 папки с названиями, идентичными названиям ABI, под которые мы скомпилировали библиотеку Opus: armeabi-v7a, arm64-v8a, x86, x86-64. В каждую из них помещаем файл libopus.a, скомпилированный под соответствующую архитектуру.

Далее модифицируем CmakeLists.txt, который был в предыдущей статье, где мы собирали Opus из исходников прямо в проекте. Сначала удаляем «простыню» со всеми путями к исходникам. Затем задаём переменные для путей:

set(NATIVE_SOURCES_PATH "${PROJECT_SOURCE_DIR}/src/main/cpp") set(OPUS_HEADERS_PATH "${NATIVE_SOURCES_PATH}/includes") set(OPUS_LIB_PATH "${NATIVE_SOURCES_PATH}/libopus")

Теперь добавляем Opus в сборку:

add_library(         libopus         STATIC         IMPORTED)

add_library используется для добавления библиотеки в сборку. Сначала идёт имя, которое у неё будет, далее тип библиотеки STATIC(.a) или SHARED(.so) и путь к исходникам, либо слово IMPORTED, если у нас уже собранная библиотека и мы хотим её использовать. В этом случае путь к готовым (импортируемым) библиотекам указывается ниже с помощью конструкции:

set_target_properties( # Specifies the target library.         libopus PROPERTIES         IMPORTED_LOCATION "${OPUS_LIB_PATH}/${ANDROID_ABI}/libopus.a")

ANDROID_ABI это NDK toolchain аргумент, который сам туда подставит ABI.

Для компиляции нашего С-враппера, куда идут external вызовы из Kotlin, мы оставляем:

add_library( # Sets the name of the library.         jniopus          # Sets the library as a shared library.         SHARED          # Provides a relative path to your source file(s).         ${NATIVE_SOURCES_PATH}/jniopus.c)

Также оставляем библиотеку, которая идёт «из коробки» в NDK для логирования таким образом:

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)

И в конце мы объединяем всё это вместе:

target_link_libraries(         # Specifies the target library.         jniopus          # Specifies the libraries that should be linked to our target         libopus ${log-lib})

Первым параметром идёт имя итоговой библиотеки, далее идут имена библиотек, которые нужно прилинковать к нашей. Вот и всё, теперь синхронизируем проект с помощью Gradle, нажимаем Run, и готово. Приложение открывается, жмём на кнопку Start call и говорим. Если тут же слышим то, что сказали, значит всё работает как надо.

Что получилось

В статье мы рассмотрели, как с помощью NDK toolchains собрать нативную библиотеку под разные ABI и использовать результирующие библиотеки у себя в проекте. Таким образом, мы сильно сократили размер CmakeLists.txt и удалили все исходники нативной библиотеки. Я не стал освещать, как собирать библиотеки без Autoconf и как быть, если у вас pre-19 NDK, потому что иначе статья получилась бы слишком длинной. Однако если этот вопрос вам интересен, напишите в комментариях, и я расскажу об этом в следующей статье.

Ссылки

Предыдущая статья «Как использовать нативные библиотеки в Android»: https://vk.com/@forasoft-kak-ispolzovat-nativnye-biblioteki-v-android

Репозиторий с проектом из статьи: https://gitlab.com/vitaliybelyaev/opus-android

Opus codec: http://opus-codec.org/, https://github.com/xiph/opus

Using the NDK with other build systems: https://developer.android.com/ndk/guides/other_build_systems

Android-документация по CMake: https://developer.android.com/studio/projects/add-native-code#create-cmake-script

Разные ABI на Android: https://developer.android.com/ndk/guides/abis

ссылка на оригинал статьи https://habr.com/ru/company/e-Legion/blog/487046/