[Android] Native Application(C/C++), NDK build 및 CMake 구성

WonseokOh·2022년 5월 9일
1

Android

목록 보기
12/16
post-thumbnail

Native Application

  Android NDK는 C 또는 C++를 Android 앱에 삽입할 수 있게 해주는 도구 집합입니다. 아래와 같은 이유로 Android 프로젝트에 C/C++ 코드 또는 라이브러리를 추가가 필요할 경우가 있을 수 있습니다.

  • 플랫폼 간 앱 호환성
  • 기존 라이브러리 재사용, 재사용할 라이브러리(.so) 제공
  • 게임 프로그래밍과 같은 앱 성능 향상

Android Studio에서는 네이티브 라이브러리 컴파일 도구로 크로스 플랫폼 프로젝트에 적합한 CMake와 CMake보다 빠르지만 Android에서만 지원하는 ndk-build를 지원합니다. 하나의 프로젝트에 CMake와 ndk-build를 모두 사용하는 것은 현재 지원되지 않는다고 합니다.


기본 구성요소

  네이티브 애플리케이션을 만들기 전에 각각의 구성요소들에 대해 파악을 하면 빠르게 환경 구성을 할 수 있습니다.

  • Shared Library : 공유 라이브러리 또는 .so파일을 빌드합니다.
  • Static Library : 정적 라이브러리 또는 .a파일을 빌드합니다.
  • Java Native Interface(JNI) : 자바와 네이티브 코드와 연결해 주는 인터페이스입니다.
  • Application Binary Interface(ABI) : 애플리케이션과 바이너리(기계어)와 연결하는 인터페이스로 다양한 Android 기기에서 각각 다른 CPU를 사용하므로 서로 다른 명령 집합을 지원하기에 ABI를 명시하여 시스템과 어떻게 상호작용할지 정의합니다.
  • Manifest : 매니페스트 파일

Android용 네이티브 앱 개발을 위한 일반적인 흐름은 아래와 같습니다

  1. 앱의 설계에 맞게 java로 구현할 부분과 네이티브로 구분할 부분을 결정합니다.
  2. Android 프로젝트를 생성합니다.
  3. JNI 폴더를 생성하고 모듈 이름, 연결된 라이브러리, 컴파일할 소스 파일 등 네이티브 코드 빌드를 하기 위한 Android.mk 파일을 만듭니다.
  4. 애플리케이션 ABI, STL 등을 구성하는 Application.mk 파일도 만들어 JNI 폴더 아래에 저장합니다.
  5. ndk-build를 사용하여 네이티브 라이브러리(.so, .a)를 컴파일합니다.
  6. 실행 가능한 .dex파일을 생성하는 자바 구성요소를 빌드합니다.
  7. 앱 실행에 필요한 .so, .dex 및 기타 리소스들을 비롯하여 모든 항목을 APK 파일에 패키징합니다.

NDK-build로 구성

  ndk-build 스크립트는 NDK의 Make 기반 빌드 시스템을 사용하는 프로젝트를 빌드합니다. ndk-build를 사용하기 위해서는 jni 폴더 아래에 Android.mk 및 Application.mk가 구성되어야 합니다. 안드로이드 스튜디오에서 ndk-build를 사용하여 구성하는 법과 ndk-build 커맨드를 직접 실행해서 C 라이브러리를 빌드하는 법을 배워보도록 하겠습니다.

NDK는 안드로이드 스튜디오에서 Tools > SDK Manager에서 다운로드 받을 수 있습니다. NDK가 설치되면 내부에 ndk-build 스크립트가 있는 것을 확인할 수 있습니다. ndk-build를 사용하기 위해서는 jni 폴더 아래에 Android.mk, Application.mk가 정의되어 있어야 합니다.


Application.mk

APP_ABI := all
APP_PLATFORM := android-16
APP_STL := c++_static
APP_ALLOW_MISSING_DEPS := true

Application.mk는 ndk-build의 프로젝트 전체 설정을 지정하며, 기본적으로 애플리케이션 프로젝트 디렉터리의 jni 폴더 아래에 저장합니다. 파일 내 정의되어 있는 변수들은 공식문서를 보면 자세하게 확인할 수 있습니다.


Android.mk

LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_MODULE := jnicalculator
LOCAL_SRC_FILES := calculator.cpp

include $(BUILD_SHARED_LIBRARY)

다음은 jni 폴더 아래에 정의되어 있는 Android.mk 파일입니다. Android.mk는 빌드 시스템의 소스 파일 및 공유 라이브러리를 설명하고 여러 소스파일을 하나의 모듈로 빌드할 수 있습니다. 여기서 모듈이라는 것은 정적 라이브러리(.a), 공유 라이브러리(.so) 또는 실행파일입니다. 빌드 시스템에서는 공유 라이브러리를 애플리케이션 패키지에 넣을 뿐입니다. 위의 파일에서는 jnicalculator라는 모듈이름으로 공유 라이브러리를 생성하고 있습니다. 따라서 빌드를 하게 되면 libjnicalculator.so 파일이 생겨나게 됩니다. 마찬가지로 각 변수들에 대해서는 공식문서를 확인하시면 됩니다.

