Собрать и заставить работать приложение с небольшим количеством нативного кода несложно. Если же вы хотите использовать нативную библиотеку, в которой много файлов, становится труднее. Сложность в том, что нативные библиотеки распространяются в виде исходного кода, который нужно компилировать под нужную архитектуру процессора. На примере аудиокодека 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