[OpenCV] Empty Activity에서 안드로이드 NDK 사용

이번 글에서는 안드로이드에서 OpenCV를 연동하기 전, NDK를 사용하는 방법에 대해 알아보겠습니다.

안드로이드 NDK는 C 및 C++와 같은 언어를 사용하여 네이티브 코드로 앱의 일부를 구현할 수 있게 하는 도구 모음입니다.

안드로이드 NDK를 사용할때 JNI라는 단어를 많이 듣게되는데 JNI의 풀네임은 자바 네이티브 인터페이스(Java Native Interface)라고 하며, 자바코드가 C, C++ 또는 어셈블리 같은 다른 언어들로 작성된 라이브러리들을 호출하거나 호출되게 하는 것을 말합니다.

안드로이드 스튜디오에서 설정부터 시작해보겠습니다.

아래 순서대로 진행하시길 바랍니다.

1. 설정

우선 상단 메뉴에서 ‘Tools’ – ‘SDK Manager’를 선택합니다.

‘Android SDK’ – ‘SDK Tools’ – ‘NDK’, ‘CMake’ 선택 – ‘Apply’를 눌러 NDK를 설치합니다.
NDK 설치 후 아래 이미지에서 하늘색 네모박스 안의 Android SDK Location 경로를 복사해 줍니다.

local.properties를 들어가보면 아래와 같이 ndk.dir 경로를 추가해 줘야합니다.

여기서 [NDK Version]이라고 적혀 있는 부분은 설치되어 있는 버전에 맞춰 입력해줘야하는데 아까 위에서 복사한 경로에서 NDK 폴더에 들어가보면 버전을 알 수 있습니다.

저는 아래와 같이 입력하였습니다.

ndk.dir=C\:\\Users\\[User]\\AppData\\Local\\Android\\Sdk\\ndk\\24.0.8215888

buidle.gradle App에 들어가서 ndkVersion을 적어줍니다.

‘File’ – ‘Project Structure’를 선택한 뒤 ‘SDK Location’ – Android NDK location’에 경로가 잘 입력되어있는지 확인합니다.

2. C++ 코드 작성

‘File’ – ‘New’ – ‘New Project’를 선택한 뒤  Empty Activity를 하나 생성해줍니다.

경로도 적당히 설정해 줍니다.

activity_main.xml에서 아래와 같이 TextView에 id를 할당해줍니다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/ndk_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity에 아래와 같이 코딩해줍니다.
System.loadLibrary의 “ndk_test_lib“는 Android.mk – LOCAL_MODULE에서 동일해야합니다.

public class MainActivity extends AppCompatActivity {

    static {
        System.loadLibrary("ndk_test_lib");
    }
    public native int ndk_sum(int a, int b);

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

        textView = findViewById(R.id.ndk_text);
        int sum = ndk_sum(1, 2);
        textView.setText("1 + 2 = " + sum);
    }
}

‘Project’ – ‘Main’ 폴더 우클릭 – ‘New’ – ‘Folder’ – ‘JNI Folder’를 선택합니다.

‘File’ – ‘Settings’를 선택합니다.

‘Tool’ – ‘External Tools’에서 플러스(+)버튼을 클릭한 뒤 javah를 만들어줍니다.

Tool Settings에 아래 값들을 입력해줍니다. *************** 오타주의 *************** 

  • Program(javah 경로): C:\Program Files\Android\Android Studio\jre\bin\javah.exe
  • Argument(클래스 경로, jni 파일 경로): -classpath “$Classpath$” -v -jni $FileClass$
  • Working directory(프로젝트 jni 경로): $ProjectFileDir$\app\src\main\jni

‘MainActivity’ 우클릭 – ‘External Tools’ – ‘javah’를 선택해줍니다.

그러면 jni 경로에 [패키지명]_[클래스명]으로 된 .h파일이 하나 생깁니다.
헤더파일 명칭은 변경해도 되니, 저는 간단하게 Calculator.h로 바꿨습니다.

‘jni’ 우클릭 – ‘new’ – ‘C/C++ Source File’을 선택합니다.

동일하게 Calculator.cpp를 만들어줍니다.

.cpp에 아래와 같이 입력합니다.

#include "Calculator.h"

JNIEXPORT jint JNICALL Java_com_snj_myapplication_MainActivity_ndk_sum
  (JNIEnv *evn, jobject thiz, jint num1, jint num2) {
  jint sum = num1 + num2;
  return sum;
  }

