Android (안드로이드)

[Android] NDK / JNI (2) - 적용해보기

Oscar:) 2024. 12. 6. 22:00
728x90

 

 

 

 

이번 포스팅에서는 로컬 환경에 NDK를 설치 · 안드로이드 프로젝트에 세팅하고,

JNI를 사용하여 C언어로 작성된 코드를 안드로이드 환경에서 호출해본다.

 

 

 

 


 

 

NDK 설치 · 안드로이드 프로젝트에 세팅

 

 

✅ 안드로이드 스튜디오에서 NDK 설치

 

[Tools] → [SDK Manager]  → [Android SDK] → [SDK Tools] 탭에 진입한다.

 

 

그리고 NDK를 찾아서 원하는 버전을 고르고 [Apply] 해준다.

위에 적힌 [Android SDK Location]에서 설정된 경로를 확인해줘야 한다.

 

 

 

 

 

예제에서는 현시점 가장 최신 버전 28.0.12674087을 설치했다.

 

 

설치가 끝나면 [Finish] 눌러준다.

 

 

 

 

 

✅ NDK 설치 확인

 

아까 말했던 경로를 직접 찾아가면 [ndk] → [설치한 버전명] 폴더가 생성되어 있다.

 

 

NDK가 잘 설치된 모습이다.

 

 

 

 

 

✅안드로이드 스튜디오 내 프로젝트에 NDK 경로 세팅

 

이제 안드로이드 스튜디오에 해당 경로를 세팅해줘야 한다.

[File] → [Project Structure] → [SDK Location] 탭에 진입한다.

 

 

2번째에 있는 Android NDK location에 경로를 세팅하면 된다.

하지만 간혹 위 처럼 경로를 입력할 수 없는 현상이 발생한다.

 

 

 

 

 

당황하지 말고 [Gradle Scripts] → [local.properties] 에 진입해준다.

 

 

그리고 위와 같이 NDK 경로를 직접 입력해주자.

 

 

 

 

 

다시 [File] → [Project Structure] → [SDK Location] 탭에 진입하면,

조금 전에 직접 입력했던 경로가 채워져있는 것을 확인할 수 있다.

 

 

 

 

 

 

그리고 그래들 파일에도 NDK 버전을 정의해주자.

 

build.gradle.kt

android {
    ...
    ndkVersion = "28.0.12674087"
}

 

설치한 NDK 버전과 동일하도록 작성해주면 된다.

 

사실 이 부분까지는 굳이 안해도 된다.

필자처럼 NDK 버전을 여러개 설치하는 등 삽질을 했다면...

NDK 빌드 환경을 통일해주는 과정이 필요할 뿐이다.

 

 

 

 

 

✅환경 변수 세팅

 

NDK를 빌드하기 위해  환경 변수에 NDK 경로를 추가해야 한다.

[제어판] → [환경 변수] 진입하여 Path를 더블클릭한다.

 

 

 

그리고 위 처럼 NDK 경로를 추가해준다.

 

이제 NDK 설치 및 경로 설정이 끝났다.

 

 

 

 

 


 

JNI 예제

 

 

✅Java 파일 세팅

 

JNI 예제라서 Java 파일을 세팅한다고 표현했지만,

Java와 100% 호환을 자랑하는 Kotlin으로 예제를 작성한다.

 

 

MainActivity.kt

class MainActivity {

    external fun getNativeTestText(): String
    
    override fun onCreate() {
        ...
        System.loadLibrary("nativetest-lib")
        bind.textview.text = getNativeTestText()
    }

}

 

유심히 봐야할 부분은 2가지다.

 

● System.loadLibrary()

네이티브 함수를 사용하기 전에, 네이티브 라이브러리를 먼저 로드해야 한다.

아직 생성하지 않았지만, 이제 곧 NDK 빌드를 통해 .so 파일을(공유 라이브러리) 생성할 것이다.

 

● external fun getNativeTestText(): String

코틀린에서는 external 키워드로 네이티브 함수를 선언할 수 있다.

네이티브 코드에서 반환받을 타입을 명시해주고, 함수명을 정확히 기억해야 한다.

이 함수명을 C 코드에서 동일하게 작성해줘야하기 때문이다.

 

그리고 TextView를 하나 만들어서 setText() 해줄 예정이다.

 

 

 

 

 

✅JNI 패키지 세팅

 

안드로이드 스튜디와 좌측탭을 [Project] 형식으로 변경 후,

[main] 패키지 하위로 [jni] 폴더를 만들어주자.

 

그리고 [jni] 폴더에 다음과 같이 3가지 파일을 생성해줄 것이다.

- nativetest-lib.c

- Android.mk

- Application.mk

 

 

 

 

 

nativetest-lib.c

#include <jni.h>

JNIEXPORT jstring JNICALL Java_com_example_testapp_MainActivity_getNativeTestText(JNIEnv *env, jobject obj) {
    return (*env)->NewStringUTF(env, "네이티브 함수 호출!");
}

 

단순히 문자열 데이터 하나를 반환하는 함수인데,

JNI, C 공부가 부족한 사람에겐 생소한 문법이 많이 보인다.

간단히라도 풀이해보자.

 

 

● #include <jni.h>

JNI 관련 함수와 데이터 타입을 사용하기 위한 헤더 파일 include

 

 

● 엄청 긴(?) 함수명

- JNIEXPORT : 함수가 네이티브 라이브러리에서 외부로 공개됨을 의미 (JNI 함수에서는 필수)

- jstring : 함수의 반환 타입. Java의 String을 JNI에서는 jstring으로 사용

- JNICALL : JNI 함수 호출 규칙 (JNI 함수에서는 필수)

 

