Building a static library via CMake for Android

Being not an Android developer, but having a good basic knowledge of Java, I had a small research task for the Android platform, for the solution of which I had to integrate a third-party c / c ++ library into an Android Studio project. This article will:

  • a step-by-step description of how to build a c / c ++ project that is correctly configured for the CMake build system
  • integrating the resulting library into an Android project through Android Studio

Introduction

Searching for keywords in the Russian-speaking segment of the Internet surprisingly gave few results on this topic. But there was only one fairly detailed article on Habré https://habr.com/ru/company/e-Legion/blog/487046/, with which you will notice both similarities and differences. To compare the approaches, I decided to use an open source project as an example. https://opus-codec.org, as in the specified article. The system on which all experiments of MacOS Big Sur will be executed with cmake version 3.19.3 on board.

I want to note right away that I am not an expert on CMake and Android. All actions described in the article, you do at your own peril and risk and the author does not bear any responsibility for your time spent.

Start

As a mobile developer, I rarely deal with c / c ++ libraries, and even more so with the CMake build system. But the task is interesting, at least a little distraction from the routine pulling json on the UI. The deadlines are sane, you can figure everything out yourself, and not copy-paste solutions with stackoverflow. So, let’s download the project from github and start our journey into the world of adventures:

git clone git@github.com:xiph/opus.git
cd opus

Regarding CMake, before I started this mini project, I knew that:

  1. it is a project build system
  2. the description of how to build the project is in the CMakeLists.txt file
  3. the assembly takes place in two stages:

cmake -S . -B ./build

where through -S we indicate where CMakeLists.txt is located, in this case we are located in the same place as the project, therefore we transfer the current directory, and through -B we transfer the directory where to add the results of preparing the project for assembly.

Let’s run this command and see what happens. The output is a lot of text. The text describes how the project was set up for assembly for the current default toolchain. Of all this, we are only interested in the following lines

-- The C compiler identification is AppleClang 13.0.0.13000029
-- Check for working C compiler: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/cc - skipped

That is, CMake set the local clang compiler to compile the project by default. Moreover, to build a static library, you need not only a compiler, but also a linker and other auxiliary programs, although we do not see them in the logs, but if you look at the created file ./build/CMakeCache.txt then there you will find more complete information on which auxiliary programs were exhibited. We will focus our attention only on some of them, namely:

CMAKE_C_COMPILER:FILEPATH=/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/cc
CMAKE_AR:FILEPATH=/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ar
CMAKE_LINKER:FILEPATH=/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ld
CMAKE_RANLIB:FILEPATH=/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ranlib
CMAKE_STRIP:FILEPATH=/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/strip

As you can see, all these programs belong to the common directory. XcodeDefault.xctoolchain

cmake --build ./build

We call cmake again, but with different parameters, namely with the parameter --build and then the directory from the previous step (if the library takes a long time to build, then through the parameter -j you can specify the number of threads cmake can be used to build a project). And we see the successful assembly of the library:

[100%] Linking C static library libopus.a
[100%] Built target opus

We have assembled a static library, it remains only to integrate it into the project. But first, let’s see what architecture our library is built for:

lipo -archs ./build/libopus.a

where the result would be x86_64, which tells us that the library is compiled for a local machine, because CMake set up the project in this way at the first stage (on board the machine used to build there is an Intel Core i5, if we were building the project on new Apple M chips, then the architecture would be arm64). Moreover, the library is compiled by a local compiler using the local toochain. That is, even if we have another computer, for example, with Ubuntu on the same Intel Core i5 and we put the library we have built on it, then our library will not be compatible with local Ubuntu binaries, because our library is built with a different toolchain ( in this case macOS x86_64).

From all this, the conclusion follows that in order to build a library for Android, we need CMake to use instead of the local toolchain, toochain, which is provided by the Android platform. I didn’t know how to do this, so keyword search android cmake toolchain leads to https://developer.android.com/ndk/guides/cmake where it is written that the NDK contains a special cmake toochain file that simplifies building projects for Android. Find out what the NDK is https://developer.android.com/ndk, and this is the Native Development Kit – a set of tools for working with c / c ++ code on the Android platform.