ndk-build 결과로 다음과 같이 공유 라이브러리가 빌드되는 것을 확인할 수 있습니다.


Java Code

  명령어로 직접 실행하는 것이 아닌 안드로이드 스튜디오에서 실행되는 것을 한번 살펴보도록 하겠습니다.

public class MainActivity extends AppCompatActivity {

    static {
        System.loadLibrary("jnicalculator");
    }

    public native int getSum(int num1, int num2);

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

        int num1 = 10;
        int num2 = 20;

        int sum = getSum(10, 20);
        Toast.makeText(this, "sum : " + sum, Toast.LENGTH_LONG).show();
    }
}

다음은 자바 소스코드로 int형 숫자를 두 개를 가지고 네이티브 메소드를 호출하여 결과 값을 받아서 토스트 메세지로 출력합니다.


    static {
        System.loadLibrary("jnicalculator");
    }

먼저 System.loadLibrary("jnicalculator") 함수를 호출하면 애플리케이션 시작 시 .so파일이 로드됩니다. 위에서 Android.mk에 모듈 정의할 때 jnicalculator로 설정하였기 때문에 libjnicalculator.so가 생성되었고 System.loadLibrary 메소드에는 lib과 .so를 생략하고 모듈명만 입력하면 됩니다.


 	public native int getSum(int num1, int num2);

위 메소드 선언에서 native 키워드는 메소드가 네이티브 쪽에 구현되어 있음을 JVM에게 알려줍니다.


C Code

#include "calculator.h"

JNIEXPORT jint JNICALL Java_com_example_ndkapplication_MainActivity_getSum(
        JNIEnv* env, jobject thiz, jint num1, jint num2){
    return num1+num2;
}

  위 코드는 자바 코드에서 두 가지의 int형 숫자를 받아서 덧셈을 계산하는 코드입니다. 자바 소스 코드에서 선언된 네이티브 함수와 상응하며 반환 유형은 jint로 자바 네이티브 인터페이스 사양에 정의된 데이터 유형입니다. jint 뒤에는 함수 이름으로 패키지명 + 자바 함수 이름을 기반으로 구성합니다.

  • 앞에 Java_를 추가합니다.
  • 패키지명(최상위 소스 디렉토리의 상대적인 파일 경로)의 , . 은 _로 대체합니다.
  • 액티비티 명을 추가합니다.
  • 마지막에는 네이티브 함수 이름을 추가합니다.

이러한 규칙을 통해서 함수 이름이 Java_com_example_ndkapplication_MainActivity_getSum으로 사용합니다. 이 이름이 getSum()이라는 자바 함수를 가리킵니다.


C header

#include <jni.h>
/* Header for class com_example_ndkapplication_MainActivity */

#ifndef _Included_com_example_ndkapplication_MainActivity
#define _Included_com_example_ndkapplication_MainActivity
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     com_example_ndkapplication_MainActivity
 * Method:    getSum
 * Signature: (II)I
 */
JNIEXPORT jint JNICALL Java_com_example_ndkapplication_MainActivity_getSum
  (JNIEnv *, jobject, jint, jint);

#ifdef __cplusplus
}
#endif
#endif

header 추가는 javah Tool을 정의하여 쉽게 추가할 수 있습니다. 자세한 내용은 아래 블로그를 확인하면 됩니다.


CMake로 구성

  CMake 빌드 스크립트는 CMakeList.txt로 이름을 지정해야 하는 텍스트 파일이며 크로스 플랫폼에 적합한 빌드 툴입니다. C/C++ 라이브러리를 빌드하는 다양한 명령어들을 포함하고 있습니다. ndk-build로 빌드 시에는 Android.mk, Application.mk 등 많은 것을 직접 추가를 해야했지만, CMake 빌드는 Project 생성 시 Native C++ Project로 선택하게 되면 자동으로 기본 구성이 됩니다.


CMakeLists.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.10.2)

# Declares and names the project.

project("cmakeapplication")

# 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.
        cmakecalculator

        # Sets the library as a shared library.
        SHARED

        # Provides a relative path to your source file(s).
        calculator.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.
        cmakecalculator

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

다음은 CMakeLists.txt에 작성된 스크립트 내용입니다.

  • add_library : 공유 라이브러리 또는 정적 라이브러리를 생성, 공유 라이브러리를 생성하기 위한 소스코드 입력
  • find_library : 이미 빌드된 라이브러리를 가져오기 위해 라이브러리명 지정 ex) log-lib
  • target_link_libraries : CMake에 어떤 라이브러리들을 링크할지 명시