- Java_<패키지명>_<클래스명>_<함수명> : JNI 문법 상의 고정된 함수명이다.

반드시 Java 파일에서 작성한 함수명을 동일하게 작성해줘야 한다.

 

- 파라미터 : JNI 함수에 반드시 포함되는 2개의 파라미터

    - JNIEnv : JNI 환경을 나타내는 포인터이며, 이 포인터로 객체 생성, 데이터 변환 등의 작업 수행

    - jobject : 이 함수를 호출한 Java 객체를 나타낸다.

상황에 따라 위 2개의 파라미터를 사용하지 않을 수도 있다.

 

 

● 반환 문자열

C의 문자열(const char*)은 Java(String)의 문자열과 형식이 다르다.

그렇기에 (*env)->NewStringUTF(env, "문자열") 문법을 사용하여

Java에서 인식할 수 있는 jstring 객체로 변환 후 반환해야 한다.

 

 

 

 

 

Android.mk

# 소스 파일의 위치 지정
LOCAL_PATH := $(call my-dir)

# LOCAL 값 중복 제거
include $(CLEAR_VARS)

# 모듈 이름 지정
LOCAL_MODULE := nativetest-lib

# 소스 파일 지정
LOCAL_SRC_FILES := nativetest-lib.c

# 공유 라이브러리로 빌드
include $(BUILD_SHARED_LIBRARY)

 

빌드할 네이티브 라이브러리를 정의하는 파일이다.

각 줄마다 주석을 작성했으니 추가적인 설명은 생략한다.

 

 

 

 

 

Application.mk

APP_ABI := all

여러 CPU를 지원하기 위해 작성하는 파일이다.

 

특정 CPU 아키텍처만 지원하려면 다음과 같이 명시할 수 있다.

APP_ABI := armeabi-v7a arm64-v8a

 

● 참고

arm64-v8a : ARM 64비트 아키텍처

armeabi-v7a : ARM 32비트 아키텍처

x86 : Intel 32비트 아키텍처

x86_64 : Intel 64비트 아키텍처

 

 

 

 

 

그리고 그래들에 다음과 같이 Android.mk 파일을 명시해준다.

 

build.gradle.kts

android {
    ...
    externalNativeBuild {
        ndkBuild {
            path("src/main/jni/Android.mk")
        }
    }
}

 

 

 

 

 

 

✅so 파일 생성 (NDK 빌드)

 

이제 터미널을 열고 프로젝트의 app/src/main/jni 폴더로 이동 후,

ndk-build

입력해준다.

 

 

빌드가 완료되면 자동으로 libs 폴더가 생성되며,

그 하위에 다음과 같이 공유 라이브러리 파일들이 생성된다.

 

 

Application.mk 파일에서 APP_ABI := all 로 작성했기 때문에,

다양한 CPU 아키텍처마다 폴더가 생성되었음을 볼 수 있다.

 

 

 

 

 

✅결과

 

이제 앱을 실행해보면..!

 

 

JNI를 사용하여 C로 작성한 함수를 호출하는데 성공했다.

 

 

 

 

 


번외 : 간단한 성능 테스트

 

네이티브 언어의 성능이 얼마나 좋길래 이렇게까지 해야하는지...

궁금해졌기 때문에 간단한 연산을 진행해보고 싶다.

 

 

 

 

C와 Kotlin으로 단순 반복문을 10만번 정도 돌려보고 속도 차이를 확인해보자.

 

 

 

nativetest-lib.c

JNIEXPORT jint JNICALL Java_com_itxai_testapp_MainActivity_getNativeSum(JNIEnv *env, jobject obj, jint num1, jint num2) {
    int value = 0;
    for (int i = 0; i < 100000; i++) {
        value += num1 + num2;
    }
    return value;
}

 

int 타입을 반환할 것이기에 반환타입에 jint 작성해주고,

기본 파라미터 2개(JNIEnv, jobject)를 제외하고도 추가로 파라미터 2개를 작성해줬다.

 

 

 

MainActivity.kt

external fun getNativeSum(num1: Int, num2: Int): Int

private fun getSumValueInNative(num1: Int, num2: Int) {
    Log.d(TAG, "C - 시작 시간: ${System.currentTimeMillis()}")
    Log.d(TAG, "C - 결과 값: ${getNativeSum(num1, num2)}")
    Log.d(TAG, "C - 종료 시간: ${System.currentTimeMillis()}")
}


private fun getSumValue(num1: Int, num2: Int) {
    Log.d(TAG, "Java - 시작 시간: ${System.currentTimeMillis()}")
    var value = 0
    for (i in 0 until 100000) {
        value += num1 + num2
    }
    Log.d(TAG, "Java - 결과 값: $value")
    Log.d(TAG, "Java - 종료 시간: ${System.currentTimeMillis()}")
}


override fun onCreate() {
    ...
    System.loadLibrary("nativetest-lib")
    
    getSumValueInNative(3, 6)
    getSumValue(3, 6)
}

 

네이티브 함수에 파라미터를 넣어서 호출하고,

Kotlin으로 작성한 반복문이 포함된 함수도 동일한 파라미터를 넣어서 호출했다.

 

 

 

 

 

결과)

 

 

10만번의 반복문 연산 속도 측정 결과는 다음과 같다.

 

C : 1ms

Java : 11ms

 

C가 압도하는 모습을 보여준다.

 

생각보다 차이가 심해서 놀람..

 

 

 

 

 

 


 

 

 

 

 

이번 포스팅에서는 NDK와 JNI를 직접 다뤄보았다.

 

NDK와 JNI를 폭 넓게 사용하려면 C언어 공부도...해야할 것..같다.

 

 

 

728x90