That is, in short, we need the NDK to access the Android toolchain. NDK installation is described in detail here https://developer.android.com/studio/projects/install-ndk… After the NDK is installed, we can move to the variable directory where the NDK is located

NDK=$HOME/Library/Android/sdk/ndk/21.4.7075529/

then following the instructions https://developer.android.com/ndk/guides/cmake#usage we got a team for preparing a project for assembling for the Android platform.

cmake -S . -B ./build 
    -DCMAKE_TOOLCHAIN_FILE=$NDK/build/cmake/android.toolchain.cmake  
    -DANDROID_ABI=$ABI 
    -DANDROID_NATIVE_API_LEVEL=$MINSDKVERSION

where we see previously undefined variables $MINSDKVERSION and $ABI$MINSDKVERSION describes the minimum Android version with which the library will work, that is, by exposing, for example, 29, the library can be integrated into Android 10+ projects. The relationship between SDK and OS versions is described here https://developer.android.com/studio/releases/platforms

It remains to deal with $ABI… Again, there is good documentation on the Android development site. https://developer.android.com/ndk/guides/abis… In short, the ABI on Android can be of 4 kinds armeabi-v7a, arm64-v8a, x86 and x86_64 at least (the most popular). A library built for the ABI of one Android device will not be compatible with an Android device with a different ABI. Therefore, in order for your library to work on all (or most) devices, you need to build a library for each ABI, but more on that later.

Now for a trial build, select arm64-v8a ABI and repeat the same steps that we did earlier when we assembled for a local machine:

  • Removing the old assembly
rm -fr ./build

  • Preparing the project for assembly with respect to the Android toolchain
cmake -S . -B ./build 
    -DCMAKE_TOOLCHAIN_FILE=$NDK/build/cmake/android.toolchain.cmake  
    -DANDROID_ABI=arm64-v8a 
    -DANDROID_NATIVE_API_LEVEL=29

and let’s take a look at the created ./build/CMakeCache.txt

CMAKE_AR:FILEPATH=/Users/mikhaildemidov/Library/Android/sdk/ndk/21.4.7075529/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android-ar
CMAKE_LINKER:FILEPATH=/Users/mikhail/Library/Android/sdk/ndk/21.4.7075529/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android-ld
CMAKE_RANLIB:FILEPATH=/Users/mikhail/Library/Android/sdk/ndk/21.4.7075529/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android-ranlib
CMAKE_STRIP:FILEPATH=/Users/mikhail/Library/Android/sdk/ndk/21.4.7075529/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android-strip
CMAKE_C_COMPILER:FILEPATH=/Users/mikhail/Library/Android/sdk/ndk/21.4.7075529/toolchains/llvm/prebuilt/darwin-x86_64/bin/clang

where we can see that the compiler, linker and other auxiliary programs are supplied from the Android NDK. All this magic happens thanks to CMAKE_TOOLCHAIN_FILE parameter that we passed.

  • Now we just run compilation and final assembly of the library.
cmake --build ./build -j 4

After successful completion, we can check for which architecture the library is built:

objdump -x ./build/libopus.a | grep architecture

which will give us

architecture: aarch64

which is true for ABI arm64-v8a

Building a library for all ABIs

We will get a similar script:

for ABI in "arm64-v8a" "x86_64" "x86" "armeabi-v7a"; do

    DESTINATION_DIR=./build/opus/$ABI
    mkdir -p $DESTINATION_DIR

    cmake -S . -B $DESTINATION_DIR 
        -DCMAKE_TOOLCHAIN_FILE=$NDK/build/cmake/android.toolchain.cmake 
        -DANDROID_ABI=$ABI 
        -DANDROID_NATIVE_API_LEVEL=29

    cmake --build $DESTINATION_DIR -j 4

done

As a result, we have collected 4 libraries:

./build/opus/arm64-v8a/libopus.a
./build/opus/armeabi-v7a/libopus.a
./build/opus/x86_64/libopus.a
./build/opus/x86/libopus.a

Now let’s see what architectures we have assembled:

objdump -x ./build/opus/arm64-v8a/libopus.a | grep architecture
architecture: aarch64
objdump -x ./build/opus/armeabi-v7a/libopus.a | grep architecture 
architecture: arm
objdump -x ./build/opus/x86_64/libopus.a | grep architecture 
architecture: x86_64
objdump -x ./build/opus/x86/libopus.a | grep architecture 
architecture: i386

Integration of the assembled library into the Android Studio project

We have assembled the library, now let’s start adding it to the Android project through Android Studio. To work with a library from an Android project, we need:

  1. The library itself (* .a file)
  2. Library interface (the interface of c / c ++ libraries is described in header files, in most cases they have the * .h extension, in programmer slang they are just header files)

Create an Android project in Android Studio as File -> New -> New Project -> Native C ++. Then Android Studio will create a template c / c ++ source file for us and the corresponding CMakeLists.txt file in which it will describe how to build it. To add our previously assembled library, we just need to slightly supplement the CMakeLists.txt file, where we need to specify the path to the library that we have collected and the path to the directory with the header files (if we use the API in native code).

But first, let’s copy the directory ./build/opus/ in the Android project, namely in the directory where the CMakeLists.txt file (my directory is called cpp inside the project). And put all the necessary header files in the directory /opus/include

Changes for CMakeLists.txt

  • We describe the name of our library, what kind it is (static) and mark it as imported.
add_library(opus STATIC IMPORTED)

  • Describing the path where the library lies
set_target_properties(opus PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/opus/${ANDROID_ABI}/libopus.a)

  • And finally, we indicate the directory where the header files are located.
include_directories(${CMAKE_SOURCE_DIR}/opus/include)

and now we simply add our library to the already existing list of the linker

target_link_libraries(/* другие либы ... */ opus)

That is, before our changes, the CMakeLists.tx file was like this

cmake_minimum_required(VERSION 3.10.2)
project("nativeopuslibdemo")

add_library(nativeopuslibdemo SHARED native-lib.cpp)
find_library(log-lib log)
target_link_libraries(nativeopuslibdemo ${log-lib})

and then became like this

cmake_minimum_required(VERSION 3.10.2)
project("nativeopuslibdemo")

add_library(opus STATIC IMPORTED)
set_target_properties(opus PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/opus/${ANDROID_ABI}/libopus.a)
include_directories(${CMAKE_SOURCE_DIR}/opus/include)

add_library(nativeopuslibdemo SHARED native-lib.cpp)
find_library(log-lib log)
target_link_libraries(nativeopuslibdemo opus ${log-lib})

After all the changes, the project should build successfully. Integration is complete, now you can access the library API from native code, we only need to include (include) the necessary header files in the source file.

Final check

The project was assembled successfully, now, to be sure, we will try to do a dummy API check, that is, we will call some simple API of the library and check that everything works. I looked at the project site for their examples and took a small piece of code from there:

// native-lib.cpp

#include <jni.h>
#include <android/log.h>
#include "opus.h"

static void dummy_check() {
    OpusEncoder *encoder;
    int err;

    encoder = opus_encoder_create(48000, 2, OPUS_APPLICATION_AUDIO, &err);
    if (encoder != nullptr && err == 0) {
        __android_log_print(ANDROID_LOG_VERBOSE, "opus_native", "Opus encoder success!");
        opus_encoder_destroy(encoder);
    } else {
        __android_log_print(ANDROID_LOG_VERBOSE, "opus_native", "Opus encoder failed %i!", err);
    }
}

After launching, I saw in the application console Opus encoder success!

Outcome

After spending quite a bit of time, we managed to create a common script for building c / c ++ libraries using the cmake build system for the Android platform. I have already used this script many times on other native projects.

I am planning, perhaps, another article on how to build the same library for iOS in the form *.xcframework

Similar Posts

Leave a Reply