이보다 더 자세하고 상세한 명령어들은 공식문서에서 확인할 수 있습니다.


Java Code

public class MainActivity extends AppCompatActivity {

    // Used to load the 'cmakeapplication' library on application startup.
    static {
        System.loadLibrary("cmakecalculator");
    }

    private ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        // Example of a call to a native method
        TextView tv = binding.sampleText;
        tv.setText(String.valueOf(getSum(10,20)));
    }

    public native int getSum(int num1, int num2);
}

ndk-build 차이가 없기 때문에 설명은 생략합니다. 동일하게 libcmakecalculator.so 파일이 생성되는 것을 확인하실 수 있습니다.


C Code

#include <jni.h>

extern "C" JNIEXPORT jint JNICALL
Java_com_example_cmakeapplication_MainActivity_getSum(
        JNIEnv* env,
        jobject /* this */,
        jint num1,
        jint num2) {
    return num1 + num2;
}

C Code도 ndk-build 시에 설정한 함수 이름과 동일한 규칙이 적용되는 것을 확인할 수 있습니다.


Gradle 구성

  Project 설정 시 Native C++ Project로 지정하게 되면 자동으로 모듈 수준 build.gradle에 포함이 됩니다. 아래와 같이 CMakeLists.txt 파일의 경로를 지정하면 네이티브 라이브러리를 연결할 수 있게 됩니다.

	externalNativeBuild {
        cmake {
            path file('src/main/cpp/CMakeLists.txt')
            version '3.10.2'
        }
    }

CMake로 구성 시에 ABI 지정은 ndk-build와 차이가 있습니다. ndk-build는 Application.mk에서 APP_ABI 변수로 설정을 하였다면 CMake로 구성하게 되면 build.gradle에 포함시켜야 합니다.

android {
  ...
  defaultConfig {
    ...
    externalNativeBuild {
      cmake {...}
      // or ndkBuild {...}
    }

    // Similar to other properties in the defaultConfig block,
    // you can configure the ndk block for each product flavor
    // in your build configuration.
    ndk {
      // Specifies the ABI configurations of your native
      // libraries Gradle should build and package with your app.
      abiFilters 'x86', 'x86_64', 'armeabi', 'armeabi-v7a',
                   'arm64-v8a'
    }
  }
  buildTypes {...}
  externalNativeBuild {...}
}

위처럼 ndk.abiFilters 플래그를 사용하여 지정을 하게 되면 원하는 ABI를 설정할 수 있습니다. 모든 ABI에 대해서 추가를 하고 싶으면 all로 지정을 해도 무방합니다.


Native 메소드 설정 방법

  런타임에 네이티브 메소드를 찾을 수 있는 방법은 두 가지 방법이 있습니다. RegisterNative 메소드를 사용하여 명시적으로 네이티브 메소드를 등록하거나 위에서 특정 이름 규칙으로 지정하는 방식이 있습니다. RegisterNative의 장점은 메소드가 존재하는지 미리 확인하고 JNI_OnLoad 이외의 메소드는 확인하지 않으므로 빠른 공유 라이브러리를 가질 수 있습니다.

  • JNIEXPORT jint JNI_OnLoad(JavaVM vm, void reserved) 함수 제공
  • JNI_OnLoad에서 RegisterNatives를 사용하여 모든 네이티브 메소드 등록
jint getSum(JNIEnv *env, jobject thiz, jint num1, jint num2) {
    return num1 + num2;
}

jint JNI_OnLoad(JavaVM *vm, void *reserved) {
    JNIEnv *env;
    if (vm->GetEnv((void **) &env, JNI_VERSION_1_6) != JNI_OK) {
        return JNI_ERR;
    }

    jclass c = env->FindClass("com/example/cmakeapplication/MainActivity");
    if (c == nullptr) return JNI_ERR;

    static const JNINativeMethod methods[] = {
            {"getSum", "(II)I", (void*)getSum}
    };

    int rc = env->RegisterNatives(c, methods, sizeof(methods) / sizeof(JNINativeMethod));
    if (rc != JNI_OK) return rc;

    return JNI_VERSION_1_6;
}

JNI_OnLoad에서 FindClass 호출을 실행하면 공유 라이브러리를 로드하는 클래스를 찾은 후 JNINativeMethod에 네이티브 메소드를 선언합니다. JNINatvieMethod 규칙은 name, signature, function name을 입력하게 되고 signature에는 괄호 안에는 매개변수 괄호 밖은 반환 타입입니다. 마지막으로 RegisterNatives 메소드를 통해서 공유 라이브러리를 사용하는 클래스의 컨텍스트에 등록을 하게 되면 네이티브 메소드를 사용할 수 있습니다.


참고

profile
"Effort never betrays"

0개의 댓글