참고로 함수 명칭은 아래 규칙을 따릅니다.

  1. 앞에 Java_를 추가합니다.
  2. 최상위 소스 디렉터리에 상대적인 파일 경로를 설명합니다.
  3. 슬래시 대신 밑줄을 사용합니다.
  4. .java 파일 확장자를 생략합니다.
  5. 마지막 밑줄 뒤에 함수 이름을 덧붙입니다.
    Java_[패키지명(‘.’대신’‘사용)][함수이름]

3. Android.mk 작성

Android.mk 파일은 빌드 시스템에 대한 소스 및 라이브러리를 설명하는 파일입니다.
‘jni’ 우클릭 – ‘new’ – ‘File’ 을 선택하여 Android.mk 파일을 만들어줍니다.

파일 이름으로 Android.mk를 입력해줍니다.

생성한 mk파일에 아래와 같이 입력합니다.
LOCAL_MODULE에 ndk_test_lib는 MainActivity.java의 System.loadLibrary “ndk_test_lib”와 동일해야합니다.

LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
LOCAL_MODULE := ndk_test_lib
LOCAL_SRC_FILES := Calculator.cpp
LOCAL_LDLIBS := -llog

include $(BUILD_SHARED_LIBRARY)

Android.mk에 대한 설명은 공식 홈페이지를 참고하시면 됩니다.

4. 기타 설정

gradle.properties에 아래와 같이 입력합니다.

android.useDeprecatedNdk=true

마지막으로 build.gradle app에 ///////////////////////// From, To 사이를 입력합니다.

plugins {
    id 'com.android.application'
}

//////////////////////////////////////////////////////////////////////////////// From
// Project Structure에서 설정한 NDK 경로를 읽어 들여 Return
def getNdkBuildPath() {
    Properties properties = new Properties()
    properties.load(project.rootProject.file('local.properties').newDataInputStream())

    def command = properties.getProperty('ndk.dir')
    if (Os.isFamily(Os.FAMILY_WINDOWS))
        command += "\\ndk-build.cmd"
    else
        command += "/ndk-build"

    return command
}
//////////////////////////////////////////////////////////////////////////////// To

android {
    compileSdk 31
    ndkVersion "24.0.8215888"

    defaultConfig {
        applicationId "[패키지명]"
        minSdk 23
        targetSdk 31
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

//////////////////////////////////////////////////////////////////////////////// From
    sourceSets.main {
        // Compile된 Native Library가 위치하는 경로를 설정합니다.
        jniLibs.srcDir 'src/main/libs'

        // 여기에 JNI Source 경로를 설정하면 Android Studio에서 기본적으로 지원하는 Native
        // Library Build가 이루어집니다. 이 경우에 Android.mk와 Application.mk를
        // 자동으로 생성하기 때문에 편리하지만, 세부 설정이 어렵기 때문에 JNI Source의
        // 경로를 지정하지 않습니다.
        jni.srcDirs = []
    }
    ext {
        // 아직은 Task 내에서 Build Type을 구분할 방법이 없기 때문에 이 Property를
        // 이용해 Native Library를 Debugging 가능하도록 Build할 지 결정합니다.
        nativeDebuggable = true
    }

    // NDK의 ndk-build 명령을 이용하여 Native Library를 Build하기 위한 Task를 정의합니다.
    //noinspection GroovyAssignabilityCheck
    task buildNative(type: Exec, description: 'Compile JNI source via NDK') {
        if (nativeDebuggable) {
            commandLine getNdkBuildPath(), 'NDK_DEBUG=1', '-C', file('src/main').absolutePath
        } else {
            commandLine getNdkBuildPath(), '-C', file('src/main').absolutePath
        }
    }

    // App의 Java Code를 Compile할 때 buildNative Task를 실행하여 Native Library도 같이
    // Build되도록 설정합니다.
    tasks.withType(JavaCompile) {
        compileTask -> compileTask.dependsOn buildNative
    }

    // NDK로 생성된 Native Library와 Object를 삭제하기 위한 Task를 정의합니다.
    //noinspection GroovyAssignabilityCheck
    task cleanNative(type: Exec, description: 'Clean native objs and lib') {
        commandLine getNdkBuildPath(), '-C', file('src/main').absolutePath, 'clean'
    }

    // Gradle의 clean Task를 실행할 떄, cleanNative Task를 실행하도록 설정합니다.
    clean.dependsOn 'cleanNative'
//////////////////////////////////////////////////////////////////////////////// To

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

코드를 실행하면 아래와 같이 표시됩니다.

참고 사이트입니다. 거의 똑같이 따라해서 써봤고, 오타나 경로를 잘 맞춘다면 문제 없이 실행됩니다.

안드로이드 스튜디오에서 JNI사용하기
Android NDK&JNI(C/C++) 사용하기
Android Mk란?